What Is a Gaming Leaderboard System?
A gaming leaderboard ranks players by score, showing top players globally, regionally, or among friends. Games like Fortnite, Clash of Clans, and League of Legends serve hundreds of millions of players. The system must handle millions of score updates per second, serve real-time rank queries in milliseconds, and support complex ranking windows (daily, weekly, all-time, friends-only). The core data structure challenge: efficient rank computation for an arbitrarily large and frequently updated dataset.
System Requirements
Functional
- Update a player’s score
- Get a player’s current rank (global and among friends)
- Get the top-N players (leaderboard page)
- Get players near a given rank (players ranked 990-1010)
- Support multiple leaderboard windows: daily, weekly, all-time
- Seasonal leaderboards that reset periodically
Non-Functional
- Score updates: 1M updates/second during peak gaming hours
- Rank query latency: <10ms
- 100M+ active players per leaderboard
The Right Data Structure: Redis Sorted Set
Redis Sorted Sets (ZSET) are the canonical solution for leaderboards. Each member has a score; the set is always sorted by score. Key operations:
# Update score (or add player)
ZADD leaderboard:global {score} {player_id}
# Get player's rank (0-indexed, ascending)
ZREVRANK leaderboard:global {player_id} # returns rank in descending order
# Get top 100 players with scores
ZREVRANGE leaderboard:global 0 99 WITHSCORES
# Get players around rank 1000
ZREVRANGE leaderboard:global 990 1009 WITHSCORES
# Get player's score
ZSCORE leaderboard:global {player_id}
All operations are O(log N) for updates and O(log N + K) for range queries, where K is the number of returned elements. For 100M players, log₂(100M) ≈ 27 — essentially constant time.
Handling Ties
When two players have the same score, ZADD assigns them the same rank by default. To create a deterministic tiebreaker: encode score as a composite value. Since Redis sorted set scores are 64-bit floats, use the integer part for game score and pack a decreasing timestamp into the fractional part:
# score_key = game_score + (1 - timestamp/MAX_TIMESTAMP) * 0.001
# Higher game score wins; among ties, earlier achiever wins
score_key = game_score + (1.0 - (timestamp / 1e13)) * 0.001
redis.zadd("leaderboard", {player_id: score_key})
Alternatively: store score as integer * 1e9 + (MAX_TIME – achievement_time). Players with the same score are ranked by who achieved it first.
Multiple Time Windows
Maintain separate sorted sets per window:
leaderboard:global— all-timeleaderboard:2024-W01— weekly (ISO week)leaderboard:2024-01-15— daily
When a player submits a score: update all active windows atomically using Redis pipeline. Add an expiry to daily/weekly keys (EXPIRE) so they auto-clean after the window ends. On seasonal reset: create a new key for the new season; archive the old key to a cold store (S3).
Friends Leaderboard
Showing rank among friends requires a per-user friends sorted set — impractical for 100M players × 500 friends each. Instead:
- Fetch the current user’s friend list (from social graph service)
- Batch-fetch scores for all friends: ZMSCORE leaderboard:global [friend_ids]
- Sort in application code and find the requesting user’s rank among friends
O(friends_count) — fast for typical friend list sizes (100-500). No need for a separate friends leaderboard sorted set.
Scaling Beyond a Single Redis Instance
A single Redis node handles ~100K ZADD/sec. For 1M updates/sec:
- Shard by leaderboard type: global, regional, seasonal each on different Redis nodes
- Shard global leaderboard: split players across N Redis nodes by player_id hash. To get the global top-100: query top-100 from each shard, merge and sort in application code (K-way merge). Accurate only for top-N; per-player rank is approximate (sum of ranks below the player across shards).
- Write batching: buffer score updates for 1 second, apply the highest score per player per batch. Reduces write amplification for players who submit many scores in quick succession.
Persistent Storage
Redis sorted sets are in-memory — persist to avoid losing leaderboard state on restart. Options:
- Redis AOF (Append-Only File) persistence: every write logged to disk. Recover by replaying AOF on restart. Trade-off: slightly higher write latency.
- Redis RDB snapshots + write-through to PostgreSQL: write score updates to both Redis and a scores table. On Redis restart, reload from PostgreSQL. Slower recovery but always durable.
Interview Tips
- Redis Sorted Set is the canonical answer — know ZADD, ZREVRANK, ZREVRANGE, ZREVRANGEBYSCORE.
- Tiebreaker via composite score key is a differentiator — most candidates forget about ties.
- Friends leaderboard: fetch-and-sort in application is more practical than maintaining per-user sorted sets.
- For 1M updates/sec: mention sharding and write batching — single Redis is the bottleneck, not the algorithm.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “Why use Redis Sorted Set for a leaderboard and what are the time complexities?”,
“acceptedAnswer”: { “@type”: “Answer”, “text”: “Redis Sorted Set (ZSET) is a B-skip-list that keeps members sorted by score automatically. Key operations: ZADD player score O(log N); ZRANK player O(log N) to get 0-based rank; ZREVRANK player O(log N) to get rank from top; ZREVRANGE 0 9 WITHSCORES O(K+log N) to get top K; ZINCRBY player delta O(log N). For a leaderboard with 10M players: ZADD and ZINCRBY are O(log 10M) ≈ 23 operations — fast enough for 1M updates/sec if spread across sharded Redis nodes. The alternative (SQL ORDER BY score) requires a full index scan on every rank query, which degrades at scale. Redis Sorted Set is the canonical leaderboard data structure for a reason.” }
},
{
“@type”: “Question”,
“name”: “How do you handle tiebreakers in a leaderboard system?”,
“acceptedAnswer”: { “@type”: “Answer”, “text”: “Redis Sorted Set ranks ties identically — two players with the same score both get rank N. For strict tiebreaking (earlier achiever wins), encode time into the score: composite_score = points * 1e10 + (MAX_TIME – achieved_at_unix). Since a higher composite score wins, the player who achieved the score earlier (smaller timestamp, so larger MAX_TIME – timestamp) ranks higher. Choose MAX_TIME as a timestamp far in the future (e.g., 9999999999). This keeps the sort key in the ZSET while encoding both points and tiebreaker. Caution: this requires knowing the range of MAX_TIME and points at design time to avoid score overlap. For multi-column tiebreakers (points, then time, then alphabetical), use a deterministic composite encoding or fall back to a secondary ZSET query that further sorts tied players.” }
},
{
“@type”: “Question”,
“name”: “How do you scale a leaderboard to handle 1 million score updates per second?”,
“acceptedAnswer”: { “@type”: “Answer”, “text”: “Single Redis node handles ~100K ops/sec. For 1M updates/sec: shard the ZSET by player_id % N_shards across N Redis nodes. Each update goes to one shard. For global rank queries: maintain a separate summary structure. Approximate global rank: each shard knows how many players scored above a threshold (use ZCOUNT). Sum across shards gives approximate global rank in O(N_shards) round trips. For exact global rank with real-time accuracy: use a two-tier approach. Shard nodes aggregate per-bucket counts (score range histograms) and push to a global aggregator every 100ms. Global rank = count of players with higher score across all shards. Alternative for write-heavy workloads: batch updates with a write buffer. Accept score updates in Kafka, process in 1-second micro-batches, apply to Redis in bulk with a pipeline. Trades real-time precision for throughput.” }
}
]
}