Technical guide

The site

Ratings

Every game gives you a rating on one shared scale, worked out from the results the games already keep.

Checked against the code on 2026-10-31.

On this page
  1. Where it lives
  2. The scale
    1. The constants
    2. Why BETA is 400 / ln 10 / √2
  3. The update rule
    1. The arithmetic, line by line
    2. Worked: two new players, one wins
    3. TAU: why a settled rating still moves
    4. KAPPA: a guard that doesn’t bind
    5. Worked: a settled player beats a stronger one
    6. Fixed players
    7. Checked against OpenSkill
  4. Provisional ratings
  5. Fields and teams
    1. Every side meets every other
    2. Why each meeting counts 2/k
    3. Worked: a six-player free-for-all
    4. Teams: a side’s skill is its members’ summed
  6. Table games
    1. Bots’ fixed ratings
    2. How the anchors were measured
    3. Worked: a settled player against each bot
    4. Guests
    5. Seats linked later
  7. Timed puzzles
    1. Par
    2. A time’s score
    3. Against par
    4. Dailies are fields, rated at midnight
    5. Hashi, The Register, the kit, Low Poly
    6. Solitaire
  8. Chess puzzles
    1. Half a win, exactly
  9. When ratings change
    1. One game, one lock, one order
    2. Days close lazily
    3. What a post hears back
    4. Rebuilding
  10. Where ratings show
    1. The result sheet
    2. Boards
    3. Your account
    4. What players are told
  11. What keeps it fair
  12. Changing it

Every signed-in player gets a rating in each game they’ve played, all on one scale, so you can see which games you’re best at. A card game against bots, a daily puzzle forty people solved and a chess puzzle solved with a hint end in very different results. One piece of arithmetic rates them all.

This chapter explains that arithmetic, then how each game feeds it. The decision is ADR 0030; the rules as players see them are in docs/ratings.md. The worked examples are computed by the site’s own rating code as the page is drawn (guide/examples.py), so they change when a constant does.

Where it lives#

ratings/ is a small Django app, keyed by game slug. It stores nothing a game doesn’t already keep, so every rating can be thrown away and rebuilt from the games’ results.

File What it does
ratings/skill.py The arithmetic and its constants: Skill, expected(), rate(), versus(), time_score(). Pure Python.
ratings/sources.py Turns each game’s stored results into events to rate. Reads, never writes.
ratings/tables.py Table games: bots’ fixed ratings, guests, partners.
ratings/service.py Rates results as they arrive, closes a day’s fields, rebuilds; VERSION.
ratings/models.py Ladder, Rating, RatedEvent, Change.
ratings/boards.py A game’s ratings board and your account’s list.
ratings/explain.py The plain-words explanation behind the Stats tab’s “?”.
ratings/management/commands/ratings.py manage.py ratings backfill, rebuild, settle.
tools/ratings/anchors.py Measures how far apart each table game’s bot levels play.
tests/test_ratings.py The arithmetic checked against OpenSkill, and every rule below.
games/static/games/house/end.js ratingLine(): the result sheet’s “Rating +12 → 1534.”

The scale#

A rating says how strong a player is. The site uses Elo’s scale, the one chess uses. Everyone starts at 1500, and the gap between two ratings predicts how often the stronger player wins: 200 points is about three wins in four, 400 points ten to one.

Expected score by rating difference The chance the higher-rated player wins: 0.5 at no gap, about 0.76 at 200 points, 0.91 at 400 and 0.99 at 800 for two settled players; flatter when one is new. −800 −600 −400 −200 0 +200 +400 +600 +800 0 0.25 0.5 0.75 1 Your rating minus theirs Chance you win +200: 0.76 +400: 0.91, ten to one Both settled: Elo’s curve One of you new (σ = 350): flatter
The chance you win by how far your rating is above theirs. The solid line is Elo's curve, which two settled players follow exactly; the dashed one assumes one of you is new (σ = 350, the code's SIGMA when this was drawn): the gap means less until the rating is sure.

Each rating also carries an uncertainty: how sure the site is of it. A new player’s rating moves a lot after each result; a settled one moves a little. In code a rating is a mean mu (the number you see) and an uncertainty sigma.

ratings/skill.pySkill · lines 39–46
@dataclass(frozen=True)
class Skill:
    mu: float = MU
    sigma: float = SIGMA

    @property
    def shown(self) -> int:
        return round(self.mu)

A new player is Skill(), 1500 ± 350: anywhere from about 800 to 2200 wouldn’t be a surprise. Each result moves the mean by how surprising it was, scaled by how unsure we were, and narrows the uncertainty by how much it told us.

A rating with fewer than 10 rated results is provisional: it shows with a question mark and stays off the boards (below).

The constants#

Name Value What it is
MU 1500 Where everyone starts.
SIGMA 350 A new player’s uncertainty (Glicko’s starting deviation).
BETA 122.84 How much one result varies around a player’s skill: 400 / ln 10 / √2, which puts settled odds on Elo’s scale.
TAU 6 Added as variance before each result, so a settled rating can still move.
KAPPA 0.0001 The least share of its variance one update may leave a player.
PROVISIONAL 10 Rated results before a rating is established.
ratings/skill.pyBETA · lines 27–29
# How much one result varies around a player's skill, set so two settled
# players' odds are exactly Elo's: 1 / (1 + 10^(-d/400)).
BETA = 400 / math.log(10) / math.sqrt(2)

Why BETA is 400 / ln 10 / √2#

expected() is the chance that a beats b:

ratings/skill.pyexpected · lines 60–63
def expected(a: Skill, b: Skill, model: Model = ELO) -> float:
    """The chance a beats b (a tie counting half)."""
    c = math.sqrt(a.sigma ** 2 + b.sigma ** 2 + 2 * model.beta ** 2)
    return 1 / (1 + math.exp((b.mu - a.mu) / c))

With d the gap between the means:

c = √(σa² + σb² + 2β²)

P(a beats b) = 1 / (1 + e^(−d / c))          d = μa − μb

This is the Bradley-Terry model: a player’s performance on the day is their skill plus noise, and the chance one performance beats another is logistic in the gap. c holds two kinds of spread. 2β² is how much two performances vary day to day. σa² + σb² is our uncertainty about the skills themselves: if we aren’t sure who’s better, we can’t be sure who’ll win.

Elo’s curve is 1 / (1 + 10^(−d/400)). For two settled players, take the uncertainty to zero:

σa = σb = 0   ⟹   c = √(2β²) = √2 · β

β = 400 / ln 10 / √2   ⟹   c = √2 · 400 / (ln 10 · √2) = 400 / ln 10 ≈ 173.7

e^(−d/c) = e^(−d · ln 10 / 400) = (e^(ln 10))^(−d/400) = 10^(−d/400)

P(a beats b) = 1 / (1 + 10^(−d/400))          exactly Elo's

So BETA isn’t tuned: it’s the one value that puts this model on Elo’s scale. Both computed live (c for settled players is 173.72):

Gapexpected()Elo's formulaOdds
00.50000.50001.00 to 1
1000.64010.64011.78 to 1
2000.75970.75973.16 to 1
4000.90910.909110.00 to 1
8000.99010.9901100.00 to 1
Live from guide/examples.py, ODDS

Uncertainty pulls the odds toward even. If the stronger player is new (± 350), a 400-point gap expects only 73.6%, not 90.9%. TAU never lets σ reach zero, so two long-settled players (σ about 62) expect 88.7% at 400 points: very close to Elo.

The update rule#

ratings/skill.py uses one rule for every game: Weng and Lin’s Bayesian approximation for the Bradley-Terry model with full pairing (“A Bayesian Approximation Method for Online Ranking”, JMLR 12, 2011), which OpenSkill calls BradleyTerryFull. Bayesian means each result updates a belief, mean and uncertainty together. Between two players it behaves much like Glicko (Elo with uncertainty added), and it also takes any number of sides, teams, ties, partial results and fixed opponents. ADR 0030 chose it over using Glicko-2 for two-sided results and Weng-Lin for the rest: one set of constants, one meaning of “uncertain”, less to test.

