Skip to content
FLICKED Docs

Concepts

Rating

What a match is worth, and why. An Elo model on the result, plus a bounded correction for how you played inside it.

The code is backend/Flicked.Api/Services/Rating.cs (the arithmetic, no database) and Services/MatchResults.cs (the one place a result is ever applied). If they disagree with this page, the code is right.

The decision everything follows from#

The result is the rating. Performance is a correction to it.

Every ladder that weights statistics heavily ends up measuring statistics. Players optimise what is measured: they take the fight that pads a K/D instead of the one that wins the round, they save instead of retaking, they farm damage on a lost round rather than trading for the plant. It is why Valve's own rank moves on wins and losses.

So the shape is fixed: an Elo expected-score model on the match result, plus a bounded modifier for how the player did. Performance is capped at a quarter of what the result is worth, and a further rule makes it structurally unable to change the sign:

A win never loses you rating. A loss never gains you rating.

However badly you play, winning moves you up. However well you play, losing moves you down. The modifier only decides how far, and that is the property the tests protect.

The formula#

For each player in a finished match:

per player
E      = 1 / (1 + 10^((opponentMean − rating) / 400))
base   = K × (S − E)
bonus  = K × 0.25 × perf
delta  = round(base + bonus), then forced to at least ±1 in the direction of the result
rating = max(100, rating + delta)
SymbolIsNotes
ratingThis player's rating before the matchNew accounts start at 1000
opponentMeanMean rating of the other teamTheir team, not the whole lobby
EExpected score, 0 to 1Standard Elo, 400-point scale
S1 win, 0 loss, 0.5 drawThe only thing the player controls as a team
K64 sliding to 32 over ten matchesSee New players
perf−1 to +1, relative to this matchSee Performance

Try it#

Result

Rating change

+16

E
K
base
bonus
0.50
32
16.0
0.0

Why the opponent's mean, and not the lobby's#

E is a claim: this rating says you should beat that team this often. The evidence for or against it is the other team. Your own teammates' ratings are not evidence about you, so they do not appear.

Team mean against team mean would be tidier and exactly zero-sum, but it rates a 2000-rated player carried by four 1000s as if they were a 1200 player. With per-player expectation the strongest player in a lopsided team gains least for winning and loses most for losing, which is the right direction. The cost is that the system is not exactly zero-sum: total rating drifts slightly when teams are uneven, which is invisible next to the noise on an instance with a few dozen players.

Why Elo and not Glicko or TrueSkill#

Glicko's rating deviation and TrueSkill's σ are the right answer when there is enough data to estimate them. On an instance with twenty players, RD barely leaves its initial value, and the machinery costs two extra columns, a migration, a time decay loop, and a number nobody can explain when they ask why they lost 14 points.

The uncertainty that matters here, this account is new and we have no idea how good they are, is captured by the K factor in one line. When there is enough data, the base term is a drop-in replacement: E and S stay as they are, and only K becomes a function of two deviations.

Performance#

perf answers one question: relative to the other people in this match, how did this player do? Not "how good is this K/D". Relative, always, and to this match only.

performance
rounds     = scoreA + scoreB
duel_i     = (kills_i − deaths_i) / rounds
perf_i     = clamp( 0.5 × (duel_i − mean(duel)) / 0.25
                  + 0.5 × (adr_i  − mean(adr))  / 25 , −1, +1 )
  • Duelling: net kills per round against the match average. A quarter-kill per round better than everybody else, about six net kills over 24 rounds, is a standout game and scores the full half.
  • Damage: damage per round against the match average. 25 clear of the field is the same kind of standout, and it credits the player who does the opening damage somebody else converts.

Measuring against the match's own average makes it immune to match length, to a stomp where everyone's figures are inflated, and to a patch that changes weapon damage. A player cannot improve their perf by picking easier opponents, because everyone is scored against the people who were there.

Why a fixed reference and not the standard deviation. A z-score would be wrong here: in a 1v1 test match the deviation is whatever the gap between two players happens to be, and every 1v1 would end with both at ±1. Fixed references keep a close game close.

What it is worth. K × 0.25, so ±8 for an established player and ±16 for a brand new one. Against a base term of ±32 (±64 provisional), the best game anyone has ever played is worth a quarter of the result.

New players#

K starts at 64 and slides linearly to 32 over the first ten matches:

