0

Design an online game leaderboard

by · about 1 hour ago · ⑂ 0 remixes

Source: https://www.hack2hire.com/question-bank/questions/system-design/69e1ab3b95e80ae213e5d40c
Company: Meta, Microsoft
Difficulty: Easy

Problem statement

Show the top players and each player's own rank in real time as millions of scores update.

Requirements

We are building the leaderboard service for an online game: it ranks players by score, shows the global top players and each player's own rank in real time, and offers a friends-only view, while millions of score updates arrive.

Functional requirements

  • Game servers submit a score when a match ends; the player's leaderboard score updates (best-score or cumulative, configured per board).
  • Show the global top 100, paginated, with name, avatar and score.
  • Show any player's own rank and score, plus the few players just above and below them ("you are #48,213").
  • Show a friends leaderboard: the player and their friends ranked together.
  • Support several boards: per game mode, per region, daily, weekly and per season.

Non-functional requirements

  • A new score is visible in ranks within 1–2 s.
  • Reads under 50 ms p99; leaderboards are opened far more often than scores change per player.
  • Ranks are exact for the top 10,000; for the long tail, an approximate rank ("top 12%") is acceptable at extreme scale.
  • Scores are durable: a cache loss must not lose any player's score.
  • Only trusted game servers submit scores; clients never write scores directly.
  • Ties break deterministically (earlier achievement ranks higher).

Back-of-the-envelope estimation

  • 50M daily active players, 300M players with a score on the all-time board.
  • Each player finishes ~10 matches/day → 500M score writes/day ≈ 6K/s average, 30K/s peak (evening, event launches).
  • Reads: each player opens the leaderboard ~5 times/day → 250M/day ≈ 3K/s average, 20K/s peak, plus 10× bursts on season end.
  • Sorted set memory: ~16-byte member ID + 8-byte score + skiplist overhead ≈ 80–100 B per entry → 300M × 100 B ≈ 30 GB for the all-time board. Daily and weekly boards hold only active players: 50M × 100 B ≈ 5 GB each.
  • Durable score rows: 300M players × ~10 boards × 50 B ≈ 150 GB, plus a score event log of 500M × 100 B ≈ 50 GB/day.

API design

Write (internal, called by authenticated game servers):

POST /v1/scores
{ "match_id": "m_9f2", "player_id": "p_123", "board_ids": ["global", "na-ranked", "weekly-2026-41"],
  "score": 4820, "achieved_at": "2026-10-12T19:03:11Z" }
→ 202 { "accepted": true }

match_id + player_id is the idempotency key, so retries never double-count cumulative boards.

Reads (client-facing, through the API gateway with a player session):

GET /v1/boards/{board_id}/top?offset=0&limit=100
→ { "entries": [ { "rank": 1, "player_id": "p_7", "name": "Ash", "score": 99120 }, ... ], "as_of": "..." }

GET /v1/boards/{board_id}/players/{player_id}?around=5
→ { "rank": 48213, "score": 4820, "percentile": 99.2,
    "neighbors": [ { "rank": 48208, "player_id": "...", "score": 4831 }, ... ] }

GET /v1/boards/{board_id}/friends/{player_id}
→ { "entries": [ { "rank": 1, "player_id": "...", "score": 7310 }, ... ] }   // rank within friends

Supporting: GET /v1/boards (list active boards and their windows), POST /v1/boards (admin creates a seasonal board with start/end and scoring rule), DELETE /v1/boards/{id}/players/{player_id} (anti-cheat removal).

Data model

Durable store: a sharded relational DB or Cassandra/DynamoDB, the source of truth used to rebuild the cache.

Table Key Columns
player_scores PK (board_id, player_id), partitioned by player_id score, achieved_at, updated_at
score_events PK (player_id, match_id) board_ids, score, achieved_at (append-only log, idempotency)
boards PK board_id type (best/cumulative), window_start, window_end, region, status
players PK player_id name, avatar_url, banned

Friends come from the social graph service (friends(player_id) → [player_id]), not this service.

