Skip to the solution
HardSystem Design15 min read

How would you design a real-time gaming leaderboard?

0 views

The problem

Question presented to candidate: "Our mobile game has 25 million players per season. After every match we want to show the player their global rank, the top 10, and the five players around them, all updated in real time. How would you design the leaderboard?"

What a strong answer should cover:

  • Why ORDER BY score with OFFSET / COUNT(*) in a relational database gets slow for rank queries at this size, and the sorted set (skip list plus hash) that gives O(log N) update and rank
  • Redis commands: ZINCRBY, ZRANGE ... REV (top N), ZREVRANK (my rank), a range around my rank; memory per member and whether one node is enough
  • Tie-breaking: equal scores are ordered by member name in Redis; encode "who got there first" into the score and stay under 2^53
  • Source of truth: match results in a durable database, the leaderboard as a rebuildable derived index; idempotent score updates keyed by match id
  • Scale-out: hash sharding with a scatter-gather top-K merge vs score-range sharding, and approximate rank (histogram buckets) for the long tail
  • Seasons, regional and friend leaderboards

Clarifying questions expected:

  • How many players, how many score updates per second at peak, and how many rank reads?
  • Does rank need to be exact for everyone, or only for the top players?
  • How are ties broken, and when does a season reset?

Code / implementation expected: Optional โ€” the design with numbers; the example simulates a Redis sorted set with ranks, ties, sharding and approximate rank.

  • system-design
  • case-study

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

15 min read ยท 17 sections

Read the code

Leaderboard on a simulated Redis sorted set: rank, around-me, tie-breaking, sharded top-K and approximate rank
// Leaderboard: a simulated Redis sorted set (ZINCRBY, ZREVRANGE, ZREVRANK), tie-breaking,
// hash-sharded top-K merge, and a histogram for approximate rank.
let seed = 99;
function rand() { seed = (seed * 48271) % 2147483647; return seed / 2147483647; }

class ZSet { // standing for one Redis sorted set; Redis keeps a skip list, here we sort on read
  constructor() { this.score = new Map(); }
  zincrby(member, by) { this.score.set(member, (this.score.get(member) || 0) + by); }
  zadd(member, s) { this.score.set(member, s); }
  sorted() { // Redis order for ZREVRANGE: score descending, equal scores by member descending
    return [...this.score].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? 1 : a[0] > b[0] ? -1 : 0));
  }
  zrevrange(start, stop) { return this.sorted().slice(start, stop + 1); }
  zrevrank(member) { return this.sorted().findIndex(([m]) => m === member); }
  countAbove(s) { let n = 0; for (const v of this.score.values()) if (v > s) n++; return n; } // ZCOUNT key (s +inf
}
const name = (i) => "p" + String(i).padStart(6, "0");
const PLAYERS = 100000, EVENTS = 500000;
const board = new ZSet();
for (let i = 0; i < PLAYERS; i++) board.zadd(name(i), 0);
for (let e = 0; e < EVENTS; e++) { const r = rand(); board.zincrby(name(Math.floor(PLAYERS * r * r)), 1 + Math.floor(rand() * 50)); }
const show = (rows, base) => rows.map(([m, s], i) => "#" + (base + i + 1) + " " + m + " " + s).join(", ");
console.log(EVENTS + " ZINCRBY events over " + PLAYERS + " players");
console.log("top 3: " + show(board.zrevrange(0, 2), 0));
const me = name(54321), rank = board.zrevrank(me);
console.log(me + " has " + board.score.get(me) + " points, rank #" + (rank + 1));
console.log("around me: " + show(board.zrevrange(rank - 2, rank + 2), rank - 2));

// Ties: two players reach 900 points, p000001 first (t=1), p000002 later (t=2).
const ties = new ZSet(); ties.zadd("p000001", 900); ties.zadd("p000002", 900);
console.log("plain score tie order: " + ties.zrevrange(0, 1).map(([m]) => m).join(" then ") + " (by member name, not by who was first)");
const T = 2 ** 30; // composite score = points * 2^30 + (2^30 - 1 - t): earlier t wins, and it stays below 2^53
const fair = new ZSet(); fair.zadd("p000001", 900 * T + (T - 1 - 1)); fair.zadd("p000002", 900 * T + (T - 1 - 2));
console.log("composite score order: " + fair.zrevrange(0, 1).map(([m, s]) => m + "=" + s).join(" then "));
console.log("max points before passing 2^53: " + (2 ** 53 / T - 1));

// Hash sharding: 4 sorted sets; the global top 10 is the merge of each shard top 10.
const SHARDS = 4, shards = Array.from({ length: SHARDS }, () => new ZSet());
for (const [m, s] of board.score) shards[Number(m.slice(1)) % SHARDS].zadd(m, s);
const merged = [];
for (const sh of shards) merged.push(...sh.zrevrange(0, 9));
merged.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? 1 : -1));
const exactTop = board.zrevrange(0, 9).map(([m]) => m).join(","), mergedTop = merged.slice(0, 10).map(([m]) => m).join(",");
console.log("hash-sharded top 10: read " + merged.length + " entries from " + SHARDS + " shards, same as exact: " + (exactTop === mergedTop));
let above = 0; for (const sh of shards) above += sh.countAbove(board.score.get(me));
console.log("rank of " + me + " via ZCOUNT of higher scores on every shard: #" + (above + 1) + " (" + SHARDS + " calls; tied players share it)");

// Score-range sharding puts most players on the low shard.
const bounds = [1000, 3000, 10000];
const sizes = [0, 0, 0, 0]; for (const s of board.score.values()) { let k = 0; while (k < 3 && s >= bounds[k]) k++; sizes[k]++; }
console.log("score-range shards [0,1000) [1000,3000) [3000,10000) [10000,inf): " + sizes.join(" "));

// Approximate rank from a histogram of 100-point buckets (one small hash instead of N members).
const W = 100, hist = new Map(); for (const s of board.score.values()) { const b = Math.floor(s / W); hist.set(b, (hist.get(b) || 0) + 1); }
function approxRank(s) {
  const b = Math.floor(s / W); let higher = 0;
  for (const [k, n] of hist) if (k > b) higher += n;
  return higher + Math.floor(hist.get(b) * ((b + 1) * W - 1 - s) / W) + 1;
}
console.log("histogram buckets: " + hist.size);
for (const i of [54321, 90000, 300]) {
  const m = name(i), s = board.score.get(m);
  console.log("  " + m + " score " + s + ": exact #" + (board.zrevrank(m) + 1) + ", estimated #" + approxRank(s));
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 96 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

How would you design a hotel reservation system?

Continue