-
Notifications
You must be signed in to change notification settings - Fork 0
Scoring Algorithm
Proton Pulse tracks two separate things per game: the tier (platinum, gold, silver, bronze, borked), which comes from structured form answers and never changes for a given report, and the confidence percentage, which says how much to trust that tier for your GPU, driver, and Proton version. Most other plugins just show the raw tier. Pulse shows both.
The scoring pipeline has three stages: per-report rating derivation, per-report confidence computation, and per-game aggregation. All pure functions, no network calls. Given the raw report data and your system info, you can reproduce any score without touching a server.
Canonical source file: src/lib/scoring.ts
Key exports:
WEIGHTS // all tunable constants
deriveRating(responses) // converts form answers to a ProtonDB-compatible tier
computeConfidence(report, sysInfo) // scores a report against current system
aggregatePerGame(reports) // collapses scored reports to one (tier, confidence)
bucketByGpuTier(reports) // splits scored reports into nvidia/amd/other bucketsUnit tests: src/lib/scoring.test.ts
The full weights table is also mirrored in src/data/scoring-info.json for external tools and the proton-pulse-web web app.
Rating is derived purely from structured form answers (formResponses on a CdnReport). It mirrors ProtonDB's live API algorithm exactly and never changes for a given set of answers. You cannot set it manually.
Function: deriveRating(responses: ReportResponses): ProtonRating | null
Both protondb.ts:inferLiveRating (for live ProtonDB API reports) and NativePulseReportModal call deriveRating from scoring.ts.
Any one of these triggers borked immediately:
canInstall === 'no'canStart === 'no'canPlay === 'no'verdict === 'no'
Each of the following fields set to 'yes' adds 1 fault:
| Field | Meaning |
|---|---|
performanceFaults |
Frame rate or stuttering problems |
graphicalFaults |
Rendering artifacts, wrong colors, missing geometry |
windowingFaults |
Fullscreen or windowing problems |
audioFaults |
Sound issues |
inputFaults |
Controller or keyboard problems |
stabilityFaults |
Crashes or freezes mid-session |
saveGameFaults |
Save file corruption or failure |
significantBugs |
Other major bugs not covered above |
These 8 fields match ProtonDB's Technical Details form exactly.
| Fault count | verdictOob | Tier |
|---|---|---|
| 0 | 'yes' |
platinum |
| 0 | 'no' |
gold (works but needed tinkering) |
| 1 | any | gold |
| 2 | any | silver |
| 3+ | any | bronze |
Tinkering (GE Proton, winetricks, etc.) does not cap the rating. A tinker report can still reach platinum if verdictOob=yes.
platinum: 1.0
gold: 0.8
silver: 0.6
bronze: 0.4
borked: 0.0
Confidence is recomputed every time the game page opens. It is not stored anywhere. The output is 0-100 (raw values can exceed 100 before clamping) and answers one question: given your GPU, driver, OS, kernel, and Proton build, how much should this specific report count?
Function: computeConfidence(report: CdnReport, systemInfo: SystemInfo): number
rawConfidence =
(ratingScore + recencyBonus + protonBonus + customProtonBonus + playtimeBonus)
* gpuMultiplier * osMultiplier * kernelMultiplier
+ notesModifier + sourcePenalty + borkedStalenessPenalty
confidence = clamp(round(rawConfidence), 0, 100)
1. Base rating score (BASE_MAX = 60)
| Tier | Score |
|---|---|
| platinum | 60 |
| gold | 48 |
| silver | 36 |
| bronze | 24 |
| borked | 0 |
2. Recency bonus
| Report age | Points |
|---|---|
| < 90 days | +15 |
| 90-365 days | +5 |
| > 365 days | -5 |
3. Proton version proximity
| Proximity | Points |
|---|---|
| Same major version | +8 |
| 1 major version off | +4 |
| 2+ major versions off | 0 |
4. Custom Proton build
Reports using GE, CachyOS, TKG, ProtonPlus, or Experimental builds: +10
Detected by matching protonVersion against /ge|cachyos|tkg|protonplus|experimental/i.
5. Playtime bonus
| Duration field value | Points |
|---|---|
overTenHours |
+12 |
fourToTenHours |
+9 |
oneToFourHours |
+6 |
underOneHour |
+3 |
| unreported / empty | 0 |
6. Notes sentiment (+/- 10 max)
Parses free-form notes text. Negation-aware: "no crash" does not trigger the crash penalty.
Negative keywords (each: -3 pts, capped at -10):
crash, broken, freeze, black screen, hang, softlock, corrupted, doesn't work, unplayable, won't launch
Positive keywords (each: +2 pts, capped at +10):
perfect, flawless, works great, no issues, out of the box, excellent, runs perfectly, zero issues, works flawlessly
7. Source penalty
Native Pulse reports (source === 'user'): -5 pts. All others: 0.
The Pulse corpus is small right now, so individual Pulse reports carry less statistical weight than ProtonDB ones. The penalty goes away as the corpus grows. See WEIGHTS.SOURCE_PULSE_PENALTY to adjust.
8. Old borked staleness penalty
Borked reports older than 365 days: -20 pts. Otherwise: 0.
A borked report from four years ago does not auto-bump to bronze. That would conflate rating with confidence, and the system keeps them separate on purpose. The tier stays borked; confidence takes the hit instead. The UI flags these as worth re-testing.
The multipliers matter more than the additive factors. A platinum report from someone on a different GPU vendor can easily score lower than a gold report from someone on your exact driver version. The additive factors set a baseline; the multipliers are what makes scores actually diverge by hardware.
GPU driver multiplier (gpuDriverMultiplier())
GPU vendor detected from the gpu field:
- NVIDIA:
/nvidia|geforce|rtx|gtx|quadro/i - AMD:
/amd|radeon|rx \d|vega/i - Intel:
/intel|arc|iris|uhd/i
Driver version parsed from gpuDriver -- handles Mesa format (Mesa 23.2.1) and vendor format (555.42.02).
| Condition | Multiplier |
|---|---|
| Same vendor + exact driver (major.minor.patch) | 1.3x |
| Same vendor + driver within 2 major versions | 1.1x |
| Same vendor, mismatched driver | 1.0x |
| Different vendor | 0.5x |
| Unknown GPU on either side | 0.75x |
OS/distro multiplier (osMultiplier())
| Condition | Multiplier |
|---|---|
| Exact string match | 1.08x |
| Same distro family (see table below) | 1.04x |
| Different distro | 1.0x |
Distro family lineages tracked in code:
| Distro | Family |
|---|---|
| SteamOS | steamos, arch |
| HoloISO | holoiso, steamos, arch |
| ChimeraOS | chimeraos, arch |
| Bazzite | bazzite, fedora |
| Nobara | nobara, fedora |
| Fedora | fedora |
| CachyOS | cachyos, arch |
| Endeavour | endeavouros, arch |
| Manjaro | manjaro, arch |
| Garuda | garuda, arch |
| Arch | arch |
| Pop!_OS | popos, ubuntu, debian |
| Linux Mint | mint, ubuntu, debian |
| Elementary | elementary, ubuntu, debian |
| Ubuntu | ubuntu, debian |
| Debian | debian |
| NixOS | nixos |
Kernel version multiplier (kernelVersionMultiplier())
Parses major.minor.patch. Valve/SteamOS kernels (/valve\d+/ in version string) get special handling via the valve build number.
Standard matching:
| Condition | Multiplier |
|---|---|
| Exact match (major.minor.patch) | 1.12x |
| Same major.minor, patch within +/-2 | 1.08x |
| Same major, minor within +/-1 | 1.04x |
| Other mismatch | 1.0x |
Valve kernel special case (when both sides have a valve build number):
| Build diff | Effective match |
|---|---|
| <= 5 | 95% |
| Same major.minor | 95% |
| <= 10 | 75% |
| <= 20 | 60% |
| > 20 | 50% |
Type: CdnReport in src/types.ts
| Field | Type | Notes |
|---|---|---|
appId |
string | Steam app ID |
cpu |
string | e.g. "Intel Core i7-12700K" |
duration |
string | playtime bucket: underOneHour, oneToFourHours, fourToTenHours, overTenHours, severalHours, allTheTime, unreported
|
gpu |
string | e.g. "AMD Radeon RX 6700 XT" |
gpuDriver |
string | e.g. "Mesa 23.2.1" or "555.42.02" |
kernel |
string | e.g. "6.1.0-17-generic" or "5.13.0-valve36-1-neptune" |
notes |
string | Free-form user text |
os |
string | e.g. "SteamOS 3.5 (Holo)" or "Ubuntu 22.04" |
protonVersion |
string | e.g. "GE-Proton9-7" or "Proton 9.0-4" |
ram |
string | e.g. "16 GB" (raw string, not parsed to number) |
rating |
ProtonRating | 'platinum' | 'gold' | 'silver' | 'bronze' | 'borked' | 'pending' |
timestamp |
number | Unix seconds |
title |
string | Game title |
reportId |
number | null | Internal Pulse report ID |
source |
string | null | "Steam", "Heroic", "Epic", "Non-Steam", or 'user' for native Pulse reports |
formResponses |
ReportResponses | null | Structured yes/no answers; may be absent on older CDN reports |
After confidence is computed, reports become ScoredReport (extends CdnReport):
| Field | Type | Notes |
|---|---|---|
confidence |
number | 0-100, clamped |
gpuTier |
GpuTier | 'nvidia' | 'amd' | 'intel' | 'unknown' |
gpuArchitecture |
string | e.g. "RDNA2", "Ada", "Polaris", or "" |
recencyDays |
number | Days since report timestamp |
notesModifier |
number | -10 to +10 from sentiment parsing |
upvotes |
number | |
downvotes |
number |
The web app (game-page.js, game-stats/main.js) uses a unified recency-weighted tier across ALL reports regardless of source (ProtonDB + Pulse). Source does not affect scoring — every report is weighted identically through the same algorithm.
Function: pulseTierFromReports(allReports, liveExcess) in js/shared/scoring.js
The stats page exposes the full derivation in the "Why this tier" section at game-stats.html?app=<id>#tier-breakdown.
Tier uses a recency-weighted mean over numeric rating scores:
weightedAverage = sum(ratingScore[i] * recencyWeight[i]) / sum(recencyWeight[i])
Rating scores:
| Rating | Score |
|---|---|
| platinum | 1.0 |
| gold | 0.8 |
| silver | 0.6 |
| bronze | 0.4 |
| borked | 0.0 |
Recency bucket weights (finer granularity for age-sensitive scoring):
| Report age | Weight |
|---|---|
| < 30 days | 1.00 |
| 30-90 days | 0.85 |
| 90-180 days | 0.65 |
| 180 days - 1 year | 0.40 |
| 1-2 years | 0.20 |
| 2-3 years | 0.10 |
| 3-5 years | 0.05 |
| 5+ years | 0.02 |
Weighted average to tier:
| Weighted average | Tier |
|---|---|
| >= 0.85 | platinum |
| >= 0.65 | gold |
| >= 0.40 | silver |
| >= 0.15 | bronze |
| < 0.15 | borked |
Example: 1 platinum (1.0) + 6 gold (0.8) + 4 borked (0.0) with similar recency:
wSum = 1×1.0 + 6×0.8 + 4×0.0 = 5.8
wTotal = 11 (assuming all at weight 1.0)
avg = 5.8 / 11 = 0.527 → silver
The web confidence percentage uses a log-curve over the total evidence pool:
totalForPct = mirroredReportCount + round(protonDbLiveExcess * 0.4)
confidencePct = min(95, round(30 + log2(max(1, totalForPct)) * 18))
ProtonDB reports that aren't mirrored (only known via the live summary API) contribute at 0.4x weight to confidence. The cap is 95% — no game can reach 100% confidence.
Confidence bucket labels (used on the game page dial and stats page):
| Confidence % | Label |
|---|---|
| >= 80% | high |
| >= 50% | moderate |
| < 50% | low |
The stats page (game-stats.html) additionally computes deeper metrics via computeGameStats() in js/lib/scoring/gameStats.js:
- Working status: last 90 days, >=60% positive → "working", >=60% negative → "not working", else "mixed"
- Confidence factors: sample size (log curve, 45%), tier consistency (std dev, 35%), data freshness (recency-weighted, 20%), with a staleness cap based on median report age
-
Compatibility trend: same
computeCompatTrendas the game page (see below) - Per-Proton-version success rates
- Launch options from positive reports
- Monthly report chart (positive vs negative)
The Decky plugin (src/lib/scoring.ts) uses a slightly different aggregation with coarser recency buckets, tuned for the on-device context where system hardware info is available:
Function: aggregatePerGame(reports: ScoredReport[]): { tier: ProtonRating; confidence: number }
Plugin recency weights:
| Report age | Weight |
|---|---|
| < 365 days | 1.0 |
| 365-730 days | 0.5 |
| 730-1825 days | 0.25 |
| > 1825 days | 0.1 |
Plugin mean-to-tier thresholds:
| Score range | Tier |
|---|---|
| >= 0.9 | platinum |
| 0.7-0.89 | gold |
| 0.5-0.69 | silver |
| 0.2-0.49 | bronze |
| < 0.2 | borked |
Plugin confidence aggregation:
meanConf = sum(reportConfidence) / reportCount
sampleSizeBoost = log2(reportCount) * 5, capped at 22 pts
freshnessPenalty = linear ramp from 0 to -15 as newest report ages from 6 months to 3 years
finalConfidence = clamp(round(meanConf + sampleSizeBoost + freshnessPenalty), 0, 100)
The game page shows a short "Compatibility is improving / stable / declining" line, and the per-game stats page shows the same direction with a bar chart. Both read from one helper, computeCompatTrend in js/lib/scoring/gameStats.js, so they never disagree.
The trend compares two time windows of reports:
- Recent: newer than 90 days (
RECENT_DAYS). - Prior: 90 to 270 days old (
PRIOR_WINDOW_DAYS). Reports older than 270 days are excluded so a years-old rating cannot skew the baseline.
It is based on the share of playable reports, not an average of tier numbers. Playable means platinum, gold, or silver (POSITIVE_TIERS); bronze and borked are the problem outcomes. For each window it computes playable / total, then takes the difference:
delta = recentPlayableShare - priorPlayableShare
delta >= +0.15 -> improving
delta <= -0.15 -> declining
otherwise -> stable
Two guards keep this honest:
- A minimum of 5 reports is required in each window. If either window has fewer, the direction is
insufficient: the game page shows no trend line and the stats page says there is not enough data. This stops a tiny baseline (for example two old reports) from producing a confident verdict. - Because the signal is the playable share, a drift between two playable tiers (platinum to gold, or gold to silver) does not register as a decline. Only a real change in how often the game is playable moves the direction. A game that is overwhelmingly gold and platinum with a couple of borked reports reads as stable, not declining.
The threshold (0.15) and minimum bucket size (5) are the defaults of computeCompatTrend and can be overridden by callers.
Reports are split into three buckets before display:
-
nvidia-- GPU field matches/nvidia|geforce|rtx|gtx|quadro/i -
amd-- GPU field matches/amd|radeon|rx \d|vega/i -
other-- everything else (Intel, unknown, no GPU field)
The game page modal defaults to your GPU's bucket. Filter tabs let you switch.
Votes are stored per report in Supabase, keyed by an anonymous voter ID (random UUID persisted in localStorage). Each voter can upvote (+1) or downvote (-1) a report. There is a 3-second cooldown between votes.
Votes currently do not affect confidence scores. They are shown in the per-report UI. Adding vote weight to the formula is planned.
Source file: src/lib/voting.ts
// src/lib/scoring.ts -- WEIGHTS constant
BASE_MAX: 60,
RECENCY_RECENT: 15, // < 90 days
RECENCY_MID: 5, // 90-365 days
RECENCY_OLD: -5, // > 365 days
CUSTOM_PROTON: 10,
GPU_MATCH: 1.0, // same vendor, any driver
GPU_MISMATCH: 0.5, // different vendor
GPU_UNKNOWN: 0.75, // unknown GPU on either side
GPU_DRIVER_EXACT: 1.3, // same major.minor.patch
GPU_DRIVER_CLOSE: 1.1, // within 2 major versions
OS_EXACT: 1.08,
OS_FAMILY_MATCH: 1.04,
KERNEL_EXACT: 1.12,
KERNEL_PATCH_CLOSE: 1.08, // same major.minor, close patch
KERNEL_MINOR_CLOSE: 1.04, // same major, close minor
BORKED_DECAY_DAYS: 365,
BORKED_STALENESS_PENALTY: -20,
NOTES_MAX: 10,
PROTON_MATCH: 8, // same major version
PROTON_CLOSE: 4, // 1 major version off
SOURCE_PULSE_PENALTY: -5, // native Pulse reports only, temporary
PLAYTIME_UNDER_ONE_HOUR: 3,
PLAYTIME_ONE_TO_FOUR_HOURS: 6,
PLAYTIME_FOUR_TO_TEN_HOURS: 9,
PLAYTIME_OVER_TEN_HOURS: 12,The competing plugin (active fork of the archived OMGDuke original, available in the Decky store) shows the ProtonDB aggregated tier badge. One number, no hardware context.
Proton Pulse shows the same tier plus a confidence score that accounts for your GPU vendor and driver, OS family, kernel version, Proton version, report recency, reporter playtime, and free-form notes sentiment. Two reports with the same tier can have very different confidence scores on different hardware.
The full per-report breakdown is available in the game page modal, showing exactly which factors pushed confidence up or down for each report.