Notes
Listen
Listen first. After each match the player's score updates. They want the global top hundred, their own exact rank, and the five players just above and below them. There are all-time, weekly and daily boards, plus a friends board. Out of scope: matchmaking, cheat detection models and the rewards system.
The qualities. A new score should show up within a second, and rank reads should take under fifty milliseconds at p ninety-nine. No score may ever be lost, even if the fast store crashes. Scores come from trusted game servers, never straight from the client, or the top of the board fills with cheaters within an hour.
- submit a score after a match
- top 100 on a global board
- my exact rank and score
- the 5 players above and below
- boards: all-time, weekly, daily
- friends board
- out of scope: matchmaking,
- anti-cheat models, rewards
- new score visible in ≤ 1 s
- rank reads in ≤ 50 ms (p99)
- scores never lost, even if the
- leaderboard store crashes
- only trusted servers write
- scores, never the client
- available during big events
Ask scale
Scale. A hundred million players, twenty million daily, ten matches each: two hundred million score updates a day, about two thousand a second, ten thousand at peak. Reads are similar. A sorted set costs roughly a hundred bytes per member, so the all-time board is about ten gigabytes. That fits in memory on one machine.
100M players, 20M daily active 10 matches/day → 200M updates/day 200M / 10⁵ s ≈ 2,000 writes/s peak (evening, events) ×5 ≈ 10K/s reads: 10 board views/day each ≈ 2,000 reads/s, peak ≈ 10K/s sorted set ≈ 100 B per member 100M × 100 B ≈ 10 GB per board
Define API
The API. Score submission is internal, called only by game servers, and carries a match id so retries are safe. Reads are three queries: top N, one player's rank and score, and the k players around them. The board name, like weekly two thousand twenty-six week forty-three, selects which board.
internal, game server only:
POST /v1/scores
{ match_id, player_id, score }
GET /v1/boards/{board}/top?n=100
GET /v1/boards/{board}/players/{id}
→ { rank, score }
GET /v1/boards/{board}/players/{id}
/around?k=5
GET /v1/boards/{board}/friends Data model
Two layers of data. A durable scores table, keyed by board and player, holds the best score and when it was set. A matches table records which match ids were applied, so a replay does not double-count. On top sits the fast view: one Redis sorted set per board, with player id as the member and points as the score.
scores (SQL or DynamoDB) PK (board, player_id) best_score, achieved_at matches PK match_id → applied? (dedupe) Redis ZSET per board key lb:weekly:2026-W43 member player_id score points (+ tie-break)
High-level design
Why not just SQL? The top hundred is fine with an index on score. But my rank means counting every player above me. For someone in the middle that is tens of millions of index entries, per request. At ten thousand rank reads a second the database falls over. We need a structure that knows positions, not just order.
The full design. The game server, not the client, decides the score and publishes an event. A score updater consumes it, checks the match id has not been applied, saves the best score to the durable table, then updates the sorted sets for every board that score belongs to. Usually that is all-time, weekly and daily.
Reads are now single commands. Top hundred is a reverse range from zero to ninety-nine. My rank is reverse rank. Around me is a range from five above my rank to five below. The friends board fetches a few hundred scores in one call and sorts them in the API. Each costs log N plus the rows returned.
top 100: index on score, fast my rank: SELECT COUNT(*) FROM scores WHERE score > :mine → walks every row above you → 47M rows for a mid player at 10,000 reads/s: impossible
top 100: ZREVRANGE lb:all 0 99 WITHSCORES my rank: ZREVRANK lb:all player:42 around me (rank r): ZREVRANGE lb:all r-5 r+5 friends (≤ 500): ZMSCORE lb:all f1 f2 … → sort each O(log N + k)
Deep dive: rank
Deep dive one: why rank is cheap. A sorted set is a hash from member to score, plus a skip list ordered by score. Each link in the skip list also stores its span, how many nodes it jumps over. Finding a player walks down about log N levels and adds up the spans on the way. That sum is the rank. For a hundred million players, that is only a few dozen steps.
- hash: member → score
- (O(1) score lookup)
- skip list ordered by score
- each forward link stores its
- "span": nodes it skips
- rank = sum of spans walked
- on the way down → O(log N)
- 100M members: a few dozen steps
Deep dive: ties
Deep dive two: ties and time windows. Two players with the same points need a fair order, usually first to reach it wins. Pack that into the score: points shifted into the high bits, and a reversed timestamp in the low bits. Stay under two to the fifty-three, the largest exact integer in a double, which caps points near two million. Each window is its own key with a time to live. Z ADD with the G T flag keeps only a better score.
tie rule: same points →
whoever got there first
score = points × 2^32
+ (2^32 − 1 − epoch_secs)
(fits in a double up to 2^53)
windows = separate keys
lb:daily:2026-10-24 TTL 3 d
lb:weekly:2026-W43 TTL 21 d
ZADD GT: keep only a higher best Deep dive: sharding
Deep dive three: scaling. One Redis node does around a hundred thousand simple operations a second and holds this board easily, so start there, with a replica for failover. You outgrow it at around a billion players, or hundreds of thousands of writes a second. Read replicas help reads first. After that, you must split one board across several nodes.
Two ways to split. Shard by player hash: the top hundred is a merge of each shard's top hundred, but my rank means asking every shard how many scores beat mine and adding them up. Or shard by score range: then my rank is my local rank plus the sizes of all higher shards, which you can cache. The catch is rebalancing ranges as scores drift upward.
A useful trick: players at rank forty-seven million do not need the exact number. Keep exact ranks for the top million, and for everyone else keep a histogram of how many players sit in each score bucket. Their rank is the count in higher buckets plus an estimate inside their own. Show top thirty-eight percent. It is cheaper and kinder.
- one Redis node:
- ~100K simple ops/s
- 10 GB board fits easily
- outgrow it with:
- 1B players → 100 GB
- many regional boards
- or 200K+ writes/s
- first: read replicas
- then: shard the board
- by player hash:
- top 100 = merge top 100
- of each shard (easy)
- my rank = ZCOUNT on every
- shard and add (fan-out)
- by score range:
- shard 0: 0–999, 1: 1000+ …
- rank = local rank + sizes
- of all higher shards
- needs rebalancing as scores grow
exact for the top 1M only
everyone else: score histogram
bucket 0–99: 41,200,000
bucket 100–199: 22,900,000
…
rank ≈ players in higher buckets
+ position inside bucket
show "top 38%", not #47,213,908 Trade-offs
Trade-offs. Redis is fast but it is a view, not the source of truth. If the primary dies, a replica takes over and may miss the last second of writes. Replaying the score log from a saved offset fills that gap, and the updater is idempotent by match id. In the worst case, the whole board can be rebuilt from the scores table in minutes.
Monitor the lag from match end to board update, Redis CPU and memory, and the updater's consumer lag. Also watch sudden jumps into the top hundred, a common cheating signal. Before a tournament, expect ten times the writes: scale ahead of time and batch updates with pipelining. Server-side scoring is your first line against cheating.
- monitor:
- event → board lag (≤ 1 s)
- Redis CPU, memory, p99
- updater consumer lag
- sudden jumps in top 100
- risks:
- event day → 10x writes
- → pre-scale, batch ZADDs
- cheaters → server-side
- scores, review top 100
Say it like this
In the interview: SQL can sort but cannot cheaply count what is above you, so use a sorted set, a skip list with spans that answers rank in log time. Game servers emit scores to a log, an idempotent updater writes a durable table and the sorted sets. Pack tie-breaks into the score, use one key per time window, and shard by score range when one node is not enough.
- SQL rank = COUNT(*) → too slow
- Redis ZSET: skip list + spans
- top, rank, around me: log N
- game server writes, not client
- log → idempotent updater
- → scores table + ZSETs
- tie-break packed in the score
- one key per window, with TTL
- shard by score range at 10x
- approximate rank for the tail