Platform
Ratings & Elo

Mathematics

Nerd stuff.

Elo Calculation

Heir uses a margin-of-victory Elo system. After every reported match, the winner gains rating and the loser loses rating. How much depends on two things:

  1. How surprising the result was — beating a higher-rated team pays more than beating a lower-rated one.
  2. How decisive the win was — a 3-0 sweep moves ratings more than a 3-2 nail-biter.

Every team starts at 1500.

The formula

expected = 1 / (1 + 10^((loserRating - winnerRating) / 400))
delta    = round( K × ln(margin + 1) × (1 - expected) )
TermMeaning
expectedThe standard Elo win probability for the winner (0 to 1). Equal ratings → 0.5; a 400-point favorite → ≈0.91.
marginRound difference, winnerScore - loserScore (e.g. 3-1 → 2).
ln(margin + 1)Margin multiplier: ≈0.69 for +1, ≈1.10 for +2, ≈1.39 for +3. Diminishing returns — running up the score helps, but less and less.
K90 — the base volatility of the system.

The result is then clamped:

  • Minimum movement: 25. Winning always pays something.
  • Maximum movement: 150. One match can only move a rating so far.
  • Rating floor: 500. A loss never drops a team below 500 (and a team already below 500 neither gains nor drops further from losing).

The winner gains delta; the loser loses delta (subject to the rating floor).

Example payouts (winner's gain)

Rating gap (winner − loser)3-03-13-2
−600 (huge upset)1219660
−300 (upset)1068453
0 (even match)624931
+100 (slight favorite)453625
+300 or more (heavy favorite)252525

Two guarantees hold everywhere: a bigger margin never pays less at the same rating gap, and a bigger upset never pays less at the same margin.

Heavy favorites always earn the minimum (25) regardless of score — climbing the leaderboard means beating teams near or above your own level, not farming weaker ones.

Reference implementation

const K_FACTOR = 90;      // Base volatility
const MIN_DELTA = 25;     // Guaranteed minimum movement per match
const MAX_DELTA = 150;    // Maximum movement per match
const RATING_FLOOR = 500; // Losses never drop a team below this
 
/***
 * Matches two teams against each other
 * @param R_A - Winner's current Elo (ex: 1500)
 * @param m_A - Winner's rounds (ex: 3)
 * @param R_B - Loser's current Elo (ex: 1500)
 * @param m_B - Loser's rounds (ex: 1)
 */
function match(R_A: number, m_A: number, R_B: number, m_B: number) {
    const expected = 1 / (1 + 10 ** ((R_B - R_A) / 400));
    const marginFactor = Math.log(m_A - m_B + 1);
    let delta = Math.round(K_FACTOR * marginFactor * (1 - expected));
 
    if (delta < MIN_DELTA) delta = MIN_DELTA;
    if (delta > MAX_DELTA) delta = MAX_DELTA;
 
    // A loss never drops a team below the rating floor (and never raises a team
    // already under it)
    const loserElo = Math.max(R_B - delta, Math.min(R_B, RATING_FLOOR));
 
    return {
        winnerEloOld: R_A,
        loserEloOld: R_B,
        rounds: [m_A, m_B],
        deltaElo: delta,
        winnerElo: R_A + delta,
        loserElo: loserElo
    };
}

Reporting rules

  • The winning score must be strictly greater than the losing score; scores must be non-negative whole numbers. Draws cannot be reported.
  • Voiding a match (/admin match-void) reverses exactly the rating change that match recorded — it does not reset teams to a snapshot, so voiding an older match never erases results that happened after it. A match can only be voided once.

Seasons

When an Organizer runs /admin season-end, final standings are archived and every team's rating is soft-reset toward 1500, keeping half the distance:

newRating = 1500 + round((oldRating - 1500) × 0.5)

Examples: 2000 → 1750, 1700 → 1600, 1000 → 1250. Standings order is preserved — the spread just compresses, so strong teams keep an edge while everyone starts the new season within reach. Season win/loss records reset to 0-0; archived placements are kept forever on each team.

Predictions (/predict) use the same expected-score curve shown above, so the percentages you see are exactly what the rating system believes.

Leaderboard movement

Leaderboard entries show weekly movement — ▲2 (climbed two places), ▼1 (dropped one), or 🆕 (newly ranked). Positions are compared against a snapshot that refreshes automatically once the previous one is at least 7 days old, and the arrows reset when a new season starts.