ratings/skill.pyrate · lines 66–133
def rate(teams, ranks=None, score=None, fixed=None, weight=1.0, model: Model = ELO):
    """Every player's skill after one result.

    teams: sides, each a list of Skill (one player, or partners).
    ranks: a number per side, lower finishing better, equal for a tie;
      or, for exactly two sides, `score`: how well side 0 did against
      side 1, from 0 (lost) to 1 (won), so 0.5 is a draw and a hint
      can make a win worth less.
    fixed: for each side, a list of bools: a fixed player (a bot, a
      puzzle, a guest) is compared like anyone else but never changes.
    weight: how much this result counts (1 is a whole game).

    Each side meets every other once; in a field of k sides each meeting
    counts 2/k, so a free-for-all is worth between one and two games,
    however many sit down, rather than k - 1 of them (one performance
    each, not k - 1 independent ones).
    """
    k = len(teams)
    if k < 2:
        raise ValueError("a result needs two sides")
    if (ranks is None) == (score is None):
        raise ValueError("give ranks or a score")
    if score is not None and (k != 2 or not 0 <= score <= 1):
        raise ValueError("a score is for two sides, from 0 to 1")
    if ranks is not None and len(ranks) != k:
        raise ValueError("a rank for every side")
    fixed = fixed or [[False] * len(t) for t in teams]
    # Fixed players don't drift; everyone else's belief loosens a little first.
    teams = [[p if f else Skill(p.mu, math.sqrt(p.sigma ** 2 + model.tau ** 2)) for p, f in zip(t, fx)]
             for t, fx in zip(teams, fixed)]
    # A side's skill is its members' summed. Sides of different sizes (an
    # odd Bowl table: four against three) are each scaled to the sides'
    # average size, so a side is as strong as its average member and an
    # extra player isn't a free edge; sides of one size are left as they are.
    size = sum(len(t) for t in teams) / k
    scale = [size / len(t) if t else 1.0 for t in teams]
    mus = [a * sum(p.mu for p in t) for a, t in zip(scale, teams)]
    vars_ = [a * a * sum(p.sigma ** 2 for p in t) for a, t in zip(scale, teams)]
    w = weight * (2 / k if k > 2 else 1)

    def outcome(i, q):
        if score is not None:
            return score if i == 0 else 1 - score
        return 1.0 if ranks[i] < ranks[q] else 0.5 if ranks[i] == ranks[q] else 0.0

    out = []
    for i, team in enumerate(teams):
        omega = delta = 0.0
        for q in range(k):
            if q == i:
                continue
            c = math.sqrt(vars_[i] + vars_[q] + 2 * model.beta ** 2)
            p_iq = 1 / (1 + math.exp((mus[q] - mus[i]) / c))
            omega += w * vars_[i] / c * (outcome(i, q) - p_iq)
            gamma = math.sqrt(vars_[i]) / c
            delta += w * gamma * vars_[i] / c ** 2 * p_iq * (1 - p_iq)
        new = []
        a = scale[i]
        for p, f in zip(team, fixed[i]):
            if f or vars_[i] == 0:
                new.append(p)
                continue
            # (A member's part in the side's skill is a × theirs: the side's
            # step reaches them a times over, its certainty a² times.)
            share = a * p.sigma ** 2 / vars_[i]
            new.append(Skill(p.mu + share * omega, p.sigma * math.sqrt(max(1 - a * share * delta, model.kappa))))
        out.append(new)
    return out

The arithmetic, line by line#

For each side i (a player, or partners), after every rated member’s variance has been loosened by TAU:

ai  = 1, or (the sides' average size) / (i's size) when sizes differ
μi  = ai · Σ μ over i's members      σi² = ai² · Σ σ² over i's members

for every other side q:
    ciq = √(σi² + σq² + 2β²)
    piq = 1 / (1 + e^((μq − μi) / ciq))         the chance i beats q
    siq = 1 if i finished ahead, ½ if level, 0 if behind
          (or the score given, for a two-sided partial result)

Ωi = Σq  w · (σi² / ciq) · (siq − piq)            how far to move the mean
Δi = Σq  w · (σi / ciq) · (σi² / ciq²) · piq · (1 − piq)    how much it told us

for each member j of i, with share = ai · σj² / σi²:
    μj ← μj + share · Ωi
    σj ← σj · √max(1 − ai · share · Δi, κ)

w is the result’s weight times 2/k for a field of k sides (1 for two sides). Three things follow:

  • The mean moves by surprise. siq − piq is what happened minus what was expected. An expected result moves no one; an upset moves both sides a lot.
  • The step is scaled by σ²/c. A new player (σ = 350) moves much further than a settled one (σ ≈ 60) for the same surprise. That’s how a new rating finds its level in a handful of games.
  • The uncertainty shrinks by piq(1 − piq). A coin-flip game tells us the most; a foregone one almost nothing, whoever won.

Worked: two new players, one wins#

Two players at 1500 ± 350; one wins. Loosened first, σ becomes √(350² + 6²) = 350.05. Then:

c  = √(σ² + σ² + 2β²)                        = 524.6
p  = 1 / (1 + e^0)                            = 0.5
Ω  = σ²/c · (1 − p)                           = 116.78
Δ  = (σ/c) · (σ²/c²) · p(1 − p)               = 0.0743

winner  μ = 1500 + Ω                          = 1616.8
loser   μ = 1500 − Ω                          = 1383.2
both    σ = 350.05 · √(1 − Δ)                = 336.8

The winner’s sheet says “Rating +117 → 1617?”, the loser’s “Rating −117 → 1383?”. A big step, because one game told us a lot about two unknowns. (docs/ratings.md calls it “about +115”.)

TAU: why a settled rating still moves#

Without TAU, σ would only shrink, and after a few hundred results a rating would stop moving: a player who improved would never be believed. Adding τ² = 6² before each result sets a floor. σ shrinks until what a result takes away (Δ) balances what TAU adds back.

A player who alternates wins and losses against equals (sure opponents at their own rating):

Results so farσOne more win against an equal
0350+156.8
1317+139.1
3265+111.1
10171+60.2
30102+25.7
10067+12.2
30062+10.4
100062+10.4
Live from guide/examples.py, SETTLING

σ comes to rest near 62, where a win between equals is worth about 10.4 points; Elo with K = 20 gives 10. That’s what TAU was set for. tests/test_ratings.py holds the step between 6 and 14 after 200 results.

KAPPA: a guard that doesn’t bind#

KAPPA stops one update from cutting a player’s variance below 0.0001 of what it was. On this scale it never applies. Since σi² < ciq², each meeting’s term is at most (σ/c)³ · ¼ < ¼, and the 2/k weighting keeps a field’s total under 2(k − 1)/k · ¼ < ½. So 1 − share · Δ stays above ½ for any result of weight 1 or less. It’s kept because OpenSkill has it, and as a floor for heavier weights than the site uses.

Worked: a settled player beats a stronger one#

Two settled players, 1600 ± 70 and 1800 ± 70. The lower one was given 26.9% and won:

Before After Change
The winner 1600 1618.0 +18.0
The favorite 1800 1782.0 −18.0

Elo at K = 20 would move them 15.2 each. Equal σ gives equal and opposite steps. σ barely changes (70 → 69.96), because TAU put back nearly what the result took.

Fixed players#

Some opponents never change: a bot, a guest, a chess puzzle, par (a board’s usual time, below). A player marked fixed in rate() counts in the sums and in c but is never loosened or updated. One fixed opponent is common enough to have its own function:

ratings/skill.pyversus · lines 136–139
def versus(player: Skill, item: Skill, score: float, weight=1.0, model: Model = ELO) -> Skill:
    """A player's skill after a result against something fixed (a bot, a
    puzzle of known rating, a par time): score 1 won, 0 lost."""
    return rate([[player], [item]], score=score, fixed=[[False], [True]], weight=weight, model=model)[0][0]

A fixed opponent’s σ still matters. A bot is Skill(rating, 0), certain, so a result against it counts in full. A guest or par is Skill(), 1500 ± 350, which widens c, so the same result moves you less. That’s deliberate for a guest, and it matters a lot for timed puzzles (below).

Checked against OpenSkill#

rate() matches OpenSkill’s BradleyTerryFull (version 6) to eight decimal places, run at OpenSkill’s own defaults (μ 25, β 25/6, τ 25/300) through its Model dataclass: two new players, partners sharing an upset, a field of three, a three-way tie.

tests/test_ratings.pyOPENSKILL · lines 32–34
# OpenSkill's defaults (mu 25), to check the update against its published
# implementation (openskill.models.BradleyTerryFull, version 6).
OPENSKILL = Model(beta=25 / 6, tau=25 / 300)

The one deliberate difference is a field’s weighting. OpenSkill counts each meeting as a whole game; the site counts each as 2/k. The field-of-three test passes weight=1.5 (k/2) to match OpenSkill exactly, then checks that without it everyone moves two thirds as far.

Provisional ratings#

