A leaderboard looks like the simplest feature in a product: sort players by score and show the top ten. The difficulty arrives with the second requirement. Players want to see their own rank among millions, ties need a rule everyone accepts, the weekly board must reset at exactly the same moment for everyone, a cheater's score must disappear without corrupting anyone else's rank, and the final standings that decide prizes must never change after the period closes.
This article builds a leaderboard system from first principles. It starts with what a score means, shows why an in-memory sorted set is the right core structure and where its limits are, works through composite scores for tie-breaking (including the floating-point limit that silently breaks naive versions), and then covers the surrounding system: ingest through a log, idempotent writers, read patterns, approximate rank for the long tail, period-close snapshots and fraud. The examples use Redis sorted sets because they are the common choice, but the design applies to any ordered index.
Decide what a score means before choosing storage
Three questions define the leaderboard, and getting them wrong is more expensive than any storage choice.
- Best or cumulative? A racing game ranks a player's best lap: a new score replaces the old one only if it is better. A season pass ranks total points: every event adds to the total. The write operation is different (a conditional replace versus an increment), and so is the cost of a duplicate event: a duplicated best score is harmless, a duplicated increment is a wrong answer.
- Which period? All-time, season, weekly and daily boards are separate leaderboards. Decide the boundary precisely, including the time zone, and stamp each event with the period it belongs to at ingest, using the server's clock, not the time it happens to be processed.
- How are ties broken? Common rules are first to reach the score wins, or ties share a rank. Whatever you choose must be stored in the data structure; a tie rule applied only in the display layer produces a different order on every page.
Also decide the scope: global, regional, per-friend group, or per-guild. Each scope that needs ranking is its own ordered index, and the number of indexes, not the number of players, often dominates memory.
The core structure: a sorted set
A sorted set keeps members unique and ordered by a numeric score. Redis implements it with a hash table for member-to-score lookup and a skip list for order, so adding or updating a member and finding a member's rank are logarithmic in the size of the set, and reading a range by rank costs logarithmic time plus the size of the range. That is exactly the shape of leaderboard queries.
| Need | Command | Notes |
|---|---|---|
| Record a best score | ZADD board GT score player | GT only updates if higher; Redis 6.2+ |
| Add points | ZINCRBY board points player | Not idempotent; guard against duplicates |
| Top N | ZRANGE board 0 N-1 REV WITHSCORES | REV form is 6.2+; older code uses ZREVRANGE |
| A player's rank | ZREVRANK board player | 0-based; add 1 for display |
| A player's score | ZSCORE board player | Use ZMSCORE for many players (6.2+) |
| Board size | ZCARD board | Constant time |
| Remove a cheater | ZREM board player | Ranks below shift up by one automatically |
One sorted set comfortably holds millions of members in a single instance. Per-member overhead depends on member length and the encoding Redis chooses, so short numeric player ids matter more than any tuning; load a sample of your own ids and measure with MEMORY USAGE before sizing.
Tie-breaking with composite scores, and the 2^53 limit
Redis orders members with equal scores lexicographically by member name. For player ids, that means player 10422 beats player 98000 on a tie for no reason anyone would accept. The standard fix is a composite score that encodes the tie-breaker in the low-order part: rank primarily by points, then by who reached that score first.
The catch is that sorted-set scores are IEEE-754 double-precision floats. A double represents every integer exactly only up to 2^53, about 9.007 quadrillion. Pack the score and the tie-breaker into more than 53 bits and the low bits are silently rounded away: two players who reached the same score at different times end up with the same composite value, and the tie order falls back to member names. Nothing errors; the order is just wrong.
So budget the bits. Suppose a weekly board where scores never exceed 10 million (24 bits, since 2^24 is about 16.8 million) and the period is 7 days, or 604,800 seconds (20 bits, since 2^20 is about 1.05 million). That is 44 bits in total, well inside 53. Store the time inverted, so earlier achievers get a larger number and sort higher in a descending board.
TIME_BITS = 20 # 2**20 s > 7 days
SCORE_MAX = 10_000_000 # fits in 24 bits; 24 + 20 = 44 <= 53
TIME_MAX = (1 << TIME_BITS) - 1
def composite(score: int, achieved_at: int, period_start: int) -> int:
t = achieved_at - period_start
if not (0 <= score <= SCORE_MAX and 0 <= t <= TIME_MAX):
raise ValueError("score or time outside the encoded range")
return (score << TIME_BITS) | (TIME_MAX - t) # earlier time -> larger value
def decode(value: float) -> tuple[int, int]:
v = int(value)
return v >> TIME_BITS, TIME_MAX - (v & TIME_MAX)For an all-time board with second resolution over ten years (29 bits), scores are limited to 24 bits; if you need larger scores, coarsen the time to minutes, or keep exact scores in the sorted set and break ties with a second lookup only for the members that share a score on the page being displayed. Assert the ranges at ingest, as the code does, so an out-of-range value is rejected loudly instead of corrupting order quietly.
Composite scores work naturally with ZADD GT for best-score boards. For cumulative boards they do not: incrementing points changes the time component's meaning. Keep the cumulative total in the sorted set and, if you need first-to-reach ordering, recompute the composite on each update inside a script that reads the current total.
Ingest: a log in front, idempotent writers behind
Scores should never be written to the sorted set directly by game clients. The flow in the figure puts an authenticated ingest API in front, appends every accepted event to a durable log partitioned by board, validates events, and only then applies them to the index. The log is the source of truth; the sorted sets are an index you can rebuild by replaying it.
Logs deliver at least once, so the writer must make duplicate delivery harmless. For best-score boards ZADD GT is already idempotent. For cumulative boards, record the event id atomically with the increment. A short Lua script does both in one round trip, and Redis runs it without interleaving other commands.
APPLY = """
if redis.call('SET', KEYS[2], 1, 'NX', 'EX', ARGV[3]) then
return redis.call('ZINCRBY', KEYS[1], ARGV[1], ARGV[2])
end
return false
"""
apply_points = r.register_script(APPLY)
def apply(event):
board = f"lb:{event.board}:{event.period}" # e.g. lb:season:2026-w40
seen = f"seen:{event.board}:{event.period}:{event.id}"
apply_points(keys=[board, seen], args=[event.points, event.player, 8 * 86400])The dedupe key expires after the longest plausible redelivery window, here eight days for a weekly board. If you use Redis Cluster, both keys must hash to the same slot for the script to run; wrap the shared board and period in a hash tag such as {season:2026-w40} in both key names.
Per-period boards and resets
Do not reset a board by deleting or zeroing it at midnight; that races with in-flight writes and leaves a window where everyone has rank one. Instead, name keys by period, as in lb:season:2026-w40, and let the ingest stamp decide which key an event goes to. The new week starts empty because it is a new key. Set an expiry on old period keys only after the snapshot job has confirmed the final standings are stored durably.
Late events are a policy decision. A match that ended at 23:59:58 may arrive at 00:00:03. Because the event carries its own period stamp, the writer can still apply it to the old key during a short grace window, and the snapshot job waits until that window closes before freezing results.
Read patterns: top N, around me, friends
The top of the board is read far more than any other part and changes slowly in relative terms, so cache the top 100 for a second or two at the API layer; that removes most read load from the sorted set. Rank queries for an individual player are cheap logarithmic operations and can go to replicas, accepting a fraction of a second of lag.
def around_me(board: str, player: str, width: int = 5):
rank = r.zrevrank(board, player)
if rank is None:
return None, []
lo = max(0, rank - width)
rows = r.zrange(board, lo, rank + width, desc=True, withscores=True)
return rank + 1, [(lo + i + 1, m, decode(s)[0]) for i, (m, s) in enumerate(rows)]
def friends_board(board: str, friend_ids: list[str]):
scores = r.zmscore(board, friend_ids) # one round trip
rows = [(f, s) for f, s in zip(friend_ids, scores) if s is not None]
return sorted(rows, key=lambda x: x[1], reverse=True)The around_me helper assumes the composite best-score board; on a cumulative board, return the raw score instead of decoding it. Friends boards are usually computed on read like this, because a sorted set per friend group multiplies writes by the number of groups each player belongs to. That changes once groups become large and stable, such as guilds; for those, maintain a separate sorted set per guild and fan out the write.
Scaling past one instance, and approximate rank
Exact global rank needs a single ordered index, which is the real scaling limit. If you shard players across several sorted sets by player id, the top N still works (take the top N from each shard and merge), but one player's global rank requires counting members above their score in every shard: a ZCOUNT per shard on each lookup. That is acceptable for a handful of shards and becomes expensive beyond that. Sharding by score range keeps rank cheap but moves members between shards as scores change, and the top shard becomes a hot key; see the hot-key techniques in the linked article.
Most products do not need exact rank in the long tail. A player at position 2,481,337 cannot tell it from 2,481,912. A common design keeps an exact sorted set for the top tier (say the top 100,000) and, for everyone else, a histogram of score buckets maintained alongside it. Approximate rank is the count of players in higher buckets plus an interpolated position within the player's bucket, and the display shows a percentile such as top 12 percent. This removes the single-index limit for the part of the board where exactness has no value.
Snapshots, fraud and failure modes
When a period closes, the snapshot job reads the full board in pages, writes final standings with ranks to a durable database, and marks them immutable. Prizes and history are served from the snapshot, never from the live index. Fraud review happens before the snapshot when possible: flagged players are held in a separate review set and excluded from the public board, or, if removed after the fact, the board is rebuilt by replaying the log without their events, and the snapshot is regenerated with an audit record of what changed.
Scores should be server-authoritative: computed or verified by the game server, signed, and checked for plausibility (maximum points per minute, impossible completion times, sudden jumps). Many games shadow-exclude flagged players so they still see their own score, which slows down cheaters who probe detection thresholds.
| Failure | Effect | Prevention |
|---|---|---|
| Duplicate delivery on a cumulative board | Inflated totals | Event-id dedupe in the same atomic script |
| Composite score over 53 bits | Ties silently mis-ordered | Bit budget plus range assertions at ingest |
| Reset by deleting the key | Lost late writes, everyone rank one | Per-period keys, grace window, then expire |
| Redis node lost without persistence | Board empty | Rebuild from the log; snapshot is the record |
| Cheater removed after prizes | Disputed standings | Review before snapshot; audited regeneration |
| Top-N read storm at a season end | Latency spikes | Short-TTL cache and read replicas |
For more on the data structures, see Redis data structures in practice; for the top shard problem, hot key mitigation; for safe retries in the ingest API, idempotency in system design; and for delivery guarantees between the log and the writer, exactly-once semantics in streaming.
What to do next
- Write down score semantics: best or cumulative, period boundaries with a time zone, the tie rule, and every scope that needs a ranking.
- Compute a bit budget for composite scores, assert it at ingest, and add a test that two equal scores at different times order correctly.
- Put a durable log in front of the index and make the writer idempotent on event id.
- Name keys by period, add a grace window for late events, and expire old keys only after a confirmed snapshot.
- Cache the top N briefly, serve rank queries from replicas, and decide where exact rank stops and percentiles start.
- Rehearse a rebuild from the log and a fraud removal with snapshot regeneration before the first prize period.