Skip to content

Scoring Algorithm

Michael DeGuzis edited this page Jul 16, 2026 · 3 revisions

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 buckets

Unit 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 derivation

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.

Borked conditions

Any one of these triggers borked immediately:

  • canInstall === 'no'
  • canStart === 'no'
  • canPlay === 'no'
  • verdict === 'no'

Fault counting (when not borked)

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 to tier

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.

Numeric rating scores (used in aggregation)

platinum: 1.0
gold:     0.8
silver:   0.6
bronze:   0.4
borked:   0.0

Confidence computation

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

Formula

rawConfidence =
  (ratingScore + recencyBonus + protonBonus + customProtonBonus + playtimeBonus)
  * gpuMultiplier * osMultiplier * kernelMultiplier
  + notesModifier + sourcePenalty + borkedStalenessPenalty

confidence = clamp(round(rawConfidence), 0, 100)

Additive factors (pre-multiplier)

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.

Multiplier factors

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%

Report schema

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

Per-game aggregation (web)

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 aggregation

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

Confidence aggregation (web)

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

Per-game stats engine

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 computeCompatTrend as the game page (see below)
  • Per-Proton-version success rates
  • Launch options from positive reports
  • Monthly report chart (positive vs negative)

Per-game aggregation (plugin)

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)

Compatibility trend

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.


GPU tier buckets

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.


Voting

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


All weights (reference)

// 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,

How this compares to bschelst/protondb-decky

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.

Clone this wiki locally