Ranking index: Redis sorted sets, one per board: key lb:{board_id}, member player_id, score a single double that encodes score and tie-break.

  • Tie-break encoding: encoded = score × 2^21 + (2^21 − 1 − seconds_since_board_start / 8) kept under 2^53 so doubles stay exact; earlier achievers get a higher suffix. For scores too large to pack, keep the tie-break in the member ({inverted_ts}:{player_id}) and accept a slightly fatter member.
  • ZREVRANK lb:{board} p → rank; ZREVRANGE lb:{board} 0 99 WITHSCORES → top 100. Both O(log N + M).
  • Player profiles are cached separately (player:{id} hash) and joined at read time.

Key invariants:

  • Redis never holds a score that the durable store doesn't; we write the DB first, then Redis.
  • A best-score board only moves a player up: use ZADD lb:{board} GT p score.
  • A cumulative board applies each match_id once: the idempotency check on score_events happens before ZINCRBY.

High-level design

flowchart LR
  GS[Game servers] --> SI[Score ingest API]
  SI --> DB[(Score DB)]
  SI --> Q[Score event stream]
  Q --> RW[Ranking workers]
  RW --> R[(Redis sorted sets)]
  C[Game client] --> GW[API gateway]
  GW --> LS[Leaderboard read service]
  LS --> R
  LS --> PC[(Profile cache)]
  LS --> SG[Social graph service]
  Q --> AC[Anti cheat]
  DB -. rebuild .-> R
  1. Score update. A game server posts the match result. The ingest API verifies the server's mTLS identity, dedups on (player_id, match_id), writes score_events and upserts player_scores (conditional: only if higher, for best-score boards), then publishes to Kafka partitioned by player_id. Ranking workers apply ZADD GT or ZINCRBY to each listed board. End to end ~200–500 ms.
  2. Global leaderboard read. The read service runs ZREVRANGE 0 99 for the top page. The top 100 of a hot board is also cached in-process for 1 s, so 20K reads/s become a few Redis calls per pod per second. Names and avatars come from one MGET on the profile cache.
  3. My rank. ZREVRANK plus ZREVRANGE rank−5 rank+5 returns the player's rank and neighbors in two O(log N) calls, pipelined.
  4. Friend leaderboard read. Fetch the friend list (cached, ~200 friends median, capped at 5,000), then pipeline ZMSCORE lb:{board} f1 f2 ... and sort the few hundred results in the service.
  5. Anti-cheat consumes the same stream and calls the removal endpoint (ZREM + DB flag) for flagged players.

Deep dives

Why sorted sets make ranking cheap. A Redis sorted set is a hash map (member → score) plus a skiplist ordered by score, where each skiplist link stores its span. Summing spans while descending gives a member's rank in O(log N): ~30 hops for 300M entries, a few microseconds. Insert, update and top-K are O(log N + K). The alternative is SQL: SELECT COUNT(*) WHERE score > :mine scans the index, O(N) per rank query, which is 300M rows for a mid-ranked player. Even with a B-tree on score, counting is linear without an order-statistic tree. One Redis node does ~100K sorted-set ops/s, so 30K writes/s + 20K reads/s fit on a primary with replicas for reads.

Friend leaderboard: fan-in on read vs. fan-out on write.

Approach Write cost Read cost Problems
Fan-in on read (pick this) 0 extra ZMSCORE of ~200 friends + sort, ~1 ms Slower for players with 5,000 friends; cap and paginate
Fan-out on write: a sorted set per player of their friends' scores 1 score update × friends count (200 avg) → 6M ZADDs/s at peak ZREVRANGE on own set Write amplification, 50M sets × 200 entries ≈ 1 TB, and friend add/remove must backfill
Precomputed batch Hourly job Instant Stale; not real time

Friend lists are small and reads are rare relative to writes per friend, so computing at read time wins. Cache the result per player for 5–10 s to absorb repeated opens.

