Elo is the rating system behind chess ladders, football power rankings and most "ranked mode" screens you've ever seen. It's also one of the few genuinely famous algorithms you can implement correctly in about fifteen lines of JavaScript. This is a plain-English walk through the formula, a working implementation, and — the part most tutorials skip — how you turn a column of ratings into a matchmaking queue that actually pairs people up without leaving them staring at a spinner.
The one idea behind Elo
Elo doesn't try to measure skill directly. It treats your rating as a prediction device: given two ratings, the system predicts how likely each player is to win. Then it compares the prediction to what actually happened and nudges both ratings toward whatever would have predicted the result better.
That's the whole system. A rating is not a score you earn — it's the number that best explains your results so far. Beat someone the system already expected you to beat and you gain almost nothing, because the prediction was already right. Beat someone rated 400 points above you and your rating jumps, because the prediction was badly wrong.
The formula, in two steps
Step one — expected score. For player A against player B:
E_a = 1 / (1 + 10 ^ ((R_b - R_a) / 400))
E_a comes out between 0 and 1 and you can read it as A's win probability
(draws counted as half). Equal ratings give exactly 0.5. The magic number 400 is a
scaling choice, not a law of nature: it means a 400-point advantage implies roughly a
10-to-1 favourite, and a 200-point gap is about 76%.
Step two — the update. Play the game, then:
R_a' = R_a + K * (S_a - E_a)
S_a is the actual result: 1 for a win, 0.5 for a
draw, 0 for a loss. K is how much a single game is allowed to
move you. Because E_a + E_b = 1 and S_a + S_b = 1, whatever
A gains B loses — the system is zero-sum, which is why the average rating in your pool
stays put instead of inflating over time.
An implementation you can actually ship
Here's the whole thing. No dependencies, no library needed:
const expected = (a, b) => 1 / (1 + Math.pow(10, (b - a) / 400));
function rate(ra, rb, scoreA, k = 24) {
const ea = expected(ra, rb);
const delta = Math.round(k * (scoreA - ea));
return { a: ra + delta, b: rb - delta, delta };
}
Three details matter more than the arithmetic:
-
Round once, symmetrically. Compute a single
deltaand apply it in both directions. If you round each player's change independently you slowly leak or mint rating points, and after a few hundred thousand games your ladder's average has drifted for no reason. -
Store the delta with the match. Write
ratingBefore,ratingAfteranddeltaonto the match row, not just the new rating on the player. When someone asks "why did I only get 3 points?" you can answer, and when you inevitably change the K-factor you can still explain old history. - Make the update atomic with the result. Rating changes belong in the same transaction as the match record. Two matches finishing at once with read-modify-write on the player row will happily lose one of the updates.
Picking K without agonising
K is the only real tuning knob, and it's a straight trade between
responsiveness and stability. High K means new players find their level in a handful of
games but veterans' ratings bounce around; low K means a stable ladder that takes
forever to correct a wrong starting estimate.
The standard answer, borrowed from chess federations, is to tier it:
- First ~25 games: K = 40. A provisional period. The player is climbing or sinking to their actual level, so let them move fast.
- Established players: K = 20–24. The steady state for most ladders.
- Top of the ladder: K = 10–16. At the sharp end, a single upset shouldn't reshuffle the leaderboard.
Everyone starts somewhere in the middle — 1200 and 1500 are both conventional. The starting number is arbitrary; what matters is that it's the same for everyone and that provisional K gets people out of the starting crowd quickly.
From ratings to an actual queue
This is where most Elo tutorials stop and most real implementations start. A rating is useless until something pairs players with it, and the naive version — "match the two closest ratings in the queue" — falls apart the moment your player pool is small or lopsided. The strongest player online has nobody within 100 points and waits forever.
The fix is an expanding search window. Every player in the queue carries a tolerance that grows with how long they've been waiting:
const tolerance = (waitedMs) => Math.min(100 + (waitedMs / 1000) * 25, 600);
Then sweep the queue on a short tick — every second or two is plenty — sort by rating, and pair adjacent players whose gap fits both of their tolerances. Someone who just joined only accepts a close match. Someone who's been waiting thirty seconds will accept a 600-point mismatch, because a slightly unfair game beats no game at all.
A few things worth adding once the basics work:
- A rematch penalty — don't re-pair the same two players immediately if anyone else is available.
- Region or latency awareness — for a real-time browser game a 200ms ping ruins the match more thoroughly than a 200-point rating gap. If you're deciding on a transport for that loop, I compared the options in WebSocket vs SSE vs WebTransport.
- Provisional flagging — hide ratings until the provisional period ends, so the leaderboard isn't full of people with one lucky win.
- Abandon handling — decide up front whether a rage-quit counts as a loss. It usually should, or the ladder gets gamed within a week.
Where Elo genuinely falls down
Elo has two real weaknesses, and knowing them saves you from cargo-culting it into the wrong game.
First, it has no idea how confident it is. A 1500 with three games and a 1500 with three thousand are the same number to Elo. Glicko-2 fixes this by tracking a rating deviation and a volatility alongside the rating, so uncertain players move more and long-absent players' uncertainty grows back. If your player base is intermittent — people who play for a week, vanish for two months, return — Glicko-2 is worth the extra complexity.
Second, it's built for one-versus-one. The zero-sum swap has no natural answer for "our team of five won, how much of that was each player?" Team systems like TrueSkill and Elo-MMR exist precisely for that credit-assignment problem. Bolting per-player Elo onto team games technically works and is what most small games do, but expect complaints about carried players, because the complaints are correct.
For a 1v1 browser game, though, Elo is the right default: it's one formula, it's zero-sum so it can't inflate, players already understand it, and you can explain any single rating change in one sentence. Start there, add an expanding queue window, and only reach for something heavier when you can name the specific thing Elo got wrong.