Representative interview topic

System Design Interview: How Would You Design a Real-Time Game Leaderboard?

System designHard
Offer.cc Editorial TeamPublished Updated

Question

Design a real-time game leaderboard. Each season has 50 million active players, with peaks of 200,000 score updates and 1 million reads per second. The system must show the global top 100, a player's exact rank, and nearby players. Updates must become visible within 5 seconds, and read p99 must stay below 100 milliseconds. Scores are integers from 0 to 1,000,000, only a player's seasonal best is retained, and tied players share a rank. Explain the APIs, data model, score validation, ordering and sharding, consistency, season settlement, recovery, capacity, and verification.

Prompt and Scope

Design a seasonal leaderboard for a competitive game. A season has 50 million active players, receives up to 200,000 score updates per second, and serves up to 1 million reads per second. Players need the global top 100, their exact rank, and 10 players on either side. A submitted result must appear within 5 seconds, and read p99 should remain below 100 milliseconds. Scores are integers in 0..1,000,000, and each player keeps only their best score for the season. Ties share a rank, so ranks 1, 2, 2 are followed by 4. Tied players have a stable display order by player_id, but that order does not change their rank.

The scale, latency, and score range are interview assumptions. A client cannot declare a trusted score. The match service finishes result validation and anti-cheat checks before producing an event the leaderboard can consume. The problem tests ordered indexing, separation of reads and writes, hot-key sharding, consistency, rebuildability, and the season lifecycle. It does not ask for an anti-cheat model.

A public technical interview guide lists “design a leaderboard for a game” as a system design example and says it tests ranking logic, read/write performance, and real-time updates. A Redis leaderboard tutorial published in 2026 also demonstrates the basic Sorted Set path for updating scores, retrieving Top-N, and looking up rank. The public evidence does not establish a reliable company attribution, so companyName remains null.

What the Interviewer Is Evaluating

The first signal is whether the candidate defines “rank.” Higher score first is easy; deciding whether ties share a rank, favor the earliest achiever, or break on player ID directly changes the data model. Without this contract, two services can produce different ranks for the same players.

The second signal is recognizing that one Sorted Set solves one ordered collection. Redis documents ZADD updates and ZREVRANK rank lookups as O(log N), which is useful for a moderate leaderboard. Redis Cluster, however, assigns hash slots by key. Keeping the global board in one key does not split its 50 million members merely because more cluster nodes were added. A strong answer keeps the simple path first, then adds application-level sharding only when one key exceeds memory, write, or recovery budgets.

The third signal is turning “exact within 5 seconds” into a consistent read version. Moving a player from one score bucket to another creates an intermediate disappearance with remove-then-add, or a duplicate with add-then-remove. Reading bucket counts from one instant and bucket order from another also produces a wrong rank. A testable design makes one request read one fully published version.

Finally, the design must be operable. Score events need idempotency, corrections must be able to lower a score, season close must freeze a consistent snapshot, and a lost cache must be rebuildable from a trusted ledger. A diagram containing only “game service → Redis” does not address duplicates, hotspots, data loss, revoked cheating scores, or settlement disputes.

Questions to Clarify Before Answering

  • Is the score cumulative, best, or latest? This prompt keeps the seasonal best. A cumulative score

makes event identity and atomic increments critical because duplicate events otherwise add twice. If adjudicators can correct scores, the API needs versioned absolute values rather than only ZINCRBY.

  • How are ties ranked? This prompt uses competition ranking: only strictly higher scores count, and

tied players share a rank. “Earliest achiever wins” requires trusted achievement time in the sort key; the default lexicographic tie order of a ZSET does not provide that rule automatically.

  • What are the exactness and freshness requirements? Published versions must be exact, with up to

5 seconds of staleness. Linearizable immediate visibility would put cross-shard coordination on the write path. Approximate rank would permit samples or quantile sketches and a much simpler design.

  • Which boards are required? The main path is the global seasonal board. A small fixed set of regions