A rating is provisional until it has 10 rated results, and shows a question mark: “1534?”. The rule counts results, not σ, so it’s simple to explain and the same for every game.

ratings/models.pyRating · lines 25–53
class Rating(models.Model):
    """A player's rating at one game."""

    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ratings")
    game = models.CharField(max_length=40)
    mu = models.FloatField(default=MU)
    sigma = models.FloatField(default=SIGMA)
    rated = models.PositiveIntegerField(default=0, help_text="Rated results so far.")
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        constraints = [models.UniqueConstraint(fields=["user", "game"], name="one_rating_per_player_per_game")]
        indexes = [models.Index(fields=["game", "-mu"])]

    def __str__(self):
        return f"{self.user} · {self.game} · {self.label}"

    @property
    def shown(self) -> int:
        return round(self.mu)

    @property
    def provisional(self) -> bool:
        return self.rated < PROVISIONAL

    @property
    def label(self) -> str:
        """"1534", or "1534?" while it's provisional."""
        return f"{self.shown}{'?' if self.provisional else ''}"

“1500?” is a real rating whose mean happens to round to 1500 (a draw against an equal, say). A player with no rated results has no rating at all: no row, nothing shown. After 10 results against equals σ is about 171, half its start (the settling table): sure enough to rank, still moving fast.

  • Boards list established players only (below); you always see your own.
  • The result sheet adds the “?”. Change.sheet() reads the count after the result, so the result that reaches 10 is the first without one, and says “no longer provisional”.
  • Your account and the Stats tab show the “?” too.

Fields and teams#

Every side meets every other#

A result can have any number of ranked sides; a side is a player or a team of partners, and a field is a result with more than two. rate() treats it as every pair of sides meeting once (“full pairing”): in a six-player game each player has five meetings, won, lost or drawn by who finished ahead. Equal ranks tie and score ½.

Why each meeting counts 2/k#

Counted whole, a six-player game would be five games for each player, and one win would move a new player as far as five straight wins. But those meetings are one performance seen against five people, not five results. So each counts 2/k for k sides:

total weight = (k − 1) · 2/k = 2 − 2/k

