System Design: Real-Time Game Leaderboard — Rankings for Millions of Players

Series System Design: Web Scenarios

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

100M
players
100K
score updates/sec
500K
rank reads/sec
<1ms
rank latency target

Four challenges make leaderboards deceptively hard:

  1. Write throughput: 100M players finishing matches → score updates arrive in bursts. A popular game can see 100,000 ZADD operations per second.
  2. Global rank queries: “What is my rank?” requires knowing how many players have a higher score. Naively that means scanning 100M rows.
  3. Top-100 freshness: The moment someone’s score pushes them into the top 100, they should appear. Stale caches won’t do.
  4. 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.

sql
-- 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 SELECT is O(log N) with the index; the outer COUNT(*) 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 100 triggers a potential lock on the entire index during writes.
SQL works fine up to ~1 million players with proper indexing. Beyond that, the rank query scan time grows linearly with the player's rank position — the worst case (last place) reads the entire index.

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.

CommandOperationComplexity
ZADD leaderboard {score} {userId}Add or update a player's scoreO(log N)
ZREVRANK leaderboard {userId}Get rank (0 = highest score)O(log N)
ZREVRANGE leaderboard 0 99 WITHSCORESTop 100 players with scoresO(log N + 100)
ZSCORE leaderboard {userId}Get a player's current scoreO(1)
ZREVRANGE leaderboard R-5 R+5 WITHSCORESPlayers near rank RO(log N + 10)
ZCARD leaderboardTotal number of playersO(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

Redis Sorted Set — Live Demo
Leaderboard
Redis Command Log

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.

Tie-breaker encoding
MAX_EPOCH = 9_999_999_999 // 10-digit Unix timestamp (year ~2286)
encoded_score = points * 10_000_000_000 + (MAX_EPOCH - unix_ts_seconds)
 
// Player A: 9999 points at t=1700000000
encoded_A = 9999 * 10_000_000_000 + (9_999_999_999 - 1_700_000_000)
= 99_998_299_999_999
 
// Player B: 9999 points at t=1700001000 (1000 seconds later)
encoded_B = 9999 * 10_000_000_000 + (9_999_999_999 - 1_700_001_000)
= 99_998_298_999_999
 
// encoded_A > encoded_B → Player A ranks higher (achieved score first)
python
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.

Memory math: 100M players × ~50 bytes each (skip list node + hash map entry) ≈ 5 GB. A modern Redis instance with 8 GB RAM handles this comfortably with room for other keys.

Sharding Strategies

⏱ Time-windowed leaderboards

Separate ZSETs per day/week/season. Weekly boards have far fewer active players, naturally reducing size. Expire old boards automatically with EXPIRE.

🌍 Regional shards

Shard by player region: leaderboard:Americas, leaderboard:EU, leaderboard:APAC. Regional reads go to the nearest Redis node; a separate global aggregator merges them hourly.

📊 Score tier sharding

Split by score range: lb:tier:bronze (0–1000), lb:tier:silver (1000–10K), lb:tier:gold (10K+). Writes route to the right tier; rank = tier-offset + intra-tier rank.

👥 Friends leaderboard

Computed on-demand: fetch friends list, pipeline ZSCORE for each friend, sort client-side. Works for up to ~5000 friends; beyond that, maintain a per-user friends ZSET updated asynchronously.

python
# 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

TypeKey patternSizeTTLNotes
Global all-timeleaderboard:global100MNoneNever expires; authoritative source
Weeklyleaderboard:2026-W24~10M active28 daysNew key each Monday; old ones auto-expire
Dailyleaderboard:2026-06-17~2M active7 daysGreat for Daily Challenge features
Regionalleaderboard:region:EU~25MNoneShards write load across 4 Redis primaries
Clanleaderboard:clan:{clanId}2–500NoneAggregate clan score; update on each member match
FriendsComputed on-demand~500NonePipeline 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.

python (change detection loop)
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.”

python
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

🏆 Global Leaderboard
● LIVE
0 updates
· · · · · · · · · ·
Your Position
Season
47:58:32
48h season

10. Capacity Estimate

MetricNumber
Players100 million
ZSET memory~5 GB (50 bytes/player)
Score updates/sec100,000
Top-100 reads/sec500,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
Key insight: Redis ZSET operations are O(log N). Going from 1M to 100M players adds only log₂(100) ≈ 7 extra skip-list hops. The latency increase from 1M to 100M players is negligible — about 30 microseconds. This is why Redis ZSET scales so gracefully.

11. Architecture Summary

architecture
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

DecisionChosenAlternativeWhy chosen
Rank storeRedis ZSETPostgreSQL + indexO(log N) all ops; no index contention at 100K writes/sec
Tie-breakingEncoded timestamp in scoreLex order of userIdDeterministic; "first to achieve wins" is better UX
Live updatesWebSocket + poll ZREVRANGERedis keyspace notificationsPolling is simpler; keyspace notifications at scale create noisy fan-out
Friends leaderboardPipeline ZSCORE per friendPer-user friends ZSETSimpler; only add per-user ZSET if friend list exceeds ~5000
DurabilityRedis AOF + RDB snapshotsRebuild from PostgreSQL on restartAOF gives sub-second durability; rebuild from PG is acceptable fallback
Score validationMatch result deduplication by matchIdClient-side score submissionPrevents 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.