k factor
K = 32 × (1 + max(0, 10 − matchesPlayed) / 10)
Matches playedKA coin-flip win is worth
064+32
548+24
10 or more32+16

A new account's 1000 is a guess, so it should move fast; a veteran's 2400 is the sum of hundreds of matches, so it should not. Ten matches gets a genuinely strong player out of the wrong bracket within an evening, without one good night minting a Division I player. matchesPlayed is Wins + Losses, so there is no separate counter to drift.

The launcher's leaderboard tags provisional players, because a rating built from two matches next to one built from three hundred is worth an asterisk.

Divisions#

A rating maps to a band in Services/Divisions.cs. The thresholds are placeholders until there are enough real matches to place them: collect the distribution, then draw the lines.

DivisionFrom rating
Division I2400
Division II2000
Division III1600
Division IV1200
Division V0

Edges#

  • The floor is 100. A rating that can fall forever is not a rank, it is a punishment, and it breaks matchmaking: a player at −400 could never be bridged to anybody by a widening window.
  • The recorded delta is the applied delta. A computed −30 against a rating of 110 is written to MatchPlayer.RatingDelta as −10, so a player's rating always equals 1000 plus the sum of their deltas.
  • Draws are real. MR12 without overtime ends 12-12. S is 0.5, neither wins nor losses move, and the minimum-change rule does not apply.
  • A match with no rounds, or with an empty team, rates nothing. There was no contest.
  • 1v1 and short matches work unchanged. Nothing in the formula assumes five a side, or that both teams are the same size.

Applied exactly once#

series_end can arrive twice. MatchZy does not deduplicate, does not retry, and does not know whether its last event was received. A rating system that is merely unlikely to double-apply will double-apply. So the right to apply a result is not a check; it is a claim on a row:

MatchResults.cs
UPDATE "Matches"
   SET "Status" = 'Finished', "ScoreA" = @a, "ScoreB" = @b
 WHERE "Id" = @id AND "Status" <> 'Finished'

This runs inside a transaction, with the ratings written in the same transaction. One row changed means this call owns the transition and applies the ratings. Zero rows means somebody else already finished the match, and nothing is written.

sequenceDiagram
    participant A as series_end (first)
    participant DB as Postgres
    participant B as series_end (duplicate)

    A->>DB: BEGIN
    A->>DB: UPDATE … WHERE Status ≠ Finished
    DB-->>A: 1 row, the claim
    B->>DB: BEGIN
    B->>DB: UPDATE … WHERE Status ≠ Finished
    Note over B,DB: blocked on A's row lock
    A->>DB: UPDATE ratings, wins, losses, deltas
    A->>DB: COMMIT
    DB-->>B: 0 rows, already Finished
    B->>DB: ROLLBACK

There is no window between the check and the write, because there is no check: the statement that decides is the statement that writes. That is why only MatchResults ever sets a match to Finished. If two places could finish a match, there would be two places that could rate one.

Worked examples#

All figures from the implementation, established players (K = 32) unless stated.

An even match#

Ten players at 1000, Team A wins 13-9. E = 0.5 for everyone, so base = ±16.

PlayerperfΔWhy
Winner, average game0.0+16The result, and nothing else
Winner, best on the server+1.0+24+8, the full quarter of K
Winner, carried−1.0+8Still up: winning is winning
Loser, top fragger+1.0−8Softened, not reversed
Loser, average game0.0−16
Loser, quiet game−1.0−24

The difference between losing well and winning badly is nothing at all: the winner takes more. That ordering is the design.

The underdog#

Team A averages 1000, Team B averages 1400. E = 0.09 for A, 0.91 for B.

OutcomeΔ for a 1000 playerΔ for a 1400 player
A wins+29−29
B wins−3+3

Beating a team 400 points above you is worth ten times beating one at your level; beating a team 400 points below is worth almost nothing, which stops anyone farming weaker opponents.

A first match#

A new account at 1000, K = 64, wins against a team averaging 1200 with an average game. E = 0.24, base = 64 × 0.76 = 48.6: +49. Three matches like it put them in Division IV, which is the point of the provisional window.

Not in the first version#

  • Rating decay. A decay before there is a season just punishes people for the project being new.
  • Seasons and resets. A reset belongs with the first real season.
  • Rating deviation. The seam is K.
  • Party-adjusted expectation. A five-stack should be expected to beat five solo players of the same rating, and one day that belongs in E.
Esc
↑↓ navigate ↵ open