← System Design Simulator

Real-time Gaming Leaderboard System Design Interview Question

By Rahul Kumar · Senior Software Engineer · Updated · 9 components · 3 operations ·Source: Alex Xu, System Design Interview Vol 2, Chapter 10

Problem: Design a real-time global leaderboard for a mobile game: top-100 worldwide, top-K by region, and a given player's rank with surrounding neighbors, all updated in near real-time.

Overview

A real-time gaming leaderboard looks cheap on the whiteboard and expensive in production. The reason is rank: you cannot just store a score, you have to know where that score falls among 100 million other scores, and you have to know it in milliseconds while tournaments run and scores stream in at tens of thousands per second. A plain SQL ORDER BY works for a leaderboard of a hundred friends and falls over at global scale, because each read is O(N log N) on the backing index. The standard move, and the one this design commits to, is a Redis Sorted Set (ZSET) that keeps all players in a skiplist keyed by score. ZADD is O(log N), ZREVRANGE is O(log N + M), and ZREVRANK is O(log N), which is exactly the shape we need for top-K, neighbors, and "where am I" queries. Durability lives in a separate relational store; Redis is the fast source of truth for rank, not the permanent record of scores.

Real-time Gaming Leaderboard — Interactive Simulator

Runs fully client-side in your browser; no sign-up. Or open full screen →

Launch the interactive walkthrough for Real-time Gaming Leaderboard — animated architecture diagram, step-by-step flow with real payloads, component swap, and a discrete-event stress simulator.

Summary

A read-heavy ranking system built around a Redis Sorted Set (ZSET) as the hot in-memory source of truth for rank, backed by a durable SQL/KV store for history, snapshots, and cold reads. The dominant design choice is using Redis ZSET's skiplist — which gives O(log N) ZADD and O(log N + M) ZREVRANGE — instead of computing ranks from a SQL ORDER BY (which would be O(N log N) per read at 100M rows). The main tradeoff is that Redis is not authoritative storage: scores are persisted asynchronously to the durable DB, and a periodic snapshot job rebuilds the ZSET from the DB to guard against Redis loss. Realtime push via WebSocket is optional but essential for tournament UX — only fire on events that actually change top-K or the player's displayed neighborhood. Sized for ~100M monthly players, ~1M concurrent, ~50K score submissions/sec, ~500K leaderboard reads/sec.

Requirements

Functional

Non-functional

Capacity Assumptions

Back-of-Envelope Estimates

High-level architecture

The write path takes a signed score from the game client, validates it server-side, and performs two operations in parallel: a ZADD into the Redis ZSET for the relevant leaderboard and an INSERT into the durable scores table for history and audit. The two writes are intentionally not wrapped in a distributed transaction. The ZSET update is the one that users see; the durable write is how we reconstruct state if Redis is lost. Redis uses GT semantics so that a lower replayed score never overwrites a higher one, which makes the pipeline safely idempotent. The read path is simpler and is backed by three ZSET commands. ZREVRANGE with WITHSCORES for top-K, ZREVRANK for a single player's rank, and a paired ZREVRANK plus ZRANGE for the neighbors window around that player. A thin Redis-fronted JSON cache absorbs the hot top-100 reads, since the top of the board changes much slower than individual ranks below it. A separate snapshot job rebuilds the ZSET from the durable DB on a schedule and after Redis failover, so no player permanently loses rank just because a cache node died. Real-time push via WebSocket is optional but worth the complexity for live tournaments: we only publish a rank_change event when the ZADD moves a player into or out of the user's currently displayed window, so fan-out is bounded by subscriber count, not score throughput.

Architecture Components (9)

Operations Walked Through (3)

Implementation

LeaderboardService on Redis ZSET
public class LeaderboardService {
    private final RedisClient redis; // Jedis/Lettuce-style sync client
    private final String key;         // e.g. "leaderboard:global"

    public LeaderboardService(RedisClient redis, String key) {
        this.redis = redis; this.key = key;
    }

    /** Submit a score; GT ensures lower replays cannot overwrite a higher score. */
    public void submitScore(String userId, long score) {
        redis.zadd(key, score, userId, ZAddArgs.Builder.gt().ch());
    }

    /** Top-K players, descending by score, with their scores. */
    public List<RankedEntry> getTopK(int k) {
        List<ScoredValue<String>> rows = redis.zrevrangeWithScores(key, 0, k - 1);
        List<RankedEntry> out = new ArrayList<>(rows.size());
        long rank = 1;
        for (ScoredValue<String> sv : rows) {
            out.add(new RankedEntry(rank++, sv.getValue(), (long) sv.getScore()));
        }
        return out;
    }

    /** Zero-indexed ZSET rank translated to human 1-indexed rank. */
    public OptionalLong getUserRank(String userId) {
        Long idx = redis.zrevrank(key, userId);
        return idx == null ? OptionalLong.empty() : OptionalLong.of(idx + 1);
    }

    public record RankedEntry(long rank, String userId, long score) {}
}
Neighbors around a player
public class NeighborsQuery {
    private final RedisClient redis;
    private final String key;

    public NeighborsQuery(RedisClient redis, String key) {
        this.redis = redis; this.key = key;
    }

    /** Return `radius` players above and below userId, descending by score. */
    public List<LeaderboardService.RankedEntry> getNeighbors(String userId, int radius) {
        Long zeroBasedRank = redis.zrevrank(key, userId);
        if (zeroBasedRank == null) return List.of();

        long start = Math.max(0, zeroBasedRank - radius);
        long end = zeroBasedRank + radius;

        List<ScoredValue<String>> rows = redis.zrevrangeWithScores(key, start, end);
        List<LeaderboardService.RankedEntry> out = new ArrayList<>(rows.size());
        long rank = start + 1;
        for (ScoredValue<String> sv : rows) {
            out.add(new LeaderboardService.RankedEntry(rank++, sv.getValue(), (long) sv.getScore()));
        }
        return out;
    }
}
Score ingest: dual-write to Redis and durable store
public class ScoreIngest {
    private final LeaderboardService lb;
    private final ScoresRepository durable;
    private final WebSocketBroadcaster ws;
    private final ScoreValidator validator;

    public ScoreIngest(LeaderboardService lb, ScoresRepository durable,
                       WebSocketBroadcaster ws, ScoreValidator validator) {
        this.lb = lb; this.durable = durable; this.ws = ws; this.validator = validator;
    }

    public void ingest(ScoreSubmission s) {
        validator.verifyOrThrow(s); // HMAC + server-side sanity bounds

        // Durable record first: if Redis dies later, snapshot job can rebuild from this.
        durable.insertScore(s.userId(), s.matchId(), s.score(), s.submittedAt());

        // Hot path: updates the ZSET that the read API reads from.
        lb.submitScore(s.userId(), s.score());

        // Only notify if the new score actually moves the player into top-100.
        OptionalLong newRank = lb.getUserRank(s.userId());
        if (newRank.isPresent() && newRank.getAsLong() <= 100) {
            ws.broadcast(new RankChangeEvent(s.userId(), newRank.getAsLong(), s.score()));
        }
    }
}

Key design decisions & trade-offs

Interview follow-ups

Related