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:
- How surprising the result was — beating a higher-rated team pays more than beating a lower-rated one.
- 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) )| Term | Meaning |
|---|---|
expected | The standard Elo win probability for the winner (0 to 1). Equal ratings → 0.5; a 400-point favorite → ≈0.91. |
margin | Round 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. |
K | 90 — 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-0 | 3-1 | 3-2 |
|---|---|---|---|
| −600 (huge upset) | 121 | 96 | 60 |
| −300 (upset) | 106 | 84 | 53 |
| 0 (even match) | 62 | 49 | 31 |
| +100 (slight favorite) | 45 | 36 | 25 |
| +300 or more (heavy favorite) | 25 | 25 | 25 |
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.