can have separate materialized views. A friends board usually fetches friend scores in a batch and sorts them per request instead of maintaining one board per player.

  • Which reads are supported? Top 100, personal rank, and a 20-player neighborhood. Unbounded deep

pagination creates expensive scans, so windows should be limited and use a versioned cursor. An expired version requires relocation.

  • Who confirms a score? Only a trusted match-results service writes. A client submission is game

input, not a score that can be inserted directly into the ordered index.

  • When does the season close? Use server event time and an explicit cutoff. Product policy must say

whether late results are rejected, reviewed, or assigned elsewhere; a background job cannot silently mutate an awarded snapshot.

  • What is durable? A rank view may be stale briefly or rebuilt. The trusted score ledger and final

season snapshot cannot disappear with a cache failure.

30-Second Answer Framework

“I would define rank as 1 + the number of players with a strictly higher score, so tied players share a rank. A trusted match service writes an absolute best score with an event_id and player version. The durable score table is the source of truth, and its change stream materializes the leaderboard. At smaller scale, one Redis Sorted Set supports ZADD, Top-N, and reverse rank. Once the global hot key for 50 million players exceeds one-shard budgets, I would partition it by fixed score ranges. A personal rank is the count in all higher buckets, plus the count of higher scores in the player's bucket, plus one.

“A cross-bucket move cannot be exposed halfway through. Materializers therefore commit a version every few seconds. After all buckets, count prefixes, and the Top 100 are ready, a coordinator atomically swaps the current manifest. Responses include leaderboard_version and as_of. Writes are idempotent by event and version, the cache is rebuildable from the score ledger, and season close freezes and reconciles one complete version before rewards are issued.”

Step-by-Step Deep Dive

Step 1: Fix the APIs and ordering contract before choosing storage.

Separate the internal write path from public reads:

text
POST /internal/v1/seasons/{season_id}/scores:apply
GET  /v1/seasons/{season_id}/leaderboard/top?limit=100
GET  /v1/seasons/{season_id}/players/{player_id}/rank
GET  /v1/seasons/{season_id}/players/{player_id}/neighbors?radius=10&version=...

The write contains event_id, match_id, player_id, absolute best_score, score_version, and a trusted completion time. Only the match-results identity is authorized. A read returns leaderboard_version, as_of, score, rank, and members. A neighborhood request must retain the version from the first response; otherwise rank changes while paging can duplicate or skip players.

Four invariants drive the design: each (season_id, player_id) occurs once in a version; the published player score matches the ordered index; rank always equals 1 + count(score > my_score); and a manifest references only shard versions that all finished.

Step 2: Make the trusted score table the source of truth.

The match-settlement service validates authorization, match state, and anti-cheat results before writing an immutable result ledger. In one database transaction, the leaderboard writer deduplicates event_id and conditionally updates the player row. It rejects an older version, returns the prior result for an identical retry, and changes the normal best only when new_score > best_score. Revoking a cheating score writes a higher score_version and a corrected absolute value, so a decrease also converges.

After commit, an outbox or equivalent durable change stream emits:

text
ScoreChanged {
  season_id, player_id, old_score, new_score,
  score_version, event_id, committed_at
}

Events are partitioned by player, and a materializer applies only a change newer than that player's current version. Duplicate delivery cannot add points twice, and a late old event cannot overwrite a new score. The leaderboard is a disposable materialized view. The ledger and current-score table are the rebuild sources.

Step 3: Present the one-Sorted-Set design first.

When membership, peak writes, memory, and recovery time fit one shard, one ZSET per season is the right starting point:

text
key    = leaderboard:{season_id}
member = player_id
score  = best_score

ZADD sets a new score for an existing member and repositions it. A descending range returns Top-N, and ZREVRANK returns a position. Redis documents update as O(log N), a fixed-size range as O(log N + M), and rank lookup as O(log N). The prompt's integer range 0..1,000,000 is far below the 2^53 boundary through which a double represents integers exactly.

