-
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 |
Aggregation reduces all per-report scores for a game to a single (tier, confidence) pair for the library badge.
Function: aggregatePerGame(reports: ScoredReport[]): { tier: ProtonRating; confidence: number }
Tier uses a recency-weighted mean over numeric rating scores:
meanRatingScore = sum(ratingScore * recencyWeight) / sum(recencyWeight)
Recency bucket weights:
| Report age | Weight |
|---|---|
| < 365 days | 1.0 |
| 365-730 days | 0.5 |
| 730-1825 days | 0.25 |
| > 1825 days | 0.1 |
Mean score to tier:
| Score range | Tier |
|---|---|
| >= 0.9 | platinum |
| 0.7-0.89 | gold |
| 0.5-0.69 | silver |
| 0.2-0.49 | bronze |
| < 0.2 | borked |
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)
Sample size boost reference:
| Reports | Boost |
|---|---|
| 1 | 0 pts |
| 5 | ~10 pts |
| 20 | ~17 pts |
| 50+ | 22 pts (cap) |
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.