k = 2:  1        (a game between two is one game)
k = 3:  1.33
k = 6:  1.67
k → ∞:  → 2      (never more than two games' worth)

A free-for-all is worth between one and two games, however many sit down. A bigger field says more about you (you beat more people), but not k − 1 times more. In ADR 0030’s words, “an eight-player game is worth less than two head-to-heads, not seven”.

Worked: a six-player free-for-all#

Six players, two of them new, finish in this order. Each meeting counts 0.333, so the game counts 1.67 games each. The last column counts every meeting whole, as OpenSkill would:

PlaceBeforeAfterChangeIf each meeting counted whole
11500 ± 3501758 ± 305+258+774
21650 ± 801661 ± 80+11+32
31480 ± 1201498 ± 118+18+55
41500 ± 3501478 ± 305−22−66
51720 ± 601709 ± 60−11−32
61390 ± 901374 ± 90−16−47
Live from guide/examples.py, FFA.rows

The new winner gained 258, against 117 for beating one new player head to head: more, for beating five including a 1650 and a 1720, but far from the 774 of five whole games. The 1720 in fifth lost to four lower-rated players and pays only 11: its σ is small and each meeting is a third of a game.

Teams: a side’s skill is its members’ summed#

Partners are one side. The side’s μ is the sum of its members’ μ, and its variance the sum of their variances, so two teams of two compare (μ1 + μ2) with (μ3 + μ4), and c includes all four uncertainties. The side’s step Ω is split by each member’s share of the side’s variance, σj² / σi², so each member moves by σj²/c · (s − p): their own uncertainty decides how far.

Partners who win together don’t move together. Here a new player (1500 ± 350) and a settled partner (1600 ± 70) beat two medium Euchre bots (1500 each, fixed):

Share of the side’s variance Gain σ after
The new player 96.1% +135.0 319
The settled partner 3.9% +5.4 70

The result says a lot about the new player and little about the settled one, and the update says exactly that. A bot partner (σ 0) has a share of zero: it adds its rating to the sum and never moves.

Sides of different sizes. Summing breaks when sides differ. At a Bowl table of seven, four 1500s against three sum to 6000 against 4500, “expected” to win 99.6% of the time. So each side is scaled to the sides’ average size by a = (average size) / (this side's size): μ is a · Σμ, variance a² · Σσ², each member’s step is scaled by a and their uncertainty by a² (the ai in the arithmetic). A side is then as strong as its average player. Four equal players against three are an even match, and a win is worth the same to either side, shared among its players: the four gain +10.2 each, the three +13.6 (Bowl has the example). Equal sides have a = 1, so Euchre and Get In Line are unaffected. Whether an extra player helps in Bowl (more guessers, fewer turns each at clues) hasn’t been measured; the scaling says no. SkillTests pins four against three, three against two and two against two.

Which seats are partners comes from teams_of():

ratings/tables.pyteams_of · lines 47–65
def teams_of(game: str, n: int, options: dict) -> list[int]:
    """Which side each seat plays for: partners across the table in Euchre,
    alternating round it in Get In Line and Bowl (two teams, people only),
    and Vibe Check's two teams (its co-op is one team, so it doesn't rate);
    everyone for themselves elsewhere."""
    if game in ("bowl", "vibe-check"):
        # (The teams the host picked, kept with the result: not by seat.)
        kept = (options or {}).get("_teams")
        if isinstance(kept, list) and len(kept) == n and all(t in (0, 1) for t in kept):
            return list(kept)
        return [s % 2 for s in range(n)]
    if game == "euchre":
        return [s % 2 for s in range(n)]
    if game == "get-in-line":
        from tables.get_in_line import rules

        teams = rules.team_count(n, rules.options(options)["teams"])
        return [s % teams for s in range(n)]
    return list(range(n))

In Euchre k is 2 (two sides of two), not 4, so a game counts as one game. Get In Line in three teams is a field of three. Vibe Check’s two teams are the host’s, kept with the result, as Bowl’s are; its co-op is one side, with no one to rate against, so it’s kept but not rated.

Table games#

A table game is rated when table history keeps it: it ended by the rules with someone signed in at it, and tables/history.py’s record() calls table_kept(). A game the host ended early is never kept, so never rated. One kept game is one result, ranked by places:

ratings/sources.pyTable.result_event · lines 430–450
def result_event(self, result, only: int | None = None) -> Event | None:
    """`only`: a seat linked after the game was rated (it was kept for
    someone else first): rate that seat alone, everyone else as they
    stand."""
    seats = sorted(result.seats.all(), key=lambda s: s.index)
    teams = teams_of(self.game, len(seats), result.options or {})
    sides, ranks = {}, {}
    for s in seats:
        if s.bot:
            seat = Seat(skill=bot(self.game, s.bot_level))
        elif s.user_id:
            seat = Seat(user=s.user_id, frozen=s.covered or (only is not None and s.index != only))
        else:
            seat = Seat(skill=UNKNOWN)
        sides.setdefault(teams[s.index], []).append(seat)
        ranks[teams[s.index]] = min(ranks.get(teams[s.index], s.place), s.place)
    order = sorted(sides)
    if len(order) < 2 or not any(seat.user and not seat.frozen for side in sides.values() for seat in side):
        return None
    return Event(self.game, f"result:{result.pk}" + (f":{only}" if only is not None else ""), "table", result.finished,
                 [sides[t] for t in order], ranks=[ranks[t] for t in order], detail={"seats": len(seats)})
Seat Becomes Changes?
A bot bot(game, level): its fixed rating, σ 0 Never
A signed-in player Their rating at this game (or Skill()) Yes, unless a bot covered their seat for part of the game (covered): then they count at their rating but aren’t rated, since it wasn’t all their play
Anyone else (a guest, a deleted account) UNKNOWN: 1500 ± 350 Never

A side ranks at its best member’s place. A game with fewer than two sides, or no one rateable, rates nothing. Each seat’s view hears only its own change (tables/service.py asks for_result() for that seat’s user), and a spectator hears none.

Bots’ fixed ratings#

Each table game with bots has three levels with fixed ratings. Medium is 1500, a typical family player; easy and hard sit from it by how far apart the levels play each other:

GameEasyMediumHard
auction-house148015001540
bullshit121015001540
contract-rummy133015001500
euchre110015001520
fictionary147015001560
get-in-line135015001510
last-one-standing138015001560
michigan-rails131015001550
michigan-rummy110015001570
millbury-mansion116015001510
quiz-whiz110015001900
sixty-six115015001740
Live from ratings/tables.py, ANCHORS
ratings/tables.pybot · lines 42–44
def bot(game: str, level: str) -> Skill:
    """A bot's fixed rating: certain (sigma 0), so it never moves."""
    return Skill(ANCHORS.get(game, DEFAULT_ANCHORS).get(level, DEFAULT_ANCHORS.get(level, MU)), 0.0)

A game without a line gets DEFAULT_ANCHORS: easy 1300, medium 1500, hard 1600. Bowl has no bots and no line. tests/test_ratings.py checks that ANCHORS names exactly the games with bots, medium is 1500 everywhere and easy ≤ medium ≤ hard.

How the anchors were measured#

tools/ratings/anchors.py plays medium against easy and hard against medium, bots only, with simulated time. To make it fair, it seats the two levels alternately round the game’s smallest table (so Euchre partners match) and plays every deal twice, swapping every seat’s level the second time. The seed picks the dealer or first player, so each level gets the same seats, cards and first moves. Then it counts, for every two seats of different levels on different sides, which finished ahead, with a 95% interval from how much the pairs of deals vary:

tools/ratings/anchors.pypairing · lines 89–96
def pairing(slug, a, b, deals, pool=None):
    """a's share against b, and its standard error, over this many deals
    (each played twice)."""
    jobs = [(slug, a, b, seed) for seed in seeds(f"anchors-{slug}-{a}-{b}", deals)]
    shares = list(pool.map(deal, jobs, chunksize=4) if pool else map(deal, jobs))
    mean = sum(shares) / len(shares)
    var = sum((x - mean) ** 2 for x in shares) / max(1, len(shares) - 1)
    return mean, math.sqrt(var / len(shares))

A share becomes an Elo gap by inverting Elo’s curve, 400 × log10(share / (1 − share)):

tools/ratings/anchors.pygap · lines 99–102
def gap(share):
    """The Elo gap that expects this share."""
    share = min(max(share, 0.01), 0.99)
    return 400 * math.log10(share / (1 - share))

ANCHORS holds runs of 300 games a pairing (October 2026), rounded to 10 and kept between 0 and 400 by hand. Euchre’s easy bot loses to medium 95% of the time, a gap of over 500, but sits 400 below: a level that nearly always loses to the next isn’t worth more than ten to one against a person. Contract Rummy’s hard bot, no better than medium (50.4%), isn’t rated below it. Fictionary (600 games, redone when its bots began writing believable fakes), Get In Line (300 deals), Michigan Rails, Auction House and Last One Standing (600 deals each), and Quiz Whiz (600 deals, both its gaps over the cap: its levels are how often a bot knows the answer) used the fair tool. The rest predate it, when the seeds may have given one level the first move every game. docs/ratings.md lists the raw gaps. ANCHORS holds runs of 300 games a pairing (October 2026), rounded to 10 and kept between 0 and 400 by hand. Euchre’s easy bot loses to medium 95% of the time, a gap of over 500, but sits 400 below: a level that nearly always loses to the next isn’t worth more than ten to one against a person. Contract Rummy’s hard bot, no better than medium (50.4%), isn’t rated below it. Fictionary (600 games, redone when its bots began writing believable fakes), Get In Line (300 deals), and Michigan Rails, Auction House and Last One Standing (600 deals each) used the fair tool, as did Millbury Mansion (150 deals, run in one process: medium beats easy nearly every time, +338, and hard beats medium only 52% at three players, so hard sits just above it). The rest predate it, when the seeds may have given one level the first move every game. docs/ratings.md lists the raw gaps.

Gaps between bots are only a guess at how far apart the levels play against people, and the easiest thing to improve. To remeasure, run uv run python tools/ratings/anchors.py 300 (game names after the count limit it; Contract Rummy alone took over an hour), set the numbers and bump VERSION.

Worked: a settled player against each bot#

A settled Sixty-Six player at 1500 ± 70:

BotRatingChance you winA winA loss
easy115087%+3.5−22.8
medium150050%+13.2−13.2
hard174022%+20.6−5.7
Live from guide/examples.py, BOTS.levels

Beating the easy bot is worth little and losing to it costs a lot; the hard bot is the reverse. A bot’s σ is 0, so these are the largest steps a settled player sees.

Bots as a farm. Can you climb by beating easy bots? Only until it stops being news. A new player who beats Sixty-Six’s easy bot (1150) every time:

Straight winsRatingThe next win
11591+65.80
101881+14.09
1002194+1.29
10002608+0.30
Live from guide/examples.py, BOTS.farm

A perfect record climbs forever, since the logistic curve never reaches 1, but each win is worth less. And nobody wins every card game: a player who wins nine in ten settles around 1568, where nine in ten is expected.

Guests#

A guest, or a deleted account’s seat, is UNKNOWN:

ratings/tables.pyUNKNOWN · lines 36–39
# Someone who played without an account (or whose account is gone): an
# unknown player, 1500 give or take a new player's uncertainty, never
# changed. Losing to one costs less than losing to a known 1500.
UNKNOWN = Skill(MU, SIGMA)

Its σ of 350 is the point: losing to an unknown is weak evidence. A settled 1500 ± 70 player loses 6.2 to a guest, against 13.2 to a sure 1500.

Seats linked later#

A guest who signs in after the game, while the table’s still there, is linked to the kept game (tables/history.py’s attach()). If it was already rated for others, table_linked() rates that seat alone, everyone else frozen at their current ratings (result_event(result, only=index), keyed result:12:3). A rebuild rates the game once with everyone, so this is the one place a rebuild can differ slightly from live ratings.

Timed puzzles#

The Register, Hashi, the kit’s puzzles (Star Battle, Fillomino, Yin-Yang, Galaxies, Aquarium, Griddles, Low Poly) and Solitaire end in a verified time. They share one source class, Timed, and two ways of rating:

  • A daily is a field of everyone who solved it that day, rated together at midnight.
  • Anything else is a game against par, the board’s usual time.

Solitaire and The Register no longer have dailies (ADR 0037, ADR 0040), so all their results are against par; fields from their daily days stay as rated.

Each game’s subclass says only where its solves are (model, solves()), which time it ranks (seconds()) and which solves share a par (board(), board_filter()).

Name Value What it is
WINDOW 99 Par is the median of this many earlier solves on the board.
MIN_PAR 5 Fewer earlier solves than this and there’s no par: a lone solve isn’t rated.
ENDLESS 0.5 The weight of a solve rated against par as it’s posted.
LOST inf Where a loss ranks in a daily field: below every time.

Par#

Par is the median of the last 99 verified solves on a board, by anyone, before this one. A board groups solves whose times compare:

Game A board is Its time
The Register Mode and difficulty final_seconds: time plus penalties, as its best times show
Hashi Size (engine-1 Easy puzzles left out) Seconds
The kit’s puzzles Size and difficulty, per kind Seconds
Solitaire Board (Klondike draw one, draw three, TriPeaks …) Seconds
ratings/sources.pyTimed._prior · lines 161–165
def _prior(self, s):
    """The times par is taken from for this solve, newest first."""
    before = Q(created__lt=s.created) | Q(created=s.created, pk__lt=s.pk)
    qs = self.solves().filter(self.board_filter(s)).filter(before).order_by("-created", "-pk")
    return [self.seconds(x) for x in qs[:WINDOW]]
ratings/sources.pymedian_par · lines 71–72
def median_par(times) -> float | None:
    return statistics.median(times) if len(times) >= MIN_PAR else None

Live, par() queries the board’s last 99 solves. A rebuild (history()) walks every solve in order with a sliding window per board, so each solve gets the par it had when posted. Par drifts with the players, keeping the scale “about the people here”.

A time’s score#

A time against par becomes a score between 0 and 1:

ratings/skill.pytime_score · lines 142–150
def time_score(seconds: float, par: float) -> float:
    """How a time does against par, as a result from 0 to 1: par is a
    draw, half of par wins four times in five, twice par loses as often
    (1 / (1 + (t / par)^2)). Against a par as sure as a settled player, one who
    always takes half of par would sit about 240 above it. But par is played as
    the board's usual player (1500 ± 350, ratings/sources.py), and that
    uncertainty stretches the scale: such a player comes to rest nearer 570
    above (docs/guide/ratings.md, "Where a fast player settles")."""
    return 1 / (1 + (max(seconds, 1) / par) ** 2)
A time's score against par The score a time earns against par: near 1 for a very fast time, 0.8 at half of par, 0.5 at par, 0.2 at twice par, falling toward 0. 0 ½ par par 2 × par 3 × par 4 × par 0 0.2 0.5 0.8 1 Your time Score against par 0.8: wins four in five 0.5: a draw 0.2: loses four in five score = 1 / (1 + (time ÷ par)²)
time_score(): par is a draw; half of par scores 0.8, twice par 0.2. The shape follows from the formula, 1 / (1 + (time ÷ par)²), not from any tunable constant.
TimeScore
0.25 × par0.941
0.5 × par0.800
0.75 × par0.640
1 × par0.500
1.5 × par0.308
2 × par0.200
4 × par0.059
Live from guide/examples.py, TIME_SCORES

The square makes it symmetric in ratios: r times par scores 1/(1 + r²), and 1/r times par scores r²/(1 + r²), exactly one minus it. As odds it’s (par/time)², so half of par is four to one: 400 · log10 4 = 240.8 points on Elo’s scale.

Against par#

Par is a fixed opponent: Skill(), the board’s usual player, 1500 ± 350.

ratings/sources.pyTimed._par_event · lines 133–138
def _par_event(self, s, par, at, weight) -> Event | None:
    if par is None:
        return None
    t = self.seconds(s)
    return Event(self.game, f"solve:{s.pk}", "par", at, [[Seat(user=s.user_id)], [Seat(skill=Skill())]],
                 score=time_score(t, par), weight=weight, detail={"seconds": t, "par": par})

No time is rated before a board has a par (its first 5 solves). After that, an endless solve (anything but today’s daily, archived dailies included) is rated as it’s posted, at weight ENDLESS = 0.5. You choose which endless puzzles to finish and which to abandon, so each counts half. A daily, the same for everyone, counts whole.

Worked: an endless solve. A settled 1550 ± 80 player finishes an endless Star Battle in 3:10; par is 4:00:

score   = 1 / (1 + (190/240)²)                 = 0.615
c       = √(80² + 6² + 350² + 2β²)             = 398.9
p       = expected(1550 ± 80, 1500 ± 350)      = 0.531
change  = σ²/c · (score − p) · ½               = +0.67

On a daily they had to themselves the same time gives +1.35; a slow 5:00 (0.390) costs 1.14. Two things keep these steps small. A time is a partial result: a bit better than par is a bit better than a draw. And par’s σ of 350 makes c 399, against 191 for a sure 1500.

Where a fast player settles. Par’s uncertainty also stretches the scale. By Elo arithmetic, always taking half of par puts you about 240 above a sure par. Against the code’s 1500 ± 350 the rating rests where expected() equals 0.8, which with 350 inside c is much further out (time_score()’s docstring and docs/ratings.md give both). Simulated, 0.5-weight solves at exactly half of par:

Par is Comes to rest at Above par σ there
Skill(), 1500 ± 350 (the code) 2069 +569 125
A sure 1500 (σ 0), for comparing 1765 +265 79

Timed ratings therefore spread wider and settle with a wider σ, but still order players correctly, and dailies compare players directly. The ±350 is deliberate: par is “the board’s usual player”, and losing to an unknown is weak evidence. Making par a sure 1500 is David’s call (known-issues.md): it would move every timed rating, so it needs a VERSION bump and a rebuild.

Dailies are fields, rated at midnight#

A daily is the same puzzle for everyone, so its solvers are compared with each other, not with par. A solve posted on the daily’s own day waits (the APIs set daily only then; a daily solved later is endless), and the sheet says “Your rating moves at midnight, with everyone who solved it today.” When the day closes, its solvers become one result:

ratings/sources.pyTimed._field_event · lines 152–159
def _field_event(self, key, day, members, pars) -> Event | None:
    if len(members) == 1:
        e = members[0]
        return self._alone_event(e, close(day), 1.0, None if self.is_loss(e) else pars(e))
    # Losers share the foot of the field, below every winner.
    times = [LOST if self.is_loss(e) else self.seconds(e) for e in members]
    return Event(self.game, f"field:{key}", "field", close(day), [[Seat(user=e.user_id)] for e in members],
                 ranks=times, detail={"day": day.isoformat(), "players": len(members)})
ratings/sources.pyclose · lines 66–68
def close(day: date) -> datetime:
    """When a day's dailies close: the site's next midnight."""
    return timezone.make_aware(datetime.combine(day + timedelta(days=1), time.min))

Times are the ranks, so equal times tie, and every solver meets every other at 2/k as at a table. A daily with one solver is rated against par, at full weight. A game has one daily a day; before 2026-10-20 it had three, each its own field (the dailies chapter), and Solitaire had one per board.

Worked: yesterday’s Star Battle. Four players solved it:

PlayerBeforeTimeAfterChange
Ray16203:321629+9
Opal14804:051479−1
June1500?3:181743?+243
Mo15505:001535−15
Live from guide/examples.py, FIELD.rows

June, new and fastest, gained 243: three wins in a field of four at σ 350. Ray beat two of three and gained a little; Opal beat one, about as expected; Mo, slowest, lost three meetings at a third of a game each. The field is built by ratings.sources and rated by service.compute, the site’s own midnight path.

Hashi, The Register, the kit, Low Poly#

  • Hashi’s engine-1 Easy puzzles (gentler than today’s) count in their dailies’ fields but never against par or in another solve’s par (Hashi.rated, board_filter).
  • The Register ranks time with penalties (final_seconds), like its boards, and posts only when you ask, so fields and par see only what’s posted.
  • The kit (Kit(slug)) is one source per kind, so a new kind in puzzles.registry.KINDS is rated for free. A player may have two solves of a puzzle: one on its daily’s day (in the field) and one at any other time (against par).
  • Griddles has many codes per picture, so a picture is rated once. The server keeps no later solve of a picture you’ve solved (Griddles); for any kept before that, Kit.seen leaves them unrated but in par’s window, live and rebuilt alike. A daily on its day always counts.
ratings/sources.pyKit.seen · lines 283–290
def seen(self, s) -> bool:
    from puzzles.registry import KINDS
    key = KINDS[self.game].picture_key
    if not key or s.daily_id or not isinstance(s.puzzle.data, dict) or s.puzzle.data.get(key) is None:
        return False
    before = Q(created__lt=s.created) | Q(created=s.created, pk__lt=s.pk)
    return self.model.objects.filter(before, self.board_filter(s), user_id=s.user_id, puzzle__kind=self.game,
                                     **{f"puzzle__data__{key}": s.puzzle.data[key]}).exists()
  • Low Poly ranks times on dailies only (an endless picture can be picked by code), but its endless solves are still rated against par at half weight.
  • The brainteasers (Chicago Traffic, Codebreaker: ADR 0042) have no dailies (ADR 0043): every solve is rated against par on time, at half weight, though their boards rank fewest moves. A lost Codebreaker isn’t rated; the kit keeps no losses.

Solitaire#

Solitaire is one rating for the whole box, though each board has its own par: four provisional ratings would be noise, and the question is “how good am I at Solitaire”. Every result is now endless and rated as posted. Fields from the days it had dailies (until 2026-10-22, ADR 0037; field_of is the daily and the board) keep their ratings, and a rebuild rates them the same way.

Losses count, but only on a winnable deal (David’s ruling). A signed-in player’s game posts a Loss on giving up, leaving a half-played endless deal for another on its board (by any button: Leaving a deal), or running out of moves with nothing to undo. Loss.winnable says whether it counts:

  • A daily was known winnable: the referee’s solver won it before anyone saw it. Its losers join the field at LOST, tied below every winner. A daily only lost is a loss against the board’s usual player, at full weight. Add a 1500 ± 100 player who gave up to the four times above, and they lose 29.0.
  • An endless deal is checked once by the referee’s solver within the board’s budget (solitaire.Winnable, kept per deal). If the solver wins it, the loss is rated at once against the board’s usual player, at half weight. If not, it isn’t rated and the sheet says so (the referees chapter has the solver).
ratings/sources.pyTimed._loss_event · lines 140–142
def _loss_event(self, e, at, weight) -> Event:
    """A loss against the board's usual player (1500, fixed)."""
    return Event(self.game, f"loss:{e.pk}", "loss", at, [[Seat(user=e.user_id)], [Seat(skill=Skill())]], score=0.0, weight=weight)

A 1600 ± 70 player who gives up a winnable endless deal loses 3.5 (7.0 on a lone daily, at full weight). Against a sure 1500 it would be 8.3: the usual player is uncertain, like par.

The first result posted for a deal stands. A loss after a win isn’t taken; a win after a loss is kept for the boards but not rated. entries() keeps the first result per deal (a win, if both land at the same moment), and superseded() tells posted() which earlier result to report:

ratings/sources.pySolitaire.entries · lines 339–348
def entries(self, q=None, losses=None):
    """Wins and losses, oldest first, the first for each deal only."""
    both = list(q if q is not None else self.solves()) + list(losses if losses is not None else self.losses)
    both.sort(key=lambda e: (e.created, self.is_loss(e), e.pk))
    seen, out = set(), []
    for e in both:
        if self._deal(e) not in seen:
            seen.add(self._deal(e))
            out.append(e)
    return out

Chess puzzles#

Each puzzle is one game against the puzzle at its Lichess rating, rated once (David’s ruling). The puzzle is Skill(rating, 0.0), certain, so results count in full. Time doesn’t count.

ratings/sources.pyChess._event · lines 384–393
def _event(self, e) -> Event:
    item = [Seat(skill=Skill(e.rating, 0.0))]
    key = self.own_key(e)
    if self.is_miss(e) or e.mistakes:
        score, half = 0.0, False
    elif e.hints:
        score, half = 0.5, True
    else:
        score, half = 1.0, False
    return Event(self.game, key, "puzzle", e.created, [[Seat(user=e.user_id)], item], score=score, half=half, detail={"puzzle": e.rating})
  • A clean solve (no wrong move, no hint) is a win.
  • A solve with hints but no wrong move gains exactly half what a clean one would.
  • Any wrong move, or Show solution, is one loss, however many follow. The game posts it at once as a Miss, so closing the tab can’t undo it; a later clean finish is kept for the boards but neither rated nor clean. The referees chapter has how a wrong move is checked.

The first result for a puzzle stands, a miss first on a tie (Chess.superseded, Chess.history).

Half a win, exactly#

Half the gain can’t be a fixed score like 0.75: against a puzzle you’d solve 90% of the time, 0.75 is a loss. So the event is marked half, and service.compute() sets the score from the player’s rating when it’s rated:

ratings/service.pycompute · lines 29–52
def compute(event, book: dict) -> list[tuple[int, Skill, Skill, int]]:
    """What an event does, given the players' ratings now (`book`: user
    → (Skill, rated so far); missing is a new player): (user, before,
    after, rated) for every player it changes."""
    teams, fixed = [], []
    for side in event.sides:
        teams.append([book.get(s.user, (Skill(), 0))[0] if s.user is not None else s.skill for s in side])
        fixed.append([s.user is None or s.frozen for s in side])
    if all(all(f) for f in fixed):
        return []
    score = event.score
    if getattr(event, "half", False):
        # Half a win's gain: halfway between what was expected and a win
        # (the expectation as rate() reckons it, uncertainty loosened first).
        me, item = teams[0][0], teams[1][0]
        p = expected(Skill(me.mu, math.sqrt(me.sigma ** 2 + TAU ** 2)), item)
        score = (1 + p) / 2
    after = rate(teams, ranks=event.ranks, score=score, fixed=fixed, weight=event.weight)
    out = []
    for side, before_side, after_side, f in zip(event.sides, teams, after, fixed):
        for s, b, a, frozen in zip(side, before_side, after_side, f):
            if not frozen:
                out.append((s.user, b, a, book.get(s.user, (None, 0))[1] + 1))
    return out

With p the chance of solving it (as rate() will compute it, after TAU), the score is (1 + p) / 2, halfway between the expectation and a win. The mean moves by σ²/c · (s − p), so:

clean:   s − p = 1 − p
hinted:  s − p = (1 + p)/2 − p = (1 − p)/2       exactly half

It’s always a gain, smaller against a puzzle you were expected to solve. σ shrinks as for a clean solve (the shrink doesn’t depend on the score): you did finish it.

Worked. A 1550 ± 80 player against a 1700 puzzle and an easy 1200 one:

Against 1700 Against 1200
Chance of solving it 31.3% 86.2%
Clean +23.1 +4.7
With a hint +11.5 (scored 0.657) +2.3 (scored 0.931)
A wrong move −10.5 −29.0

Lichess puzzle ratings are on a 1500-centered Glicko scale too, so the two read roughly alike.

When ratings change#

One game, one lock, one order#

Each game has a Ladder row: the lock every change takes first, the rules VERSION its ratings were built under, and the last day whose fields are rated.

ratings/models.pyLadder · lines 11–22
class Ladder(models.Model):
    """One game's ratings as a whole: the row every change to them locks
    first, the rules they were worked out under, and the last day whose
    daily fields have been rated."""

    game = models.CharField(max_length=40, primary_key=True)
    version = models.PositiveSmallIntegerField(help_text="ratings.rules.VERSION when it was last rebuilt.")
    settled = models.DateField(null=True, blank=True, help_text="Daily fields are rated through this day.")
    built = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"{self.game} (rules {self.version}, settled {self.settled})"

A result goes through _offer(): lock the ladder (rebuilding if its rules are old, else closing any ended days), build the event, rate it.

ratings/service.py_offer · lines 155–168
def _offer(game: str, build) -> dict | None:
    """Rate what `build()` makes (an event, or None) in its game, after
    closing any days that ended before it. Returns {user: Change}, or None
    if nothing was rated (or something went wrong: logged)."""
    if source(game) is None:
        return None
    try:
        with transaction.atomic():
            _ladder(game)
            event = build()
            return _apply(event) if event else None
    except Exception:
        log.exception("Couldn't rate a %s result", game)
        return None

Closing days first keeps live ratings in the order a rebuild uses. A field is dated at its day’s midnight (close()), so a post just after midnight must find yesterday’s fields rated.

_apply() rates each event once. A RatedEvent row, unique on game and key (result:12, solve:34, field:56, loss:7, miss:8), records it; offered again (a retried post, two requests settling one day), it returns the first outcome. Each player’s Change keeps their rating before and after, for the sheet’s “+12 → 1534” and the Stats tab’s rating line.

ratings/service.py_apply · lines 84–102
def _apply(event) -> dict:
    """Rate one event (the ladder already locked): {user: Change}. An
    event already rated returns what it did then."""
    done = RatedEvent.objects.filter(game=event.game, key=event.key).first()
    if done:
        return {c.user_id: c for c in done.changes.all()}
    users = event.users()
    rows = {r.user_id: r for r in Rating.objects.select_for_update().filter(game=event.game, user__in=users)}
    book = {u: (Skill(r.mu, r.sigma), r.rated) for u, r in rows.items()}
    changes = compute(event, book)
    kept = RatedEvent.objects.create(game=event.game, key=event.key, kind=event.kind, at=event.at)
    out = {}
    for user, before, after, n in changes:
        row = rows.get(user) or Rating(user_id=user, game=event.game)
        row.mu, row.sigma, row.rated = after.mu, after.sigma, n
        row.save()
        out[user] = Change.objects.create(event=kept, user_id=user, game=event.game, before_mu=before.mu, before_sigma=before.sigma,
                                          mu=after.mu, sigma=after.sigma, rated=n)
    return out

Hooks run after the result is safely kept. A rating failure is logged, never shown to the player: the post goes through and the next rebuild fixes the ratings.

Days close lazily#

There’s no midnight job. The first post or board view of a game after midnight rates its closed days (settle(), called by posted() and board()). manage.py ratings settle does every game, for a scheduler if one’s ever wanted.

ratings/service.py_settle · lines 72–81
def _settle(ladder: Ladder, today) -> None:
    from datetime import timedelta

    yesterday = today - timedelta(days=1)
    if ladder.settled is not None and ladder.settled >= yesterday:
        return
    for event in sorted(source(ladder.game).fields(ladder.settled, today), key=lambda e: e.order):
        _apply(event)
    ladder.settled = yesterday
    ladder.save(update_fields=["settled"])

What a post hears back#

The puzzle APIs (puzzles/api.py, hashi/api.py, leaderboard/api.py for The Register, solitaire/api.py, chesspuzzles/api.py) return posted()’s answer as rating:

ratings/service.pyposted · lines 171–196
def posted(game: str, solve, user_id: int) -> dict | None:
    """A verified solve just posted (or posted again): what the poster's
    result sheet says. {"rating", "delta", "provisional"} once it's rated;
    {"pending": True} for today's daily, rated with everyone's when the day
    closes; None when it isn't rated (no par yet). Asking twice is
    harmless: the second time reads what the first did."""
    src = source(game)
    if src is None:
        return None
    field = getattr(src, "field_of", None)
    k = field(solve) if field else None
    mine = Change.objects.filter(user_id=user_id, game=game).with_peak()
    if k is not None:
        settle(game)
        change = mine.filter(event__key__in=[f"field:{src._key(k)}", src.own_key(solve)]).first()
        if change:
            return change.sheet()
        return {"pending": True} if src.day_of(solve) >= site_today() else None
    out = _offer(game, lambda: src.solve_event(solve))
    change = (out or {}).get(user_id)
    if change is None:
        # A result after the one that was rated for it (the first stands):
        # what that first one did.
        earlier = getattr(src, "superseded", lambda e: None)(solve)
        change = mine.filter(event__key=earlier).first() if earlier else None
    return change.sheet() if change else None

That’s {"rating", "delta", "provisional", "established", "high"} once rated, {"pending": true} for today’s daily, and None when not rated (no par yet, a lone daily without par). Asking twice just reads the first answer.

Rebuilding#

_rebuild() deletes a game’s ratings, asks its source for its whole history() (the same events the live path makes), sorts them by (at, key), rates them in memory and writes everything at the end. Running it twice changes nothing.

ratings/service.py_rebuild · lines 105–130
def _rebuild(ladder: Ladder, today) -> int:
    """Every rating at this game, worked out again from everything it
    keeps, in the order it happened: deterministic, so running it twice
    changes nothing. Held in memory and written at the end."""
    from datetime import timedelta

    game = ladder.game
    Rating.objects.filter(game=game).delete()
    RatedEvent.objects.filter(game=game).delete()  # and their Changes
    events = sorted(source(game).history(today), key=lambda e: e.order)
    book, kept, pending = {}, [], []
    for event in events:
        changes = compute(event, book)
        kept.append(RatedEvent(game=game, key=event.key, kind=event.kind, at=event.at))
        pending.append(changes)
        for user, _, after, n in changes:
            book[user] = (after, n)
    kept = RatedEvent.objects.bulk_create(kept, batch_size=500)
    Change.objects.bulk_create([
        Change(event=ev, user_id=user, game=game, before_mu=before.mu, before_sigma=before.sigma, mu=after.mu, sigma=after.sigma, rated=n)
        for ev, changes in zip(kept, pending) for user, before, after, n in changes
    ], batch_size=500)
    Rating.objects.bulk_create([Rating(user_id=u, game=game, mu=s.mu, sigma=s.sigma, rated=n) for u, (s, n) in book.items()], batch_size=500)
    ladder.version, ladder.settled = VERSION, today - timedelta(days=1)
    ladder.save(update_fields=["version", "settled"])
    return len(events)

All three commands are safe on the live database (ratings/management/commands/ratings.py):

Command Does
manage.py ratings backfill Rebuilds each game whose ratings are missing or from older rules. The release phase runs it after migrating (the Procfile), so usually it costs a query.
manage.py ratings rebuild [game …] Rebuilds the named games (or all) from scratch, in one transaction holding the ladder’s lock; a result arriving meanwhile waits.
manage.py ratings settle Rates every game’s closed days.

The rules’ version is one number:

ratings/service.pyVERSION · lines 23–26
# Bump when what's rated or how changes (sources, anchors, the constants):
# each game's ratings are rebuilt from scratch the next time they're
# touched, or on release (`manage.py ratings backfill`).
VERSION = 6  # 6: the brainteasers, rated on time against par (October 2026)

Bump it when you change what’s rated or how (a source, the anchors, a constant). The next release’s backfill rebuilds every game, and a game touched sooner rebuilds when its ladder is locked (_ladder() compares versions). Only an older ladder is rebuilt, and backfill skips any at this version or later: during a deploy an old dyno is still serving, and it must not roll a ladder the new release rebuilt back to the old rules.

Where ratings show#

The result sheet#

The house kit’s ratingLine() turns posted()’s answer, or a table seat’s rating, into the sheet’s line:

games/static/games/house/end.jsratingLine · lines 58–70
/** What a rated result did, a sentence for the post line (docs/ratings.md),
 * from the server's `rating`: "Rating +12 → 1534." ("1534?" while it's
 * provisional, and on the result that ends that, a word that it has); a
 * daily waits for its day's field; '' when it isn't rated. */
export function ratingLine(r) {
  if (!r) return '';
  if (r.pending) return 'Your rating moves at midnight, with everyone who solved it today.';
  if (typeof r.rating !== 'number') return '';
  const delta = r.delta > 0 ? `+${r.delta}` : r.delta < 0 ? `−${-r.delta}` : '±0';
  if (r.established && !r.provisional) return `Rating ${delta} → ${r.rating}, no longer provisional.`;
  if (r.high) return `Rating ${delta} → ${r.rating}, your highest yet.`;
  return `Rating ${delta} → ${r.rating}${r.provisional ? '?' : '.'}`;
}

Its shape comes from Change.sheet():

ratings/models.pyChange.sheet · lines 110–116
def sheet(self) -> dict:
    """What a result sheet says: "+12 → 1534" (a "?" while provisional),
    whether this is the result that made it established, and whether
    it's the player's highest yet (`high`: an established rating above
    every one before it in this game, which the sheet celebrates)."""
    return {"rating": round(self.mu), "delta": self.delta, "provisional": self.rated < PROVISIONAL,
            "established": self.rated == PROVISIONAL, "high": self.high()}

For a first win between new players that’s rating 1617, delta 117, provisional true: “Rating +117 → 1617?”. The delta is the difference of the rounded ratings, so it matches what’s shown. A change that rounds to nothing reads “±0”, and a loss uses a true minus sign. Solo puzzles’ solved sheets, Solitaire’s lost sheet (or “Not rated: the solver couldn’t show this deal can be won.”), a Chess Puzzles miss and every table game’s end all use it (the front-end chapter has the sheet).

established is true on one result only, the one that reaches 10 and drops the “?”. That sheet ends in words instead of a full stop: “Rating +9 → 1561, no longer provisional.”

high is true when an established rating went up past every established rating this player has had in this game before. A provisional peak doesn’t count: a player who starts hot and settles would otherwise wait out a long climb (most never made it) for a high their early luck set. “Before” is the order a rebuild rates in, (at, key), so results kept at the same moment (a day’s dailies, all closing at midnight) are in order too, and a seat linked after its game was kept is placed by when the game was played. That sheet says “Rating +14 → 1612, your highest yet.” and plays the personal-best celebration (ADR 0051), unless the sheet has played it already for a new best: a celebration plays once on a sheet. Never while provisional, nor on the result that establishes it, nor on a loss:

ratings/models.pyChange.high · lines 118–128
def high(self) -> bool:
    """Above every established rating before it (a provisional one's
    peak doesn't count: it's settling, not earned). Before is in the
    order a rebuild rates them, (at, key), so results kept in the same
    moment (a day's dailies) are ordered too. The peak comes with the
    change when it was fetched `with_peak()`; else it's asked for."""
    if self.rated <= PROVISIONAL or self.delta <= 0:
        return False
    if not hasattr(self, "peak"):
        self.peak = Change.objects.filter(pk=self.pk).with_peak().values_list("peak", flat=True).first()
    return round(self.mu) > round(max(self.before_mu, self.peak if self.peak is not None else self.before_mu))

The peak is a subquery fetched with the change (with_peak()), so a finished table’s snapshot, which every stream asks for again and again, reads its rating in one query. Keeping high on the row was the other way; it would need a migration and a rebuild of every ladder for one boolean, and would freeze an answer that a seat linked late, with an earlier at, can still change.

ratings/models.pyChanges.with_peak · lines 74–82
def with_peak(self):
    """Each change with `peak`: the player's highest established rating
    at the game before it, in (at, key) order (None if there's none),
    worked out in the same query."""
    earlier = (Change.objects.filter(user_id=models.OuterRef("user_id"), game=models.OuterRef("game"), rated__gte=PROVISIONAL)
               .filter(models.Q(event__at__lt=models.OuterRef("event__at"))
                       | models.Q(event__at=models.OuterRef("event__at"), event__key__lt=models.OuterRef("event__key")))
               .order_by("-mu").values("mu")[:1])
    return self.annotate(peak=models.Subquery(earlier))

Boards#

Each game’s leaderboard has a Ratings board beside Daily and All-time; a table game’s is on its history page.

ratings/boards.pyboard · lines 33–46
def board(game: str, user, noun: str = "puzzle") -> dict:
    """A game's ratings board: established ratings, best first (a tie to
    whoever has more rated results, then by name), and the viewer's own,
    provisional or not, with where it ranks."""
    settle(game)
    established = Rating.objects.filter(game=game, rated__gte=PROVISIONAL)
    rows = list(established.select_related("user").order_by("-mu", "-rated", "user__display_name")[:TOP])
    for k, r in enumerate(rows, 1):
        r.rank = k
    mine = Rating.objects.filter(game=game, user=user).first() if user.is_authenticated else None
    rank = None
    if mine and not mine.provisional:
        rank = established.filter(Q(mu__gt=mine.mu) | Q(mu=mine.mu, rated__gt=mine.rated)).count() + 1
    return {"rating_rows": rows, "my_rating": mine, "my_rating_rank": rank, "rating_how": how(game, noun), "provisional_after": PROVISIONAL}

It lists the top 100 established players, ties to whoever has more rated results, then by name. Your own rating leads, provisional or not, ranked once established. Reading a board settles the game first, so a closed day shows at once. Its one-line explanation is how(), by kind of game.

Your account#

The hub’s /account/ lists your ratings, strongest first, each a bar leaning left or right of 1500 (the accounts chapter):

ratings/boards.pyyours · lines 60–74
def yours(user) -> list[dict]:
    """Your ratings in every listed game you've a rating in, strongest
    first, for the hub's account page: each with how far it sits from
    1500, as a share of the widest, for its bar."""
    on = {g.slug for g in listed()}
    rows = [r for r in Rating.objects.filter(user=user, rated__gt=0) if r.game in on]
    rows.sort(key=lambda r: (-r.mu, GAMES_BY_SLUG[r.game].title))
    widest = max([abs(r.mu - MU) for r in rows] + [100])
    out = []
    for r in rows:
        # The bar runs from the middle (1500) left or right, at most half the track.
        lean = 50 * (r.mu - MU) / widest
        out.append({"game": GAMES_BY_SLUG[r.game], "rating": r, "label": r.label, "url": board_url(r.game),
                    "left": f"{50 + min(lean, 0):.1f}", "width": f"{abs(lean):.1f}"})
    return out

A bar’s length is your distance from 1500 as a share of your widest (at least 100 points), so it compares your games with each other, not with other players. Each Stats tab draws your Change rows as a line over time (the dailies chapter). The hub’s home page shows no ratings.

What players are told#

A “?” beside “Your rating” on each Stats tab opens “How your rating works”: three to six plain points for that game. No one writes them. ratings/explain.py builds them from the game’s source class (a Timed game, worded by whether it still has a daily; Solitaire with its losses; a Kit puzzle whose codes name pictures; Chess; a Table, with partners and uneven sides) and takes every number from the rating constants (MU, PROVISIONAL, WINDOW, MIN_PAR, ENDLESS, time_score(), ANCHORS, UNKNOWN). dailies/stats.py passes them to the page as ratingHelp.

ratings/explain.pypoints · lines 166–172
def points(game: str) -> list[str]:
    """What goes into this game's rating, three to six short points; [] for
    a game that isn't rated."""
    src = sources.source(game)
    if src is None:
        return []
    return _points(src, game)

One game of each kind, as the sheet shows it now:

GameWhat its ? says
Star Battle, and every kit puzzleToday’s daily is rated at midnight, all at once: everyone who solved it that day is ranked by time, and you gain for each player you beat and lose for each who beat you.
Any other puzzle you finish is played against par, the usual time: the middle of the last 99 solves at that size. It counts half as much as a daily, since you choose which ones to finish.
Matching par is a draw; finishing in half of par is worth a win four times in five. Until a size has 5 solves there’s no par, and a puzzle there isn’t rated.
Everyone starts at 1500. For your first 10 rated results your rating is provisional (it shows a ?) and moves a long way each time while it finds your level.
HashiToday’s daily is rated at midnight, all at once: everyone who solved it that day is ranked by time, and you gain for each player you beat and lose for each who beat you.
Any other puzzle you finish is played against par, the usual time: the middle of the last 99 solves at that size. It counts half as much as a daily, since you choose which ones to finish.
Matching par is a draw; finishing in half of par is worth a win four times in five. Until a size has 5 solves there’s no par, and a puzzle there isn’t rated.
Puzzles from Hashi’s old, gentler Easy count only in a daily field with other solvers, never against par: one you solved alone that day, or on any other day, isn’t rated.
Everyone starts at 1500. For your first 10 rated results your rating is provisional (it shows a ?) and moves a long way each time while it finds your level.
The RegisterEvery case you solve is played against par, the usual time, penalties included: the middle of the last 99 solves at that difficulty. It counts half as much as a daily would, since you choose which cases to play.
Matching par is a draw; finishing in half of par is worth a win four times in five. Until a difficulty has 5 solves there’s no par, and a case there isn’t rated.
The dailies it once had stay in your rating as they were rated.
Everyone starts at 1500. For your first 10 rated results your rating is provisional (it shows a ?) and moves a long way each time while it finds your level.
SolitaireEvery deal you win is played against par, the usual time: the middle of the last 99 wins at that game. It counts half as much as a daily would, since you choose which deals to play.
Matching par is a draw; finishing in half of par is worth a win four times in five. Until a game has 5 solves there’s no par, and a win there isn’t rated.
A loss isn’t timed: it counts as losing to a steady 1500 player, at half weight, and only on a deal that’s proven winnable, one our solver could win. Losing one it couldn’t costs nothing. Only your first result on a deal counts.
One rating covers the whole box, though each game in it has its own par.
The dailies it once had stay in your rating as they were rated.
Everyone starts at 1500. For your first 10 rated results your rating is provisional (it shows a ?) and moves a long way each time while it finds your level.
Chess PuzzlesEvery puzzle is a game against the puzzle itself, at its Lichess rating: solving a harder one gains more, missing an easy one costs more.
Solve it with no help and no wrong moves: a win. With hints but no wrong moves: half of what a clean solve would gain.
A wrong move or Show solution is one loss, however many mistakes follow.
Only your first try at each puzzle counts, and time doesn’t.
Everyone starts at 1500. For your first 10 rated results your rating is provisional (it shows a ?) and moves a long way each time while it finds your level.
Euchre, and every table gameEvery game you finish signed in is rated against everyone else at the table: you gain for each player you finish ahead of and lose for each who finishes ahead of you, more when they’re rated higher than you.
Partners are one side: you win and lose together, rated against the other side.
Bots are fixed anchors whose ratings never move: easy 1100, medium 1500, hard 1520.
Someone playing without an account counts as an unknown 1500 player. Only games played to the end count.
Everyone starts at 1500. For your first 10 rated results your rating is provisional (it shows a ?) and moves a long way each time while it finds your level.
Live from ratings/explain.py, guide_rows

RatingHelpTests holds them to the code: every rated game has three to six points (an unrated one none), every number is one of its constants, and patching PROVISIONAL, WINDOW, ENDLESS and an anchor changes the words. share() and odds() turn numbers into words (“half”, “four times in five”). A new kind of source has no words until given some: points() raises rather than say nothing.

What keeps it fair#

Ratings add no new way to cheat. They rate only what the boards already accept, checked by a referee (Referees and anti-cheat), and only what a signed-in player’s game posts. Within that:

  • Choosing what to finish. An abandoned puzzle is never seen, so endless solves count half; dailies count whole.
  • Deal hunting. A lucky Solitaire deal counts half, and walking away from a winnable one posts a loss.
  • A chess slip. The first wrong move is posted as it’s made.
  • Farming bots. You climb only until winning stops being news (above).
  • Sandbagging a field. A field compares only people who posted the same daily; a slow post hurts the poster.

Changing it#

Change What it needs
A constant in skill.py Bump VERSION. The worked examples recompute; reread the prose around them. SkillTests pins the OpenSkill agreement, the ten-to-one scale and the settled step size.
The bots’ anchors Rerun tools/ratings/anchors.py, set ANCHORS, bump VERSION.
What a game rates (a source) Edit ratings/sources.py, bump VERSION, check live and rebuild agree (RebuildTests does for the kinds it knows).
A new table game Nothing: every game in tables.games.GAMES gets a Table source. Add its bots to ANCHORS (the test insists).
A new kit puzzle Nothing: every kind in puzzles.registry.KINDS gets a Kit source.
A new kind of game A source class like those in ratings/sources.py (solve_event, fields, history, plus field_of, day_of, own_key with dailies), registered in _sources(); the game’s API calling posted(); its points in ratings/explain.py (RatingHelpTests fails without them).

Then run uv run python manage.py test tests.test_ratings and reread this chapter against the change.