Equal-score members are ordered by the binary lexicographic order of member values. That only fits a contract where ties share a rank and ID controls display order. Do not pack an arbitrary score and millisecond timestamp into one floating-point value and assume composite ordering remains correct. ZREVRANK + 1 is also not the shared business rank because tied members receive different positions. The business formula is 1 + count(score > my_score). Inside one ZSET, ZCOUNT key (my_score +inf computes that count; ( makes the lower bound exclusive. Keep display position separate from business rank.

Step 4: Prove why larger scale requires application-level sharding.

Redis Cluster maps keys to hash slots, and a stable slot is served by one primary. The key leaderboard:{season} remains one key, so its members, writes, and recovery load remain concentrated on one slot's primary. Sharding by hash(player_id) balances writes but makes a global rank expensive because every request must merge or count across all player shards.

This prompt has a finite score range, so fixed score-range buckets are useful. With one 10,000-point range per bucket, there are 101 buckets:

text
bucket_id = floor(score / 10,000)
rank(player) = 1
             + count(all buckets with a higher bucket_id)
             + count(score > player_score inside the player's bucket)

Each bucket remains ordered, and bucket keys can occupy different slots. For every version, store bucket counts and a high-to-low prefix sum. Personal rank then needs a player lookup, one prefix value, and one strictly-higher count inside the bucket. Top 100 scans only the highest nonempty buckets and is materialized separately. Fixed ranges can create a hot high-score bucket. Split that range further when measured, but put the boundaries in the versioned manifest so readers and writers use the same layout.

Step 5: Use versioned publication to solve cross-bucket atomicity.

When a player moves from 39,000 to 51,000, the system removes them from bucket 3, adds them to bucket 5, and changes two counts. Remove, add, and counter changes across Redis slots are not one ordinary atomic operation. Exposing intermediate state produces a duplicate, disappearance, or off-by-one global rank.

Materializers therefore commit logical snapshots in short epochs. Each rank shard starts from the current published version, applies an idempotent batch, and prepares the next ordered index and count. Once every shard reports completion, the coordinator checks total membership, change counts, and shard checksums, then atomically moves current_manifest from v to v+1. A read gets the manifest first and carries that version through all subqueries. An unfinished version is invisible, failed shards can retry, and the prior version stays until in-flight reads finish.

Logical snapshots can reuse old data through MVCC, copy-on-write pages, or a base plus deltas, avoiding a full copy of 50 million members every few seconds while preserving an immutable external version. Epoch duration, apply lag, and publish lag must remain below 5 seconds together. If they do not, report staleness and stop claiming real-time service instead of publishing a half-complete version.

Step 6: Separate global, regional, and friends boards.

The global board uses the bucketed index. A small fixed set of regions can maintain independent (season, region) views from the same score event. Region comes from a server-maintained player-profile version so a client cannot switch regions during a request. Filtering only the global Top-N would miss a strong regional player outside the global page; use a regional index or label the result approximate.

A friends board is normally small. Fetch versioned friend IDs from the social graph, batch-read their scores at the same leaderboard version, then sort by score DESC, player_id ASC and compute ties in the application service. Maintaining one friends ZSET per player causes extreme write amplification: one score change fans out to every friend's board, and relationship changes require backfills.

Step 7: Estimate traffic, then size shards from measurements.

If one durable score event including its envelope is 128 bytes, the peak logical write lower bound is:

text
200,000 events/s × 128 bytes = 25.6 MB/s
25.6 MB/s × 86,400 s = 2.21184 TB/day
three-replica log lower bound = 6.63552 TB/day

This uncompressed lower bound excludes indexes, protocol overhead, batches, retries, and replica recovery. Only an event that improves the best score or corrects it changes the ranking, but every trusted event still needs ledger-side deduplication and audit. One million reads per second cannot all reach rank shards. Cache Top 100 by version. A personal result can use a short TTL, but the key includes player and version; the shard then batch-reads neighborhood windows.

Do not copy a fixed shard count from an article. Benchmark member memory, ZADD, strictly-higher counts, range reads, and snapshot construction with 50 million production-shaped rows. Record p50/p95/p99, CPU, memory, replication lag, and recovery time, then derive bucket splits and node count from peak writes and failure headroom. If one ZSET remains inside every budget, it is more reliable than a custom bucket coordinator.

Step 8: Close seasons and keep the view rebuildable.

After a season enters CLOSING, already accepted pre-cutoff results continue through the stream, while new ineligible results are rejected. Record an input high watermark. Once every materializer reaches it, create a candidate final version. Settlement compares total players, the sum of bucket counts, Top 100, random sampled ranks, duplicate-player count, and every shard checksum. On success, mark the manifest FINAL; the reward service reads only that immutable version. Later disputes enter an audited correction flow instead of silently rewriting the awarded board.

If a ranking cache or the entire cluster is lost, replay the current-score table or immutable ledger by score_version into a new namespace. Build and reconcile a complete candidate, then atomically swap the manifest. The old version remains read-only during rebuild. If none exists, return an explicit temporarily unavailable or stale state rather than presenting a partial board as complete.

The fault matrix includes duplicate and out-of-order events; cross-bucket increases and downward corrections; a shard crash mid-epoch; a coordinator crash before and after manifest swap; loss of a cache node; heavy ties at the Top-100 boundary; 1 million read QPS; late results around the cutoff; and full rebuild with shadow comparison. Continuously assert the four invariants and monitor event-to-publish p99, version age, bucket skew, rejected events, rebuild progress, and checksum failures.

Strong Sample Answer

“I would define rank first. Tied players share a rank here, so a player's rank is 1 + the number with a strictly higher score; Top-100 display position and business rank are separate. Clients cannot write a trusted score. After match validation, the leaderboard writer deduplicates the event ID and conditionally sets an absolute best by player score version. The current-score table is the source of truth, and its committed change stream drives the rank view.

“If the board fits one Redis shard, I would start with one Sorted Set per season. Setting scores, reading Top-N, and counting by score are direct. But the global board for 50 million players is one hot key, and Redis Cluster shards by key rather than by members within a key. Since the score range is bounded at 0..1,000,000, I would scale to 101 fixed score-range buckets. Personal rank is the higher-bucket count prefix plus strictly higher players in the local bucket plus one. Top 100 comes from the highest nonempty buckets.

“A cross-bucket move changes two keys and counts. Updating in place would expose intermediate state, so I would build the next index in epochs of at most a few seconds. Only after every shard applies idempotent changes and membership/checksum reconciliation passes does the coordinator atomically swap the current manifest. Every response includes version and as-of, and neighborhood reads retain that version. Published versions are exact at the cost of up to 5 seconds of staleness.

“At 128 bytes per event, peak writes are 25.6 MB/s of logical traffic, about 2.21 TB per day, and a three-replica log lower bound is about 6.64 TB. Index nodes and bucket counts still come from a benchmark on production-shaped data. Cache Top 100 by version. Build friends boards by batch-fetching friends' scores and sorting locally, avoiding fan-out on every score change.

“At season close, I would record an input high watermark, wait for all shards to catch up, and freeze a candidate final version. I would reconcile membership, bucket sums, Top 100, sampled ranks, and shard checksums before the reward service reads a FINAL manifest. A lost cache is rebuilt from the trusted score table or ledger. Fault tests cover duplicate/out-of-order events, cross-bucket corrections, shard and coordinator crashes, heavy ties, cutoff races, and full rebuild.”

Common Mistakes

  • Accept a score directly from the client → the ordering store cannot determine whether it is valid → consume only audited results confirmed by a trusted match service.
  • Use ZREVRANK + 1 for tied ranks → a ZSET gives equal scores distinct lexicographic positions, violating the shared-rank contract → compute 1 + count(score > my_score).
  • Pack a timestamp casually into a floating-point score → doubles have a precision boundary and a composite encoding can reverse key priority → define ties first, then use a proven integer encoding or a composite-order index.
  • Assume more Redis Cluster nodes split one global-board key → Cluster assigns a key's hash slot, and the single key remains on one slot primary → measure the single-key boundary, then shard by a mergeable business dimension.
  • Hash-shard players and fan out every rank query → query cost grows with shard count and explodes at 1 million read QPS → use counts over the finite score range, or return an approximate rank when permitted.
  • Remove then add, or add then remove, across buckets → readers see a disappearance, duplicate, or wrong count → build a complete version and publish it atomically through a manifest.
  • Use ZINCRBY for every result → duplicate events add twice and a cheating-score revocation cannot decrease safely → idempotently set an absolute score by event and player version.
  • Issue rewards from the live cache at cutoff → accepted pre-cutoff events may remain in flight, and cache is not the durable truth → record a high watermark, catch up, reconcile, and freeze a FINAL version.
  • Validate only that Top 100 looks right → a bucket-count error or duplicated player can shift every long-tail rank → verify membership conservation, uniqueness, sampled rank formulas, shard checksums, and a full rebuild.

Follow-up Questions and Responses

Follow-up 1: Can we keep the five-second snapshot if a player must read a write immediately?

Separate the writer's read-your-writes experience from the globally published rank. The write response can return the committed new score and pending version. An immediate refresh can say that the score is confirmed and global rank is updating, or show the new personal score while global rank still references the latest complete manifest. If product requires the new score and exact global rank in the same synchronous response, the update must enter a globally coordinated ordering path. Write latency and failure coupling increase, so the original throughput target must be re-evaluated.

Follow-up 2: What if 10 million players accumulate in the highest score bucket?

Bucket layout is versioned metadata. First prove the hotspot through write QPS, membership, CPU, and p99, then split that score range into narrower sub-buckets. Because shared rank depends on strictly higher scores, one exact score cannot be arbitrarily player-sharded and have local ranks summed; its total count must remain aggregated. Build the new layout in parallel, compare membership and sampled ranks at the same input high watermark, and then publish a manifest referencing the new layout.

Follow-up 3: What changes if the earliest player to reach a tied score wins?

Rank is no longer a score-only count. The sort key becomes (score DESC, achieved_at ASC, player_id ASC), with achieved_at coming from trusted settlement. A normal ZSET ties only by member lexicographic order. A fixed-width integer score and inverted member encoding can work when its precision and order are proven, but it is fragile. At larger scale I would favor ordered shards supporting composite keys and order statistics, with tests for the same millisecond, retries, and corrected achievement times.

Follow-up 4: What happens if anti-cheat revokes the champion after rewards were issued?

The technical system cannot decide whether rewards are clawed back. It accepts a negative correction with a higher score_version, retains the original event, evidence, approver, and time, and produces a new corrected manifest without rewriting the original FINAL snapshot. The reward service applies the operational policy to freeze, recover, or promote rewards and associates its action with a leaderboard version. This preserves an explanation of both the original award and later correction.

Follow-up 5: How do you prove a ledger rebuild matches the online leaderboard?

Replay event versions to the same input high watermark in an isolated namespace. Compare player count, counts by score bucket, score sums and hashes, Top 100, many stratified sampled players using 1 + count(higher score), and deterministic checksums for every shard. Shadow both read paths and count any score, rank, or neighbor-window difference. Swap the manifest only after every threshold passes. A rebuild job exiting successfully is not evidence that its result is correct.

Public sources

Related questions

Related interview tool

Use Solve for a system design answer

Clarify the requirements first, then move through scale, architecture, component choices, and trade-offs.

View the tool