Platform reference

Ratings and Elo

Understand Roster Elo, Player performance ratings, eligibility, and season changes.

Heir uses two different ratings. Roster Elo changes with competition results. Player performance rating summarizes eligible, observed play within a League and game.

Player performance rating

The Player rating is a 0–100 performance measure with scope and confidence information. Read its mode, reporting window, eligibility, and provisional label before comparing numbers. It is not the Roster's Elo and is not a universal cross-League skill score.

The current model uses valid observations, mode-appropriate exposure, and stable reference baselines. Missing or partial enhanced measurements are not treated as fully trusted data. The performance rating reference explains the formula's observation and confidence rules.

Roster Elo at a glance

A newly created Roster starts at the game's configured Elo baseline (1500 in the base model). Approved competition results move that Elo; raw duel/Deathmatch counters and Server time do not create team results.

The following is the base Elo curve. Game-specific constants can override it; it is not the Player performance formula.

Elo Calculation

Heir uses a margin-of-victory Elo system. After every eligible recorded 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.

The base starting value is 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.

The minimum keeps a low-probability rating movement from becoming zero. The exact result still depends on the rating gap and score margin.

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.