Time-windowed and seasonal boards. Each window is its own key: lb:weekly:2026-41, lb:daily:2026-10-12. Workers write to all active windows for each event; a new window starts empty. Set a TTL of the window plus a grace period (e.g. 7 days) so old keys expire. At window close, a job snapshots the final top 10,000 and each player's final rank to the DB for rewards and history, then the live key ages out. A rolling "last 7 days" board is harder: keep daily keys and answer with ZUNIONSTORE of 7 daily keys into a temp key refreshed every minute, rather than trying to subtract expired scores.

Sharding and approximate ranking at extreme scale. 300M entries (30 GB) fit on one large node, but a single key cannot be split by Redis Cluster, and one primary caps write throughput. Options:

  • Shard by score range: shard 0 holds scores ≥ 100K, shard 1 holds 50K–100K, and so on. Top-K reads hit only the top shard. A global rank is my rank in my shard + sum of ZCARD of all higher shards (cache the counts, refreshed every second). Boundaries must be rebalanced as the score distribution drifts; a player crossing a boundary is a ZREM + ZADD across shards.
  • Shard by player hash: even load, but top-K needs scatter-gather (top 100 from each of N shards, merge), and rank needs ZCOUNT(score, +inf) on every shard and a sum.
  • Approximate tail: keep exact sorted sets only for the top ~1M; for everyone else keep a histogram of score buckets (e.g. 10K buckets with counts, updated by the workers). Rank ≈ players above the bucket + interpolation within it, shown as "top 12%" or "≈ #1.2M". This cuts memory from 30 GB to ~100 MB plus the top set, and players below the top million rarely care about the exact digit.

Cache warming and Redis recovery. Redis runs with a replica per primary and AOF every second, but the DB is the source of truth. If a node is lost, a rebuild job scans player_scores for the board in parallel by partition and pipelines ZADD in batches of 1,000; at ~500K entries/s per loader and 8 loaders, 300M entries reload in ~75 s. During the rebuild, Kafka consumers pause for that board and resume from the offset taken at scan start, so updates during the scan are replayed (ZADD GT and idempotent cumulative updates make the replay safe). Reads in the meantime serve the last snapshot of the top 10,000 from the DB and mark ranks as "updating".

Trade-offs

  • Redis sorted sets give O(log N) rank and real-time top-K but hold everything in memory (30 GB per all-time board) and need the DB to stay durable; a pure DB design is cheaper per byte but cannot answer "my rank" cheaply.
  • Writing the DB first, then Redis through a stream, adds ~0.5 s of lag but makes Redis rebuildable and keeps one source of truth; writing Redis directly would be faster but lose scores on a failover.
  • Fan-in friend reads cost a few milliseconds per request but avoid hundreds-fold write amplification; very large friend lists get capped.
  • Approximate ranks for the long tail save ~99% of memory and remove the single-key bottleneck, at the cost of showing "top 12%" instead of an exact number to most players.
  • Score-range sharding keeps top-K reads on one shard but needs boundary rebalancing; hash sharding balances load but turns every read into scatter-gather.
  • The 1 s in-process cache of the top 100 means the visible top list can lag by a second; that is invisible to players and removes the hot-key problem.

Interviewer expectations

Mid-level (L4)

  • Identifies Redis sorted sets and the right commands (ZADD, ZREVRANGE, ZREVRANK) for top-K and own rank.
  • Keeps a durable score table separate from the cache and has game servers, not clients, submit scores.
  • Gives a working friends leaderboard, even if it's simply fetch-friends-and-sort.

Senior (L5)

  • Explains why rank is O(log N) in a skiplist and O(N) in SQL, with estimates for memory and QPS.
  • Handles ties, best-score vs. cumulative semantics, idempotent retries, and daily/weekly/season keys with expiry.
  • Compares fan-in vs. fan-out for friends with numbers and picks fan-in; covers Redis recovery from the DB.

Staff (L6+)

  • Recognizes that one sorted set is a single-key bottleneck and designs score-range sharding or a hybrid exact-top plus histogram tail.
  • Defines the consistency contract (DB is truth, Redis is rebuildable, stream replay is idempotent) and the season-end snapshot for rewards.
  • Addresses abuse and operations: anti-cheat removal, read spikes at season end, hot-key caching, and how ranks degrade during a rebuild.

Sign in to comment, vote or remix.