System Design: Real-Time Game Leaderboard — Rankings for Millions of Players
Redis Sorted Sets use a
skip list for O(log N)
rank operations and a
hash map for O(1) score
lookups. Skip lists were
invented by William Pugh
in 1990 as a probabilistic
alternative to balanced
trees — simpler to
implement, similar
performance.
Design a real-time leaderboard for a mobile game with 100 million players. The leaderboard shows the top 100 players globally and each player’s personal rank (“You are #4,521,334 globally”). Scores update in real-time after every match. Design for correctness, low latency reads, and high write throughput.
The question: Design a real-time leaderboard for a mobile game with 100 million players. Show the global top 100 and each player’s personal rank. Scores update after every match. Design for correctness, low latency, and high write throughput.
1. What Makes Leaderboards Hard
Four challenges make leaderboards deceptively hard:
- Write throughput: 100M players finishing matches → score updates arrive in bursts. A popular game can see 100,000 ZADD operations per second.
- Global rank queries: “What is my rank?” requires knowing how many players have a higher score. Naively that means scanning 100M rows.
- Top-100 freshness: The moment someone’s score pushes them into the top 100, they should appear. Stale caches won’t do.
- Tie-breaking: Two players at score 9,999 need a deterministic ordering — otherwise ranks flicker every time either player plays.
2. Level 1 — Naive SQL
The obvious first attempt: store scores in a relational table and query rank on demand.
-- Schema CREATE TABLE scores ( user_id BIGINT PRIMARY KEY, score BIGINT NOT NULL, updated TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_score ON scores (score DESC); -- Rank query for a given user SELECT COUNT(*) + 1 FROM scores WHERE score > (SELECT score FROM scores WHERE user_id = ?); -- Top 100 SELECT user_id, score FROM scores ORDER BY score DESC LIMIT 100;
Why this fails at scale:
- Rank query at 100M rows: the inner
SELECTis O(log N) with the index; the outerCOUNT(*)is O(log N + K) where K = players with higher score. At rank #4,521,334 that means scanning ~4.5M index entries. - Top-100 query is fast with the index (index scan stops after 100 rows). But every score update rewrites the index — at 100K updates/sec that creates severe index contention.
ORDER BY score DESC LIMIT 100triggers a potential lock on the entire index during writes.
3. Level 2 — Redis Sorted Set (the Right Answer)
Redis Sorted Set (ZSET) is purpose-built for exactly this problem. Internally it combines a skip list for ordered traversal and a hash map for O(1) member lookup.
| Command | Operation | Complexity |
|---|---|---|
ZADD leaderboard {score} {userId} | Add or update a player's score | O(log N) |
ZREVRANK leaderboard {userId} | Get rank (0 = highest score) | O(log N) |
ZREVRANGE leaderboard 0 99 WITHSCORES | Top 100 players with scores | O(log N + 100) |
ZSCORE leaderboard {userId} | Get a player's current score | O(1) |
ZREVRANGE leaderboard R-5 R+5 WITHSCORES | Players near rank R | O(log N + 10) |
ZCARD leaderboard | Total number of players | O(1) |
At 100M members, ZREVRANK runs in ~27 skip-list hops (log₂(100M) ≈ 27). At typical Redis latencies that’s sub-millisecond from the application server.
Interactive Redis ZSET Demo
4. Handling Ties
Two players with the same score need a deterministic ordering. Redis ZSET breaks ties lexicographically by member name — acceptable for usernames like “alice” vs “bob”, but problematic if UserIDs are numeric or you want “first to achieve the score wins.”
Fortnite’s leaderboard
system handles ~350 million
registered players. Epic
uses a combination of
Redis for real-time
rankings and Cassandra
for historical stats. The
global leaderboard is
rarely shown — most
players see region +
friends leaderboards
which are much smaller.
The canonical solution: encode the timestamp into the score as a decimal.
import time, redis MAX_EPOCH = 9_999_999_999 SCORE_MULT = 10_000_000_000 def encode_score(points: int, ts: int = None) -> int: if ts is None: ts = int(time.time()) return points * SCORE_MULT + (MAX_EPOCH - ts) def decode_score(encoded: int) -> tuple[int, int]: points = encoded // SCORE_MULT ts = MAX_EPOCH - (encoded % SCORE_MULT) return points, ts r = redis.Redis() def record_match_result(user_id: str, new_points: int): # Only update if new score is higher than current current_encoded = r.zscore('leaderboard', user_id) if current_encoded is not None: current_points, _ = decode_score(int(current_encoded)) if new_points <= current_points: return # no improvement encoded = encode_score(new_points) r.zadd('leaderboard', {user_id: encoded})
This approach keeps the ZSET as a single sortable number. No secondary sorting step needed — the ZSET handles everything natively.
5. Partitioning for Massive Scale
100M players in one Redis ZSET is perfectly feasible — Redis handles ZSETs with hundreds of millions of members. But a single primary node becomes a write bottleneck at high throughput.
Sharding Strategies
# Time-windowed leaderboard keys import datetime def weekly_key() -> str: iso = datetime.date.today().isocalendar() return 'leaderboard:' + str(iso.year) + '-W' + str(iso.week).zfill(2) def zadd_with_ttl(pipe, user_id: str, encoded: int): key = weekly_key() pipe.zadd(key, {user_id: encoded}) pipe.expire(key, 28 * 86400) # expire after 4 weeks # Friends leaderboard def friends_leaderboard(user_id: str, friends: list[str]) -> list: pipe = r.pipeline(transaction=False) all_ids = friends + [user_id] for fid in all_ids: pipe.zscore('leaderboard', fid) scores = pipe.execute() pairs = [(all_ids[i], scores[i]) for i in range(len(all_ids)) if scores[i] is not None] pairs.sort(key=lambda x: x[1], reverse=True) return pairs
6. Multiple Leaderboard Types
| Type | Key pattern | Size | TTL | Notes |
|---|---|---|---|---|
| Global all-time | leaderboard:global | 100M | None | Never expires; authoritative source |
| Weekly | leaderboard:2026-W24 | ~10M active | 28 days | New key each Monday; old ones auto-expire |
| Daily | leaderboard:2026-06-17 | ~2M active | 7 days | Great for Daily Challenge features |
| Regional | leaderboard:region:EU | ~25M | None | Shards write load across 4 Redis primaries |
| Clan | leaderboard:clan:{clanId} | 2–500 | None | Aggregate clan score; update on each member match |
| Friends | Computed on-demand | ~500 | None | Pipeline ZSCORE per friend; sort client-side |
7. Real-Time Updates to the Client
The top 100 players watching the leaderboard should see it update live. Use WebSockets.
import asyncio, json, redis.asyncio as aioredis r_async = aioredis.Redis() prev_top100: list = [] async def top100_watcher(broadcast_fn): global prev_top100 while True: raw = await r_async.zrevrange( 'leaderboard', 0, 99, withscores=True ) current = [(uid.decode(), int(sc)) for uid, sc in raw] if current != prev_top100: changes = compute_diff(prev_top100, current) await broadcast_fn(json.dumps({'type': 'top100_update', 'changes': changes})) prev_top100 = current await asyncio.sleep(0.5) # poll every 500ms def compute_diff(old, new): old_map = {uid: (i, sc) for i, (uid, sc) in enumerate(old)} new_map = {uid: (i, sc) for i, (uid, sc) in enumerate(new)} changes = [] for uid, (new_rank, new_sc) in new_map.items(): old_rank, old_sc = old_map.get(uid, (new_rank, new_sc)) if new_rank != old_rank or new_sc != old_sc: changes.append({ 'userId': uid, 'rank': new_rank + 1, 'score': new_sc, 'delta': new_sc - old_sc }) return changes
The 500ms polling is aggressive but cheap: ZREVRANGE 0 99 is O(log N + 100) — sub-millisecond. For 100K connected spectators, use pub/sub: the watcher publishes to a Redis channel, and a fan-out layer broadcasts to WebSocket connections.
8. The “Nearby Players” Feature
The ‘friends leaderboard’
is psychologically more
engaging than the global
leaderboard. Seeing that
your friend is rank
#3,421,002 while you’re
#3,421,001 is motivating.
Game designers have known
this for decades — it’s
why local arcade high
score tables were more
compelling than national
ones.
“Show me the players just ahead of and behind me.”
def nearby_players(user_id: str, window: int = 5) -> dict: pipe = r.pipeline(transaction=False) pipe.zrevrank('leaderboard', user_id) pipe.zscore('leaderboard', user_id) pipe.zcard('leaderboard') rank_0indexed, score, total = pipe.execute() if rank_0indexed is None: return {'error': 'player not found'} start = max(0, rank_0indexed - window) end = min(total - 1, rank_0indexed + window) raw = r.zrevrange('leaderboard', start, end, withscores=True) nearby = [ {'rank': start + i + 1, 'userId': uid.decode(), 'score': int(sc)} for i, (uid, sc) in enumerate(raw) ] return { 'myRank': rank_0indexed + 1, 'myScore': int(score), 'totalPlayers': total, 'nearby': nearby }
This makes three pipelined Redis calls and one ZREVRANGE. At rank 4,521,334 out of 100M players, ZREVRANGE 4521329 4521339 still runs in O(log N + 10) — indistinguishable in latency from a top-10 query.
9. Full Interactive Leaderboard
10. Capacity Estimate
| Metric | Number |
|---|---|
| Players | 100 million |
| ZSET memory | ~5 GB (50 bytes/player) |
| Score updates/sec | 100,000 |
| Top-100 reads/sec | 500,000 |
| ZADD latency | ~0.1 ms (O log N) |
| ZREVRANGE top-100 latency | ~0.5 ms |
| ZREVRANK latency | ~0.15 ms (27 skip-list hops) |
| Redis primaries needed (100K ZADD/sec) | 2–4 (50K writes/sec/node comfortable) |
| WebSocket connections (top-100 watchers) | ~100K concurrent |
| Fan-out broadcast cost per top-100 change | ~100K × 500 bytes = 50 MB/event |
11. Architecture Summary
Mobile Game Client | | POST /match/result (score, userId, matchId) v Match Result Service stateless, x20 pods | validate, deduplicate (matchId → idempotency key) | compute new score (points + bonus) v Redis Cluster | ZADD leaderboard:global encoded_score userId | ZADD leaderboard:weekly encoded_score userId | ZADD leaderboard:region:X encoded_score userId | +--------+----------------+ | | v v Rank API Top-100 Watcher | ZREVRANK | poll every 500ms | ZREVRANGE nearby | compute diff | ZSCORE friends v v Redis Pub/Sub REST / gRPC clients | v WebSocket Fan-out | x5 nodes, 20K conn each v Live Clients (top-100 watchers) Write path also fans to: PostgreSQL → match history, audit log Kafka → analytics, anti-cheat pipeline
Trade-offs Worth Discussing in an Interview
| Decision | Chosen | Alternative | Why chosen |
|---|---|---|---|
| Rank store | Redis ZSET | PostgreSQL + index | O(log N) all ops; no index contention at 100K writes/sec |
| Tie-breaking | Encoded timestamp in score | Lex order of userId | Deterministic; "first to achieve wins" is better UX |
| Live updates | WebSocket + poll ZREVRANGE | Redis keyspace notifications | Polling is simpler; keyspace notifications at scale create noisy fan-out |
| Friends leaderboard | Pipeline ZSCORE per friend | Per-user friends ZSET | Simpler; only add per-user ZSET if friend list exceeds ~5000 |
| Durability | Redis AOF + RDB snapshots | Rebuild from PostgreSQL on restart | AOF gives sub-second durability; rebuild from PG is acceptable fallback |
| Score validation | Match result deduplication by matchId | Client-side score submission | Prevents duplicate submissions on retry; matchId is server-generated |
Design a leaderboard, and you touch O(log N) data structures, tie-breaking with composite keys, high write throughput, real-time fan-out, and the psychology of competitive ranking. The key insight: Redis Sorted Set is the right abstraction — not because SQL can’t do it, but because ZSET does exactly this and nothing else, with optimal complexity at every operation.