Design a Real-Time Gaming Leaderboard
A mobile game has tens of millions of daily players. After every match, a player's score changes, and they immediately want to see where they now stand: their global rank, the players just above and below them, and the top 100 in the world. There are also daily and weekly boards that reset on schedule, and prizes are paid to the top players of each week.
Score updates arrive in bursts when a tournament ends, and the top-100 page is opened far more often than anything else. Cheaters will try to submit fake scores.
Design the leaderboard service. In 45 minutes, choose a data structure that answers rank queries fast, size it, decide how to scale it past one machine if needed, and explain how periodic boards and prize payouts stay correct.
Sketch on the HLD board, write your answer in the Design doc, and size it with the Estimator. Start the mock interview from the timer for a 45-minute run.
Design Path · Data & Infrastructure · problem 38 of 70
Ask before you design. Try answering each yourself, then compare.
- How many players and updates?
Typical answer
50M daily active players, about 10 matches each per day, so ~500M score updates a day with tournament-end bursts around 5× average.
- What is the score: cumulative or best single result?
Typical answer
For the all-time board, cumulative points; for daily and weekly boards, points earned in that period. Scores only go up within a period.
- How exact must rank be?
Typical answer
Exact for the top 1,000 and for prize calculation. For a player ranked 3,000,000th, being off by a few hundred for a second is acceptable.
- How are ties broken?
Typical answer
Equal scores share a rank in display; for prizes, the player who reached the score first wins.
- How fresh must rankings be?
Typical answer
A player should see their own new rank within a second of the match ending; the global top 100 can be up to a second stale.
- Do we need friend leaderboards?
Typical answer
Nice to have; treat it as a follow-up.
Requirements
Functional
- Submit a score result for a player after a match (validated by the game server).
- Get the global top N (N up to 100) for all-time, daily and weekly boards.
- Get a player's rank and score, and the players ranked just above and below them.
- Reset periodic boards on schedule and snapshot final standings for prize payouts.
Non-functional
- ~6k updates/s average, 30k/s at tournament end; 60k reads/s peak.
- Top-N and own-rank reads under 50 ms p99; a new score visible within 1 s.
- No lost score updates; final weekly standings exact and reproducible.
- 99.95% availability for reads; degraded reads allowed from cache during an outage.
Out of scope
- Matchmaking and the game logic itself.
- Payment of prizes (we only produce the winners list).
- Friend graph management.
Back-of-the-envelope estimates
- Writes: 50M × 10 = 500M updates/day ≈ 5,800/s, ~30k/s at tournament end. Each update touches three boards (all-time, daily, weekly): ~90k sorted-set operations/s at peak.
- Reads: roughly 2 reads per update, ~12k/s average and ~60k/s at peak, dominated by the top-100 page.
- Memory: a sorted-set entry with an 8-byte player ID costs ~80–100 bytes including skip-list and hash overhead. 200M all-time players ≈ 20 GB; a daily board of 50M players ≈ 5 GB. Everything fits in a few in-memory nodes.
- Durable store: 500M results/day × ~100 B ≈ 50 GB/day of match history, ~18 TB/year.
A single in-memory node can do on the order of 100k simple sorted-set operations per second, so the all-time board fits on one primary with replicas. Sharding is needed for headroom and for burst writes, not for memory.
API
POST /v1/scores (game server only) {matchId, playerId, points, ts, signature}
GET /v1/boards/{board}/top?n=100 -> [{rank, playerId, name, score}]
GET /v1/boards/{board}/players/{id}?around=5 -> {rank, score, above[], below[]}
// board = all-time | daily:2026-10-26 | weekly:2026-W44Scores are submitted by the authoritative game server with a match ID, never by the client, so a retry with the same match ID is ignored and players cannot post arbitrary numbers.
Data model
Score store (source of truth): match_results(match_id PK, player_id, points, ts) and player_totals(player_id, period, points, reached_at) in a partitioned key-value or SQL store keyed by player. A conditional insert on match_id makes submissions idempotent.
Ranking store: one in-memory sorted set per board and period, e.g. lb:weekly:2026-W44, member = player ID, score = points. For tie-breaking, encode the score as points × 2^32 + (MAX_TS − reached_at) so earlier achievers sort higher. Players' names and avatars come from a profile cache, not the sorted set.
The sorted sets can always be rebuilt from the score store, which is why they may live in memory with only asynchronous persistence.
High-level design
Write path. When a match ends, the authoritative game server posts the result through the API gateway to the score service. The score service verifies the server's signature, runs basic plausibility checks, and writes the match result and new player totals to the score store with a conditional insert on match ID, so retries are harmless. It then publishes a score event. Board updaters consume events in order per player and apply them to the in-memory ranking store, incrementing the player's entry in the all-time, daily and weekly sorted sets.
Read path. The leaderboard API serves the top-100 page from a small cache refreshed from the ranking store about once a second, because every player sees the same list. A player's own rank is a direct reverse-rank lookup on the sorted set, and the neighbours are a range read around that rank; both are logarithmic and touch only a few entries.
Periods. A rollover job creates new keys for the next day or week at the boundary, so no reset is needed: writers simply start targeting the new key. After a grace period for late events, it snapshots the final standings to durable storage for prizes and lets old keys expire.
Open the HLD board and press Reference to see the architecture diagram.
Deep dives
Why a sorted set, and what it costs
A relational table with an index on score can return the top 100 cheaply, but a player's rank needs a count of everyone above them, which walks a large part of the index on each call. An in-memory skip list or balanced tree augmented with subtree sizes answers insert, update, rank-of and range-by-rank in O(log n), about 26 steps for 50M players. Memory is the main cost: roughly 100 bytes per member, so tens of GB for the largest boards, comfortably within a few nodes. Keep the durable store as the source of truth so the in-memory structure can be rebuilt after a total loss.
Scaling beyond one node
If one node cannot take the write burst, shard players by hash across N sorted sets. The top 100 is a scatter-gather: take the top 100 from each shard and merge, which is cheap and cached anyway. Global rank is the sum over shards of how many players score higher, a count-by-score-range query each shard answers in O(log n). Sharding by score range instead makes rank simple but creates hot shards as everyone crowds into the middle buckets. For very large boards an approximate rank from a per-shard score histogram is often enough beyond the top few thousand, with exact rank kept for the top tier.
Periodic boards and correct prize payouts
Key boards by period so a reset is just a new key. Define the period boundary by the match end time from the game server, not by the arrival time, and keep the old board writable for a grace period so delayed events still count. When the grace period ends, freeze it, and compute final standings from the durable score store rather than from the in-memory set, so a cache glitch cannot change who wins money. Store the snapshot with a checksum and keep it for audits and disputes.
Fairness and anti-cheat
Only accept scores from game servers that actually ran the match, signed with a server key. Reject results that exceed what is physically possible for the match length, and flag players whose score rate is a statistical outlier for review. Flagged players can be hidden from public boards immediately by removing them from the sorted sets while the score store keeps the evidence. Re-adding them after review is a single update.
Failure modes
- If a ranking node fails, its replica is promoted within seconds; any updates lost in asynchronous replication are replayed from the score event stream by offset.
- If the whole ranking store is lost, rebuild each board from player totals in the score store; meanwhile serve the last cached top 100 and hide individual ranks.
- If the board updater falls behind during a tournament burst, players see their rank a few seconds late; the score itself is already durable.
- If a duplicate match result arrives, the conditional insert on match ID rejects it, so totals never double-count.
- If a period rolls over while events are delayed, the grace window accepts them into the correct period based on match time.
Core algorithm in code
import heapq
import zlib
class Fenwick:
"""Counts players per score so 'how many scored more than s' is O(log MAX)."""
def __init__(self, size):
self.n, self.t = size, [0] * (size + 1)
def add(self, i, d):
i += 1
while i <= self.n:
self.t[i] += d
i += i & -i
def prefix(self, i): # players with score <= i
i, s = i + 1, 0
while i > 0:
s += self.t[i]
i -= i & -i
return s
class Shard:
"""Players hashed to shards. Each shard keeps a score histogram plus its own top list."""
def __init__(self, max_score):
self.max = max_score
self.hist = Fenwick(max_score + 1)
self.score = {}
def set_score(self, player, s):
if player in self.score:
self.hist.add(self.score[player], -1)
self.score[player] = s
self.hist.add(s, 1)
def count_above(self, s):
return len(self.score) - self.hist.prefix(s)
def top(self, n):
return heapq.nlargest(n, ((s, p) for p, s in self.score.items()))
def near(self, s, n):
above = sorted(((v, p) for p, v in self.score.items() if v > s))[:n]
below = sorted(((v, p) for p, v in self.score.items() if v < s), reverse=True)[:n]
return above + below
class Leaderboard:
def __init__(self, shards=4, max_score=100_000):
self.shards = [Shard(max_score) for _ in range(shards)]
def _shard(self, player):
return self.shards[zlib.crc32(player.encode()) % len(self.shards)]
def submit(self, player, points): # keep each player's best score
sh = self._shard(player)
if points > sh.score.get(player, -1):
sh.set_score(player, points)
def rank(self, player): # 1 + players strictly ahead, summed over shards
s = self._shard(player).score[player]
return 1 + sum(sh.count_above(s) for sh in self.shards)
def top(self, n): # scatter: local top n, gather: merge
return heapq.nlargest(n, (x for sh in self.shards for x in sh.top(n)))
def around(self, player, n=2):
s = self._shard(player).score[player]
cands = [x for sh in self.shards for x in sh.near(s, n)]
above = sorted(c for c in cands if c[0] > s)[:n]
below = sorted((c for c in cands if c[0] < s), reverse=True)[:n]
rows = list(reversed(above)) + [(s, player)] + below
return [(self.rank(p), p, v) for v, p in rows]
lb = Leaderboard()
scores = {"ana": 8200, "ben": 9100, "cho": 7600, "dev": 9950, "eli": 8800, "fay": 6100,
"gus": 8800, "hal": 7000, "ivy": 9400, "jon": 5000, "kim": 8350, "leo": 3000}
for p, s in scores.items():
lb.submit(p, s)
lb.submit("ana", 7000) # lower than her best: ignored
lb.submit("cho", 9300) # new personal best
print("top 5:", [(i + 1, p, s) for i, (s, p) in enumerate(lb.top(5))])
print("rank of kim:", lb.rank("kim"), " rank of eli and gus (tied):", lb.rank("eli"), lb.rank("gus"))
print("around kim:")
for r, p, s in lb.around("kim"):
print(f" #{r:<2} {p:<4} {s}")
print("players per shard:", [len(sh.score) for sh in lb.shards])
Trade-offs
- An in-memory sorted set gives O(log n) rank queries but costs RAM and needs rebuilding from the durable store after a total loss.
- Hash-sharding players spreads writes evenly but turns global rank into a scatter-gather over all shards; score-range sharding makes rank simple but creates hot shards.
- Caching the top 100 for one second removes most read load but shows slightly stale positions at the very top.
- Approximate ranks for the long tail save work and memory but cannot be used for prizes, which need an exact recomputation.
- Updating sorted sets asynchronously from events decouples the write path but adds up to a second of delay before a new score shows.
What a strong answer shows
- Explains why naive SQL rank queries fail and picks a structure with logarithmic rank.
- Estimates memory and throughput to decide whether sharding is needed at all.
- Computes global rank and top-N correctly when players are sharded.
- Keeps a durable source of truth separate from the in-memory ranking structure.
- Handles periodic boards with per-period keys, grace windows and exact final snapshots.
- Makes score submission idempotent and server-authoritative.
Mistakes to avoid
- Computing rank with COUNT(*) WHERE score > x on every request.
- Letting clients submit their own scores.
- Sharding by score range without noticing the hot middle buckets.
- Deleting and rebuilding a board at midnight instead of switching keys.
- Paying prizes from the in-memory board without reconciling against durable data.
Follow-ups to expect
- How would you support friend leaderboards? (Fetch the friend list and read their scores in one batch; for large friend lists maintain a per-user sorted set on write.)
- How would you build regional boards? (Separate keys per region; the same event updates global and regional sets.)
- How do you show percentile instead of exact rank for most players? (Rank divided by board size, or a score histogram per shard.)
- What if scores can go down? (Use set, not increment, with the new total; the structure handles moves in both directions.)