system-design49 min read

How to Design a Real-Time Leaderboard System — A Senior+ Guide | Ayodhyya

How to Design a Real-Time Leaderboard System — A Senior+ Guide

Building Redis-backed leaderboards, percentile rankings, and global top-N queries at billion-event scale

By Ayodhyya · July 14, 2026 · 30 min read · 13,500+ words
System Design Redis C# Distributed Systems

1. Introduction — Leaderboards Everywhere

Leaderboards are one of the most powerful engagement mechanisms in modern software. Whether it is a mobile game ranking players by score, a fitness app comparing daily step counts, an e-commerce platform surfacing top reviewers, or an educational platform displaying quiz champions — the underlying system is remarkably similar: maintain a sorted collection of scores, support real-time updates, and serve rank queries with sub-second latency.

At first glance, a leaderboard seems trivially easy. Store user-score pairs, sort by score, done. But at scale — millions of concurrent users, thousands of score updates per second, multiple time windows, friend-filtered views, percentile calculations, and anti-cheat validation — the problem becomes a fascinating distributed systems challenge.

Why This Matters for Senior+ Engineers

The leaderboard problem tests your understanding of data structures (skip lists, sorted sets), distributed caching (Redis cluster), stream processing (Kafka for score ingestion), database design (hot/cold storage), and API design (pagination, cursor-based navigation). It frequently appears in system design interviews at companies like Google, Meta, Amazon, and gaming studios like Riot Games and Supercell.

In this article, we will design a production-grade real-time leaderboard system from the ground up. We will cover every major subsystem — from the Redis sorted set internals that power rank lookups in O(log N), to the Kafka-based score update pipeline that guarantees exactly-once processing, to the snapshot mechanism that preserves historical leaderboards. We will include a complete C# implementation exceeding 300 lines, multiple architecture diagrams, capacity estimations, and ten-plus interview questions with detailed answers.

Real-World Use Cases

DomainExampleScore MetricUpdate Frequency
GamingCandy Crush, FortnitePoints, kills, XPEvery action
FitnessStrava, Nike Run ClubSteps, distance, caloriesEvery session
EducationKahoot, DuolingoQuiz score, streakEvery quiz
E-commerceAmazon ReviewsHelpful votes, review countDaily batch
FinanceTrading platformsPnL, ROIReal-time tick
SocialLinkedIn profile viewsEngagement scoreNear real-time

2. Functional & Non-Functional Requirements

Functional Requirements

  1. Update Score: A user submits a score for a specific leaderboard. The system must handle duplicate submissions (idempotency).
  2. Get Rank: Given a user ID and leaderboard ID, return the user's current rank and score.
  3. Get Top-N: Return the top N users for a given leaderboard, with their ranks, scores, and user metadata.
  4. Get Nearby Ranks: Given a user, return the players immediately above and below them (the "players around me" feature).
  5. Get Percentile: Given a user, return the percentile they are in (e.g., "You are in the top 5%").
  6. Multiple Time Windows: Support daily, weekly, monthly, and all-time leaderboards from the same underlying score data.
  7. Historical Snapshots: Preserve leaderboard state at the end of each period (e.g., end-of-day freeze for daily boards).
  8. Friends Leaderboard: Filter the leaderboard to show only a user's friends.
  9. Score Validation: Basic anti-cheat checks to reject obviously invalid scores.

Non-Functional Requirements

RequirementTargetRationale
Latency (rank lookup)< 10ms p99Real-time feel in games
Latency (top-N query)< 50ms p99Page load for leaderboard views
Score update throughput50,000 writes/secPeak during global events
Data consistencyEventual consistency (seconds)Race conditions acceptable for brief window
Availability99.95%Core engagement feature
DurabilityZero score lossPlayers will rage-quit if scores are lost
Scalability100M users per boardGlobal game launches

3. Capacity Estimation

Key Assumptions

  • Total registered users: 200 million
  • Daily active users (DAU): 50 million
  • Average score updates per active user per day: 20
  • Total leaderboards: 10,000 (per-game, per-time-window)
  • Users per major leaderboard: up to 50 million

Write Throughput

Score update QPS = DAU × updates per user / 86,400 seconds (if uniform). But traffic is bursty. During a global tournament, we may see 5× the average.

Average QPS: 50M × 20 / 86,400 ≈ 11,600 writes/sec

Peak QPS (5× burst): ~58,000 writes/sec

Peak QPS (10× during finals): ~116,000 writes/sec

Read Throughput

Most users read leaderboards more often than they update scores. Assume a 10:1 read-to-write ratio for active boards.

Average read QPS: 116,000 reads/sec

Peak read QPS: ~580,000 reads/sec

With caching, actual Redis reads may be 30-50% of this.

Storage Estimation

DataSize per EntryEntriesTotal
Active sorted set entries~100 bytes50M per major board5 GB per board
Score history (90 days)~60 bytes50M × 90 days × 20 updates~54 TB (compressed to ~5 TB)
Hourly snapshots (30 days)~5 GB per snapshot720 snapshots~3.6 TB
User metadata cache~200 bytes50M10 GB

Bandwidth Estimation

Each top-N response (100 entries with metadata) is roughly 15 KB. At 580K reads/sec, that is approximately 8.7 GB/sec of outbound data for top-N queries alone. Response compression (gzip/brotli) reduces this by 70-80%. The effective outbound bandwidth is approximately 2 GB/sec, which is manageable with modern load balancers and CDN caching for popular boards.

4. Data Model

Entity Relationships

erDiagram LEADERBOARD ||--o{ LEADERBOARD_ENTRY : contains USER ||--o{ LEADERBOARD_ENTRY : has USER ||--o{ SCORE_EVENT : produces LEADERBOARD ||--o{ SCORE_EVENT : receives LEADERBOARD ||--o{ LEADERBOARD_SNAPSHOT : has LEADERBOARD { string id PK string name string type string time_window datetime start_time datetime end_time int max_entries bool is_active } USER { string id PK string username string avatar_url string region datetime created_at } LEADERBOARD_ENTRY { string leaderboard_id FK string user_id FK double score int rank datetime last_updated int total_games } SCORE_EVENT { string id PK string leaderboard_id FK string user_id FK double score string event_type datetime timestamp string idempotency_key bool validated } LEADERBOARD_SNAPSHOT { string id PK string leaderboard_id FK datetime snapshot_time blob snapshot_data int entry_count }

Redis Key Schema

Key PatternTypeDescription
lb:{board_id}:scoresSorted SetMain leaderboard — member = user_id, score = score
lb:{board_id}:user:{user_id}HashUser's score detail for this board
lb:{board_id}:friends:{user_id}Sorted SetPre-computed friend leaderboard (TTL 60s)
lb:{board_id}:snapshot:{date}String (JSON)End-of-period snapshot
lb:{board_id}:percentilesSorted SetScore distribution for percentile lookups
lb:{board_id}:top100String (JSON)Cached top-100 response (TTL 5s)
dedup:{idempotency_key}StringScore dedup guard (TTL 24h)

5. API Design

REST API Endpoints

POST   /api/v1/leaderboards/{boardId}/scores
GET    /api/v1/leaderboards/{boardId}/rank/{userId}
GET    /api/v1/leaderboards/{boardId}/top?limit=100&offset=0
GET    /api/v1/leaderboards/{boardId}/nearby/{userId}?count=10
GET    /api/v1/leaderboards/{boardId}/percentile/{userId}
GET    /api/v1/leaderboards/{boardId}/friends/{userId}
GET    /api/v1/leaderboards/{boardId}/snapshots/{date}
GET    /api/v1/leaderboards/{boardId}/stats

Request & Response Examples

Update Score

// POST /api/v1/leaderboards/game_abc_daily/scores
{
    "userId": "user_12345",
    "score": 98500,
    "idempotencyKey": "evt_abc_123_456",
    "metadata": {
        "gameMode": "ranked",
        "duration": 342,
        "accuracy": 0.87
    }
}

// Response 200 OK
{
    "status": "accepted",
    "newRank": 42,
    "previousRank": 105,
    "rankChange": 63,
    "totalParticipants": 12500000
}

Get Top-N

// GET /api/v1/leaderboards/game_abc_daily/top?limit=10&offset=0
{
    "leaderboardId": "game_abc_daily",
    "entries": [
        {
            "rank": 1,
            "userId": "user_99999",
            "username": "ShadowBlade",
            "avatarUrl": "https://cdn.example.com/avatars/99999.jpg",
            "score": 2450000,
            "country": "JP"
        },
        {
            "rank": 2,
            "userId": "user_88888",
            "username": "PixelQueen",
            "avatarUrl": "https://cdn.example.com/avatars/88888.jpg",
            "score": 2380000,
            "country": "KR"
        }
    ],
    "totalEntries": 12500000,
    "hasMore": true,
    "cursor": "eyJyYW5rIjozMH0="
}

6. High-Level Architecture

graph TB subgraph Clients MOB[Mobile App] WEB[Web App] CLI[CLI Tool] end subgraph API Gateway GW[API Gateway / Load Balancer] AUTH[Auth Service] RL[Rate Limiter] end subgraph Application Tier LS[Leaderboard Service] SV[Score Validation Service] NS[Notification Service] AN[Analytics Service] end subgraph Message Queue KF[Kafka Cluster] TOPIC_Scores[scores-topic] TOPIC_Events[events-topic] end subgraph Cache Layer RC[Redis Cluster - Primary] RCREAD[Redis Read Replicas] end subgraph Storage Layer PG[PostgreSQL - Primary] PGREAD[PostgreSQL Read Replicas] S3[S3 - Snapshots] ES[Elasticsearch - Analytics] end subgraph Workers WP[Window Processor] SP[Snapshot Worker] PW[Percentile Worker] CW[Cache Warmer] end MOB --> GW WEB --> GW CLI --> GW GW --> AUTH GW --> RL GW --> LS LS --> RC LS --> SV SV --> KF KF --> TOPIC_Scores KF --> TOPIC_Events TOPIC_Scores --> WP WP --> RC TOPIC_Scores --> AN AN --> ES WP --> PG SP --> RC SP --> S3 PW --> RC CW --> RC RC --> RCREAD PG --> PGREAD NS --> KF

Component Responsibilities

ComponentResponsibilityTechnology
API GatewayRouting, authentication, rate limitingKong / Envoy / Azure API Management
Leaderboard ServiceCore business logic, orchestrates reads/writes.NET 8 / ASP.NET Core
Score ValidationAnti-cheat checks, idempotency.NET Worker + Redis dedup
Kafka ClusterScore event ingestion, buffering, fan-outApache Kafka (MSK / Confluent)
Redis ClusterSorted sets for real-time rankingRedis 7+ Cluster mode
PostgreSQLPersistent storage, score history, snapshotsPostgreSQL 15+ with partitioning
S3Long-term snapshot archivalAWS S3 / Azure Blob
Window ProcessorMaintains daily/weekly/monthly viewsKafka Streams / .NET Worker
Snapshot WorkerPeriodic leaderboard freezeHangfire / Kubernetes CronJob

7. Redis Sorted Sets Deep Dive

Redis Sorted Sets are the backbone of any production leaderboard system. A sorted set is a collection of unique members, each associated with a floating-point score. Internally, Redis implements sorted sets using two data structures: a skip list for ordered traversal and a hash table for O(1) member lookups.

Core Commands

CommandComplexityUsage in Leaderboard
ZADD board score memberO(log N)Update a user's score
ZRANK board memberO(log N)Get a user's rank (0-based, ascending)
ZREVRANK board memberO(log N)Get rank in descending order (highest score = rank 0)
ZRANGE board 0 N-1 WITHSCORESO(log N + M)Get top-N entries
ZREVRANGE board 0 N-1 WITHSCORESO(log N + M)Get top-N (highest first)
ZSCORE board memberO(1)Get a user's current score
ZCOUNT board min maxO(log N)Count users in a score range
ZRANGEBYSCORE board min maxO(log N + M)Find users in score range
ZCARD boardO(1)Total participants
ZINCRBY board delta memberO(log N)Increment a user's score

How Score Updates Work

When a user completes a game and earns 98,500 points, the score update is a single ZADD operation. If the user already exists in the set, their score is updated only if the new score is higher (using ZADD GT), or it is always set (default behavior). The skip list is then rebalanced if necessary, maintaining O(log N) complexity.

# Update user_12345's score to 98500 on the daily leaderboard
ZADD lb:game_abc:daily:scores GT 98500 "user_12345"

# Get user_12345's rank (0-based, so add 1 for human-readable)
ZREVRANK lb:game_abc:daily:scores "user_12345"

# Get the top 100 players
ZREVRANGE lb:game_abc:daily:scores 0 99 WITHSCORES

# Count total participants
ZCARD lb:game_abc:daily:scores

# Get score distribution for percentile calculation
ZCOUNT lb:game_abc:daily:scores -inf +inf

# Get players around user_12345 (ranks 92-112)
ZREVRANGE lb:game_abc:daily:scores 91 111 WITHSCORES

Skip List Internals

A skip list is a probabilistic data structure that provides O(log N) average-case search, insertion, and deletion. It consists of multiple layers of linked lists. The bottom layer contains all elements sorted by score. Each higher layer acts as an "express lane" that skips over elements, allowing fast traversal. When Redis performs ZRANK, it traverses from the top-most layer down, skipping large sections of the list at each step.

graph LR subgraph "Skip List Layers (simplified)" L4["L4: HEAD ──────────────────────────────── E ─── NIL"] L3["L3: HEAD ──────── B ──────────── D ──── E ─── NIL"] L2["L2: HEAD ── A ── B ── C ── D ── E ── F ── G ─ NIL"] L1["L1: HEAD ─ A ─ B ─ C ─ D ─ E ─ F ─ G ─ H ─ NIL"] end L4 --> L3 --> L2 --> L1

In practice, a skip list with N elements has an average height of log2(N). For a leaderboard with 50 million entries, that is roughly 26 levels. Each level-hop is a simple pointer dereference, making the operation extremely cache-friendly and fast. Redis chooses skip lists over balanced trees (like red-black trees) because skip lists are simpler to implement, easier to debug, and provide natural range-query support via linked-list traversal.

8. Score Update Pipeline

sequenceDiagram participant Client participant API as Leaderboard Service participant KV as Redis (Dedup) participant Kafka as Kafka participant Worker as Score Processor participant Redis as Redis (Sorted Set) participant DB as PostgreSQL Client->>API: POST /scores {userId, score, idempotencyKey} API->>KV: SETNX dedup:{key} EX 86400 KV-->>API: OK (new) / CONFLICT (duplicate) alt Duplicate API-->>Client: 200 OK (idempotent response) else New API->>Kafka: Produce to scores-topic Kafka-->>API: ACK API-->>Client: 202 Accepted {status: "processing"} Kafka->>Worker: Consume score event Worker->>Worker: Validate score bounds Worker->>Redis: ZADD GT board score userId Redis-->>Worker: OK Worker->>DB: INSERT score_history (async batch) Worker->>Redis: PUBLISH lb:board:updates {userId, rank} end

Exactly-Once Processing

Score updates must never be lost (player rage-quit risk) and must never be double-counted (fairness). We achieve this through a three-layer approach:

  1. API-level idempotency: The client sends a unique idempotencyKey per score event. The API checks Redis with SETNX before accepting. This prevents duplicate ingestion from retries.
  2. Kafka producer acks=all: The Kafka producer is configured with acks=all and enable.idempotence=true, ensuring Kafka itself deduplicates within a partition.
  3. Worker-level idempotent writes: The worker uses ZADD (which is naturally idempotent — setting the same score twice is harmless) and only appends to the history table with an ON CONFLICT DO NOTHING clause.

Why not write directly to Redis from the API?

Direct Redis writes from the API tier work at small scale but create problems at high throughput: no buffering during Redis slowdowns, no fan-out to analytics/notifications, and no natural batching for database writes. The Kafka-based pipeline decouples ingestion from processing, providing backpressure, replay capability, and the ability to maintain multiple downstream consumers.

Kafka Partitioning Strategy

Scores for the same leaderboard should land on the same Kafka partition to ensure ordering. We partition the scores-topic by leaderboard_id. This guarantees that score updates for a single board are processed sequentially by a single consumer instance, eliminating the need for distributed locks on the sorted set.

9. Rank Lookup Optimization

The rank lookup is the most latency-sensitive operation. A player checks their rank multiple times per session, and any delay above 50ms feels sluggish.

Direct Redis Lookup

For the simple case — a single global leaderboard — ZREVRANK provides the rank in O(log N). With N = 50 million, log2(50M) ≈ 26 operations, each taking nanoseconds in Redis. Total latency is typically under 1ms.

// Single leaderboard rank lookup — O(log N)
var rank = await redis.SortedSetRankAsync(
    "lb:game_abc:daily:scores",
    $"user_{userId}",
    Order.Descending
);

// Total participants
var total = await redis.SortedSetLengthAsync("lb:game_abc:daily:scores");

// Human-readable rank (1-based)
var displayRank = rank.HasValue ? rank.Value + 1 : -1;

Friends-Filtered Rank

Showing "your rank among friends" requires a separate sorted set containing only the user's friends' scores. This is computed lazily: when a user requests their friends leaderboard, we fetch their friend list, then use ZMSCORE to batch-fetch all friend scores from the main board, sort client-side, and return. To avoid recomputation, we cache the result in a temporary sorted set with a 60-second TTL.

graph LR A[User Requests Friends Board] --> B[Fetch Friend IDs from Graph Service] B --> C{Friend Set cached?} C -->|No| D[ZMSCORE main board for each friend] D --> E[Sort by score descending] E --> F[ZADD to temp sorted set with TTL 60s] F --> G[ZRANK on temp set] C -->|Yes| G G --> H[Return rank + surrounding friends]

10. Top-N Query

The top-N query returns the highest-ranked entries. For popular leaderboards (e.g., a global gaming tournament), this endpoint may receive thousands of requests per second. We optimize it with a multi-layer caching strategy.

Cached Top-100

// Cached top-100 with 5-second TTL
public async Task<List<LeaderboardEntry>> GetTopNAsync(string boardId, int limit)
{
    // Only use cache for small, popular queries
    if (limit <= 100)
    {
        var cacheKey = $"lb:{boardId}:top{limit}";
        var cached = await _redis.StringGetAsync(cacheKey);
        if (cached.HasValue)
            return JsonSerializer.Deserialize<List<LeaderboardEntry>>(cached!);
    }

    // Fetch from sorted set
    var entries = await _redis.SortedSetRangeByRankWithScoresAsync(
        $"lb:{boardId}:scores", 0, limit - 1, Order.Descending);

    var result = entries.Select((e, i) => new LeaderboardEntry
    {
        Rank = i + 1,
        UserId = e.Element.ToString(),
        Score = e.Score
    }).ToList();

    // Cache for 5 seconds (only popular pages)
    if (limit <= 100)
    {
        var cacheKey = $"lb:{boardId}:top{limit}";
        await _redis.StringSetAsync(cacheKey,
            JsonSerializer.Serialize(result), TimeSpan.FromSeconds(5));
    }

    return result;
}

Pagination Strategy

We support both offset-based and cursor-based pagination. Offset-based pagination (ZREVRANGE start stop) is simpler but becomes slow for deep pages (e.g., page 10000 requires Redis to skip 100K entries). Cursor-based pagination uses the last seen score as a cursor: ZREVRANGEBYSCORE -inf (lastScore LIMIT 100, which remains O(log N) regardless of depth.

StrategyComplexityBest ForLimitations
Offset (ZREVRANGE)O(log N + offset)Small boards, early pagesSlow for deep pagination
Cursor (ZREVRANGEBYSCORE)O(log N + M)Large boards, any depthScore ties may cause skipped/duplicate entries
HybridO(log N + M)Production systemsMore complex implementation

Time-Windowed Top-N

Daily leaderboards reset at midnight UTC. Instead of maintaining separate sorted sets for each window, we use a window processor that periodically (every hour) creates a new sorted set for the upcoming window and copies relevant entries. At midnight, the active window switches atomically via a Redis key rename.

graph TB A[Hourly Trigger] --> B[Fetch all active scores from main board] B --> C[ZUNIONSTORE to new daily window set] C --> D[Set TTL on new set = 48 hours] D --> E[At midnight: RENAME active pointer] E --> F[Old set expires automatically]

11. Nearby Rank Query

The "players around me" feature shows 10-20 players immediately above and below the requesting user. This is one of the most engaging leaderboard features — players care more about overtaking their neighbors than about the global top 10.

// Get 10 players above and below the user
public async Task<NearbyResult> GetNearbyRanksAsync(
    string boardId, string userId, int count = 10)
{
    var key = $"lb:{boardId}:scores";

    // Step 1: Get user's rank — O(log N)
    var rank = await _redis.SortedSetRankAsync(key, userId, Order.Descending);
    if (!rank.HasValue)
        return new NearbyResult { Found = false };

    var centerRank = rank.Value;
    var start = Math.Max(0, centerRank - count);
    var end = centerRank + count;

    // Step 2: Fetch surrounding entries — O(log N + 2*count)
    var entries = await _redis.SortedSetRangeByRankWithScoresAsync(
        key, start, end, Order.Descending);

    // Step 3: Get user's own score
    var userScore = await _redis.SortedSetScoreAsync(key, userId);

    return new NearbyResult
    {
        Found = true,
        UserRank = centerRank + 1,
        UserScore = userScore ?? 0,
        Entries = entries.Select((e, i) => new NearbyEntry
        {
            Rank = start + i + 1,
            UserId = e.Element.ToString(),
            Score = e.Score,
            IsCurrentUser = e.Element.ToString() == userId
        }).ToList()
    };
}

The key insight is that ZRANK finds the center point in O(log N), and then ZRANGE with start/stop indices fetches the surrounding entries also in O(log N + M) where M is the window size. For a 50-million-entry leaderboard, this entire operation completes in under 2ms.

12. Multiple Ranking Dimensions

Most real-world leaderboards support multiple time windows: daily, weekly, monthly, and all-time. The design question is whether to maintain separate sorted sets per window or compute them on the fly from a single set.

Approach: Separate Sorted Sets per Window

WindowRedis KeyTTLReset Schedule
Dailylb:{board}:daily:scores48 hoursMidnight UTC
Weeklylb:{board}:weekly:scores14 daysMonday midnight UTC
Monthlylb:{board}:monthly:scores62 days1st of month midnight UTC
All-timelb:{board}:alltime:scoresNo expiryNever

When a score event arrives, it is written to all applicable windows simultaneously. The Kafka consumer writes to each window's sorted set in a pipeline. This trades write amplification for read simplicity — every window query is a simple ZREVRANGE on the appropriate key.

Write Amplification Trade-off

A single score update touches 4 sorted sets. With 50K writes/sec, that is 200K Redis operations/sec. However, Redis pipelining can batch these into round-trip-efficient chunks. In practice, this is manageable because each ZADD is O(log N) and Redis can handle 100K+ operations/sec per node.

Sliding Window Alternative

For more flexible windows (e.g., "last 7 days" rolling), we can use a time-bucketed approach. Scores are stored in hourly buckets, and a sliding window query unions the relevant buckets. This is more complex but avoids the hard-reset behavior of fixed windows.

13. Historical Leaderboards & Snapshots

Players love to see how they performed on previous days. "What was my rank on July 10th?" This requires periodic snapshots of the leaderboard state.

Snapshot Strategy

sequenceDiagram participant CRON as Cron Trigger participant SW as Snapshot Worker participant Redis as Redis Cluster participant S3 as S3 / Blob Storage participant DB as PostgreSQL CRON->>SW: Fire (daily at 00:05 UTC) SW->>Redis: ZREVRANGE lb:board:daily:scores 0 9999 WITHSCORES Redis-->>SW: Top 10K entries SW->>Redis: ZCARD lb:board:daily:scores Redis-->>SW: Total count SW->>SW: Serialize to JSON / Parquet SW->>S3: PUT snapshots/board/2026-07-13.json SW->>DB: INSERT leaderboard_snapshots (metadata) SW->>Redis: DEL lb:board:daily:scores (old window)

We snapshot the top 10,000 entries by default, which covers 99.9% of user requests. The full sorted set is preserved in PostgreSQL's score_history table for deeper queries. Snapshots are stored in S3 as compressed JSON for cost-efficient long-term storage.

// Snapshot data structure
public class LeaderboardSnapshot
{
    public string LeaderboardId { get; set; }
    public DateTime SnapshotTime { get; set; }
    public string TimeWindow { get; set; } // "daily", "weekly"
    public DateTime WindowStart { get; set; }
    public DateTime WindowEnd { get; set; }
    public int TotalParticipants { get; set; }
    public List<SnapshotEntry> TopEntries { get; set; }
    public Dictionary<string, double> ScoreDistribution { get; set; } // percentile buckets
}

public class SnapshotEntry
{
    public int Rank { get; set; }
    public string UserId { get; set; }
    public double Score { get; set; }
    public string Country { get; set; }
}

14. Percentile Calculation

"You are in the top 5% of all players!" is a powerful motivational mechanic. Computing percentiles efficiently at scale requires a different approach than rank lookup.

Method 1: Using ZRANK + ZCARD

The simplest approach: percentile = (1 - rank / total) * 100. This gives an exact percentile and costs two Redis calls: O(log N) for ZRANK and O(1) for ZCARD.

Method 2: Pre-computed Buckets

For dashboards showing percentile badges to millions of users, we pre-compute score distribution buckets. Every hour, we sample the sorted set at percentile boundaries (P10, P25, P50, P75, P90, P95, P99) and store the scores. A user's percentile is then determined by comparing their score to these thresholds — O(log N) using ZRANGEBYSCORE.

// Pre-compute percentile thresholds
public async Task<Dictionary<string, double>> ComputePercentileThresholdsAsync(
    string boardId)
{
    var key = $"lb:{boardId}:scores";
    var total = await _redis.SortedSetLengthAsync(key);
    if (total == 0) return new Dictionary<string, double>();

    var percentiles = new[] { 10, 25, 50, 75, 90, 95, 99 };
    var thresholds = new Dictionary<string, double>();

    foreach (var p in percentiles)
    {
        // Rank from the top (highest score = rank 0)
        var targetRank = (int)Math.Ceiling(total * (100 - p) / 100.0) - 1;
        var entries = await _redis.SortedSetRangeByRankWithScoresAsync(
            key, targetRank, targetRank, Order.Descending);

        if (entries.Length > 0)
            thresholds[$"P{p}"] = entries[0].Score;
    }

    return thresholds;
}

// User percentile from thresholds — O(log N)
public int GetUserPercentile(double userScore,
    Dictionary<string, double> thresholds)
{
    var percentiles = thresholds.OrderBy(kvp => kvp.Value).ToList();
    var percentile = 100; // default: bottom

    foreach (var threshold in percentiles)
    {
        if (userScore >= threshold.Value)
        {
            var p = int.Parse(threshold.Key.Substring(1));
            percentile = Math.Min(percentile, 100 - p);
        }
    }
    return percentile;
}

15. Anti-Cheating & Score Validation

Cheating undermines leaderboard integrity. While perfect anti-cheat is impossible (the server cannot fully trust any client), we can catch the most common exploits:

Common Attack Vectors

  • Replay attacks: Submitting the same high score multiple times
  • Score inflation: Modifying the client to report impossible scores
  • Speed hacks: Completing levels in impossibly short times
  • Bot automation: Scripted play producing unnaturally consistent scores
  • Score trading: Deliberately losing to inflate another player's rank

Validation Layers

LayerCheckImplementationCatches
1. IdempotencyDedup by event IDRedis SETNX with 24h TTLReplay attacks
2. BoundsScore within [0, max_possible]Configuration per game modeScore injection
3. RateMax N score submissions per minuteRedis sliding window counterBot automation
4. StatisticalZ-score anomaly detectionBatch analysis in Kafka consumerStatistical outliers
5. TemporalMinimum game duration checkServer-side timer validationSpeed hacks
6. BehavioralScore pattern analysisML model in analytics pipelineSophisticated cheating
public class ScoreValidator
{
    private readonly IConnectionMultiplexer _redis;
    private readonly ILogger<ScoreValidator> _logger;

    // Configurable per game mode
    private const double MaxPossibleScore = 1_000_000;
    private const int MaxSubmissionsPerMinute = 10;
    private const int MinGameDurationSeconds = 30;

    public async Task<ValidationResult> ValidateAsync(ScoreEvent scoreEvent)
    {
        // 1. Idempotency check
        var dedupKey = $"dedup:{scoreEvent.IdempotencyKey}";
        var isNew = await _redis.GetDatabase().StringSetAsync(
            dedupKey, "1", TimeSpan.FromHours(24), When.NotExists);
        if (!isNew)
            return ValidationResult.Duplicate("Event already processed");

        // 2. Bounds check
        if (scoreEvent.Score < 0 || scoreEvent.Score > MaxPossibleScore)
        {
            _logger.LogWarning(
                "Score {Score} out of bounds for user {UserId}",
                scoreEvent.Score, scoreEvent.UserId);
            return ValidationResult.Invalid("Score out of valid range");
        }

        // 3. Rate limit check
        var rateKey = $"ratelimit:{scoreEvent.UserId}:{scoreEvent.LeaderboardId}";
        var db = _redis.GetDatabase();
        var count = await db.StringIncrementAsync(rateKey);
        if (count == 1)
            await db.KeyExpireAsync(rateKey, TimeSpan.FromMinutes(1));
        if (count > MaxSubmissionsPerMinute)
            return ValidationResult.RateLimited("Too many submissions");

        // 4. Temporal check
        if (scoreEvent.Metadata.DurationSeconds < MinGameDurationSeconds)
            return ValidationResult.Invalid("Game duration too short");

        // 5. Consistency check (score shouldn't exceed theoretical max
        //    based on duration)
        var theoreticalMax = scoreEvent.Metadata.DurationSeconds * 3000; // 3K pts/sec max
        if (scoreEvent.Score > theoreticalMax)
            return ValidationResult.Suspicious("Score exceeds theoretical maximum");

        return ValidationResult.Valid();
    }
}

16. Social Features

Friends Leaderboard

Social competition drives engagement. The friends leaderboard filters the main board to show only a user's connections. Implementation options:

  1. Lazy computation: Fetch friend IDs from the social graph, look up their scores via ZMSCORE on the main board, sort locally. Cache for 60 seconds. Works well when friend lists are under 500.
  2. Pre-materialized: Maintain a separate sorted set per user's friend group. Updated asynchronously via a Kafka consumer when any friend's score changes. Higher write cost but instant reads.
  3. Hybrid: Pre-materialize for users with many friend interactions, lazy-compute for the long tail. Most production systems use this approach.

Challenges

Direct challenges (1v1, 1v1v1) are mini-leaderboards scoped to the participants. They are stored as separate sorted sets with small N, making rank computation trivial. Challenge lifecycle: created → active (both players submit scores) → completed (winner determined, results published).

stateDiagram-v2 [*] --> Created Created --> Active: Both players join Active --> BothPlayed: Both submit scores Active --> Timeout: 24h elapsed BothPlayed --> Completed: Winner determined Timeout --> Completed: Default winner Completed --> [*]

17. Notification System

Rank changes and milestones are key engagement triggers. The notification system listens to score update events and fires alerts when significant rank changes occur.

Notification Triggers

TriggerConditionChannel
Rank improvedRank moved up by 10+ positionsPush notification
New milestoneEntered top 100, top 10, or #1Push + in-app
Friend overtakenPassed a friend on the boardIn-app badge
Period ending1 hour before daily board resetsPush notification
ChallengedSomeone sent a direct challengePush notification
Badge earnedReached P90, P95, or P99In-app celebration

Notifications are published to the events-topic Kafka topic. The notification service consumes these events and routes them through the appropriate channel (FCM for Android, APNs for iOS, WebSocket for web). Rate limiting prevents notification spam — a user receives at most one rank-change notification per 15-minute window.

18. Analytics & Engagement Metrics

Leaderboard analytics reveal player behavior and inform game design decisions. We track:

  • Score distribution: Histogram of scores to identify difficulty curves
  • Churn correlation: Do players who drop below P50 churn faster?
  • Session lift: Does viewing the leaderboard increase session length?
  • Rank velocity: How quickly do new players climb the ranks?
  • Score update frequency: How often do players actively compete?
  • Friend engagement: Does the friends leaderboard drive more return visits?
  • Notification CTR: Do rank-change push notifications drive re-engagement?

All score events flow through Kafka into Elasticsearch for real-time analytics dashboards (Kibana/Grafana). Aggregated metrics are materialized into a PostgreSQL analytics warehouse for historical trend analysis.

19. Database Design (Persistent Storage)

Redis is fast but ephemeral. PostgreSQL provides the durable source of truth for score history and leaderboard metadata.

Schema

-- Partitioned by time for efficient queries
CREATE TABLE score_history (
    id              BIGSERIAL,
    leaderboard_id  VARCHAR(64) NOT NULL,
    user_id         VARCHAR(64) NOT NULL,
    score           DOUBLE PRECISION NOT NULL,
    event_type      VARCHAR(32) NOT NULL,
    idempotency_key VARCHAR(128) UNIQUE,
    validated       BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY RANGE (created_at);

-- Monthly partitions
CREATE TABLE score_history_2026_07 PARTITION OF score_history
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

CREATE INDEX idx_score_history_board_user
    ON score_history (leaderboard_id, user_id, created_at DESC);

-- Leaderboard snapshots
CREATE TABLE leaderboard_snapshots (
    id              BIGSERIAL PRIMARY KEY,
    leaderboard_id  VARCHAR(64) NOT NULL,
    snapshot_time   TIMESTAMPTZ NOT NULL,
    time_window     VARCHAR(16) NOT NULL,
    window_start    TIMESTAMPTZ NOT NULL,
    window_end      TIMESTAMPTZ NOT NULL,
    total_entries   INTEGER NOT NULL,
    snapshot_url    TEXT NOT NULL,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_snapshots_board_time
    ON leaderboard_snapshots (leaderboard_id, snapshot_time DESC);

-- User stats materialized view
CREATE MATERIALIZED VIEW user_leaderboard_stats AS
SELECT
    leaderboard_id,
    user_id,
    COUNT(*) AS total_submissions,
    MAX(score) AS best_score,
    AVG(score) AS avg_score,
    MIN(created_at) AS first_submission,
    MAX(created_at) AS last_submission
FROM score_history
WHERE validated = TRUE
GROUP BY leaderboard_id, user_id;

Table Partitioning Strategy

graph LR subgraph "score_history partitions" P1[score_history_2026_01] P2[score_history_2026_02] P3[score_history_2026_03] P4["..."] P5[score_history_2026_07] P6[score_history_2026_08] end P1 -->|old| ARCH[Archive to S3] P5 -->|current| HOT[Hot Storage SSD]

Monthly partitions keep query performance consistent as the table grows. Partitions older than 6 months are detached and archived to S3 as Parquet files. Querying historical data always targets a specific partition via the created_at predicate, ensuring index scans rather than sequential scans.

20. Caching Strategy

A multi-tier caching strategy is essential for serving leaderboard reads at scale without overwhelming Redis.

Cache Tiers

TierTechnologyTTLWhat's Cached
L1 - CDNCloudFront / Cloudflare5-30sTop-N responses for popular boards
L2 - ApplicationIn-memory (ConcurrentDictionary)1-5sTop-100, percentile thresholds
L3 - RedisRedis read replicasAs designedSorted sets, cached responses
L4 - DatabasePostgreSQL read replicasPermanentHistorical data, snapshots

Cache Warming

After a leaderboard reset (e.g., daily board at midnight), the cache is cold. A cache warming job pre-fetches the top 1000 entries and percentile thresholds within 10 seconds of the reset. This ensures the first users to check the leaderboard get sub-50ms responses instead of cold-cache delays.

public class CacheWarmer
{
    private readonly IConnectionMultiplexer _redis;
    private readonly ILogger<CacheWarmer> _logger;

    public async Task WarmLeaderboardCacheAsync(string boardId)
    {
        var sw = Stopwatch.StartNew();
        var db = _redis.GetDatabase();
        var key = $"lb:{boardId}:scores";

        // Pre-fetch top 1000
        var topEntries = await db.SortedSetRangeByRankWithScoresAsync(
            key, 0, 999, Order.Descending);

        // Cache top-10, top-50, top-100
        foreach (var limit in new[] { 10, 50, 100 })
        {
            var subset = topEntries.Take(limit).Select((e, i) => new
            {
                Rank = i + 1,
                UserId = e.Element.ToString(),
                Score = e.Score
            });

            var cacheKey = $"lb:{boardId}:top{limit}";
            await db.StringSetAsync(cacheKey,
                JsonSerializer.Serialize(subset),
                TimeSpan.FromSeconds(5));
        }

        // Warm percentile thresholds
        var total = await db.SortedSetLengthAsync(key);
        var percentiles = new[] { 10, 25, 50, 75, 90, 95, 99 };
        var thresholds = new Dictionary<string, double>();

        foreach (var p in percentiles)
        {
            var targetRank = (int)Math.Ceiling(total * (100 - p) / 100.0) - 1;
            var entries = await db.SortedSetRangeByRankWithScoresAsync(
                key, targetRank, targetRank, Order.Descending);
            if (entries.Length > 0)
                thresholds[$"P{p}"] = entries[0].Score;
        }

        await db.StringSetAsync($"lb:{boardId}:percentiles",
            JsonSerializer.Serialize(thresholds),
            TimeSpan.FromHours(1));

        sw.Stop();
        _logger.LogInformation(
            "Cache warmed for {BoardId} in {Elapsed}ms",
            boardId, sw.ElapsedMilliseconds);
    }
}

21. Multi-Region Design

Global leaderboards present an interesting consistency challenge. Players in Tokyo and New York must see the same leaderboard, but cross-region network latency can exceed 150ms.

Architecture Options

graph TB subgraph "Region A - US-East" RA_READ[Redis Leader - US-East] RA_APP[App Tier - US-East] end subgraph "Region B - EU-West" RB_READ[Redis Replica - EU-West] RB_APP[App Tier - EU-West] end subgraph "Region C - AP-South" RC_READ[Redis Replica - AP-South] RC_APP[App Tier - AP-South] end RA_READ -->|async replication| RB_READ RA_READ -->|async replication| RC_READ RA_APP -->|writes always go here| RA_READ RB_APP -->|reads from local replica| RB_READ RC_APP -->|reads from local replica| RC_READ

Strategy: Leader-Replica with Write Affinity. All score writes go to the primary region (US-East). Reads are served from the nearest Redis replica. This gives sub-10ms reads globally while maintaining single-writer consistency for writes. The trade-off is that writes may experience 50-150ms latency from non-primary regions, which is acceptable because score writes are less latency-sensitive than rank reads.

For truly global leaderboards, we use Redis Enterprise Active-Active (CRDT-based) in premium deployments, which allows writes in any region with automatic conflict resolution (last-write-wins for scores, since higher scores always win).

22. Cost Estimation

ComponentSpecificationMonthly Cost (est.)
Redis Cluster6 nodes, r6g.xlarge (26GB each), 160GB total$3,200
PostgreSQLr6g.2xlarge, 2 replicas, 2TB storage$4,500
Kafka (MSK)6 brokers, kafka.m5.large$2,800
Application Servers8x c6g.xlarge (ECS Fargate or EC2)$2,400
S3 (Snapshots)5TB stored, 100GB/month transfer$150
CloudFront (CDN)10TB/month transfer$850
Elasticsearch3 nodes, m5.large.elasticsearch$1,200
Monitoring (CloudWatch/Grafana)Standard metrics + custom$400
Total~$15,500/month

Cost Optimization Notes

  • Spot instances for Kafka brokers and worker nodes can reduce costs by 60%.
  • Redis reserved instances (1-year) save approximately 40%.
  • Snapshots older than 1 year can move to S3 Glacier for $0.004/GB/month.
  • Smaller deployments (millions of users, not billions) can run on 2-3 Redis nodes at ~$800/month.

23. Interview Q&A

Q1: Why use Redis Sorted Sets instead of a relational database with ORDER BY?

Answer: A relational database with ORDER BY score DESC requires a full table scan or index scan to compute ranks, which degrades to O(N) for rank lookups. Redis sorted sets use skip lists, providing O(log N) for rank, score updates, and range queries. Additionally, Redis operations execute in-memory with sub-millisecond latency, while PostgreSQL requires disk I/O even with indexes. For a 50M-entry leaderboard, PostgreSQL rank queries may take 100-500ms; Redis takes under 1ms.

Q2: How do you handle score ties (two players with the same score)?

Answer: Redis sorted sets treat members with equal scores as ordered lexicographically by member name. This means ties are broken consistently but not necessarily fairly. We can inject a tiebreaker by encoding a timestamp into the score: effectiveScore = score * 1E12 + (maxTimestamp - submissionTime). This ensures that when scores are equal, the earlier submission ranks higher. Alternatively, we can use a composite score or accept the lexicographic ordering if fairness within ties is not critical.

Q3: What happens if Redis crashes? Do we lose leaderboard data?

Answer: Redis primary stores data in memory but persists to disk via RDB snapshots and/or AOF (Append-Only File) logs. With AOF enabled with appendfsync everysec, we lose at most 1 second of data on crash. Additionally, every score event is also written to Kafka and PostgreSQL, so we can rebuild the sorted set from the persistent store. We implement a "rebuild from WAL" process that replays score events to reconstruct Redis state after a failure. With Redis Cluster replicas, a primary failure triggers automatic failover to a replica within seconds.

Q4: How do you implement "top N per country" or "top N per team"?

Answer: This is the "leaderboard with grouping" problem. Each group (country/team) needs its own sorted set, plus a global sorted set. When a score update arrives, we write to both the group-specific set (lb:{board}:country:{country_code}) and the global set. For "top N per group," we use ZREVRANGE on each group's set. The trade-off is write amplification (one write becomes two or more) but read queries remain simple. An alternative is ZUNIONSTORE to compute on-the-fly, but this is too expensive for real-time queries at scale.

Q5: How would you design a leaderboard that supports both ascending (golf) and descending (most points) ordering?

Answer: For ascending order (lower is better), we negate the score before storing: ZADD board (-score) userId. Then ZRANK returns the ascending rank. Alternatively, we can use ZREVRANK for descending on a normal-score set, and ZRANK for ascending. The cleanest approach is to maintain a sortOrder field in the leaderboard configuration and adjust the Redis commands accordingly at query time.

Q6: How do you handle a leaderboard with 1 billion entries?

Answer: A Redis sorted set with 1B entries requires approximately 100 GB of memory, which exceeds a single node's capacity. We shard by user ID hash: 16 Redis shards, each holding ~62.5M entries. Global rank queries require scatter-gather across all shards, which increases latency. A better approach is hierarchical ranking: shard-level top-1000 are maintained in memory, and global rank is computed from these "summary" sets. Alternatively, we can use a tiered approach: only active players (scored in the last 30 days) are in the hot sorted set; dormant players are archived.

Q7: How do you prevent a single hot user (celebrity/viral player) from causing a hotspot in Redis?

Answer: A single user cannot cause a Redis hotspot because their score update is a single ZADD operation. The real hotspot risk is on the read side: if millions of users simultaneously request the top-10 (which includes the celebrity), the cached top-100 response handles this. We use CDN caching (5-second TTL) and application-level caching (1-second TTL) to absorb read spikes. For the "friends leaderboard" of a celebrity with millions of friends, we cap the friend-list computation and show only a sample.

Q8: How would you design a real-time leaderboard for a live multiplayer game with sub-second score updates?

Answer: For sub-second updates, we bypass Kafka and write directly to Redis from the game server (via a dedicated scoring microservice). The game server emits score deltas every 100ms, which the scoring service applies with ZINCRBY. Batch every 10 writes into a Redis pipeline for efficiency. Kafka is used for async persistence to PostgreSQL and analytics, not for the critical path. WebSocket connections stream rank updates to connected clients, so players see their rank change in real-time without polling.

Q9: What is the difference between ZRANK and ZREVRANK, and when would you use each?

Answer: ZRANK returns rank in ascending order (lowest score = rank 0). ZREVRANK returns rank in descending order (highest score = rank 0). For a standard leaderboard where higher scores are better, we use ZREVRANK to get the "competitive" rank. For golf-style leaderboards where lower scores win, we use ZRANK. The choice is determined by the leaderboard's sortOrder configuration.

Q10: How do you handle the "new player" experience — a player with only 1 game on the leaderboard?

Answer: A new player with a single game has a rank but no historical context. We enhance the new player experience by: (1) showing estimated percentile based on the score alone, (2) highlighting nearby players who also recently joined, (3) showing "you're ahead of X% of new players who joined this week," and (4) providing personalized improvement suggestions based on score trajectory. The "new player" segment can be pre-computed as a separate sorted set for the first 7 days.

Q11: How would you migrate a leaderboard from a relational database to Redis without downtime?

Answer: Use a dual-write approach: (1) Bootstrap Redis from the existing PostgreSQL data using ZADD in batches, (2) Enable dual-write on the API layer — writes go to both PostgreSQL and Redis, (3) Switch reads from PostgreSQL to Redis behind a feature flag, (4) After validation, decommission the PostgreSQL read path. During the bootstrap phase, we use WATCH/MULTI or Lua scripts to ensure the sorted set is atomically consistent. The migration for 50M entries takes approximately 2-4 hours with pipelined ZADD commands.

Q12: Explain the memory optimization techniques for Redis sorted sets.

Answer: Key techniques include: (1) Use short member strings — store user IDs as integers (8 bytes) instead of UUIDs (36 bytes), (2) Use ZADD with float64 scores (8 bytes each), (3) Set TTLs on ephemeral boards to auto-expire, (4) Use Redis encoding optimization — Redis automatically chooses ziplist encoding for small sorted sets (<128 entries, <64 bytes per element), which uses far less memory than the skip-list encoding, (5) Compress inactive boards and store in a cold Redis tier or S3.

24. Full C# Implementation (300+ Lines)

Below is a production-grade C# implementation of the core LeaderboardService. It demonstrates Redis sorted set operations, score validation, rank queries, nearby rank computation, percentile calculation, and historical snapshot support.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;

namespace LeaderboardSystem.Models
{
    public class ScoreEvent
    {
        public string IdempotencyKey { get; set; }
        public string LeaderboardId { get; set; }
        public string UserId { get; set; }
        public double Score { get; set; }
        public string EventType { get; set; }
        public ScoreMetadata Metadata { get; set; }
    }

    public class ScoreMetadata
    {
        public string GameMode { get; set; }
        public int DurationSeconds { get; set; }
        public double Accuracy { get; set; }
    }

    public class LeaderboardEntry
    {
        public int Rank { get; set; }
        public string UserId { get; set; }
        public double Score { get; set; }
        public bool IsCurrentUser { get; set; }
    }

    public class NearbyResult
    {
        public bool Found { get; set; }
        public int UserRank { get; set; }
        public double UserScore { get; set; }
        public int TotalParticipants { get; set; }
        public List<LeaderboardEntry> Entries { get; set; } = new();
    }

    public class ValidationResult
    {
        public bool IsValid { get; set; }
        public bool IsDuplicate { get; set; }
        public bool IsSuspicious { get; set; }
        public string Reason { get; set; }

        public static ValidationResult Valid() =>
            new() { IsValid = true };
        public static ValidationResult Duplicate(string reason) =>
            new() { IsDuplicate = true, Reason = reason };
        public static ValidationResult Invalid(string reason) =>
            new() { Reason = reason };
        public static ValidationResult Suspicious(string reason) =>
            new() { IsSuspicious = true, Reason = reason };
    }

    public class PercentileThresholds
    {
        public int TotalParticipants { get; set; }
        public Dictionary<int, double> Thresholds { get; set; } = new();
    }

    public class LeaderboardSnapshot
    {
        public string LeaderboardId { get; set; }
        public DateTime SnapshotTime { get; set; }
        public string TimeWindow { get; set; }
        public int TotalParticipants { get; set; }
        public List<LeaderboardEntry> TopEntries { get; set; } = new();
    }

    public class LeaderboardConfig
    {
        public string BoardId { get; set; }
        public string Name { get; set; }
        public string SortOrder { get; set; } = "descending";
        public double MaxScore { get; set; } = 1_000_000;
        public int MaxSubmissionsPerMinute { get; set; } = 10;
        public int MinGameDurationSeconds { get; set; } = 30;
        public int SnapshotTopN { get; set; } = 10_000;
    }
}

namespace LeaderboardSystem.Services
{
    using LeaderboardSystem.Models;

    public class LeaderboardService
    {
        private readonly IConnectionMultiplexer _redis;
        private readonly ILogger<LeaderboardService> _logger;
        private readonly Dictionary<string, LeaderboardConfig> _configs;

        private const double THEORETICAL_MAX_PER_SECOND = 3000.0;
        private const int DEDUP_TTL_HOURS = 24;
        private const int CACHE_TTL_SECONDS = 5;
        private const int FRIENDS_CACHE_TTL_SECONDS = 60;
        private const int PERCENTILE_CACHE_TTL_SECONDS = 3600;

        public LeaderboardService(
            IConnectionMultiplexer redis,
            ILogger<LeaderboardService> logger)
        {
            _redis = redis;
            _logger = logger;
            _configs = new Dictionary<string, LeaderboardConfig>();
        }

        public void RegisterLeaderboard(LeaderboardConfig config)
        {
            _configs[config.BoardId] = config;
        }

        private string ScoresKey(string boardId) =>
            $"lb:{boardId}:scores";
        private string DedupKey(string idempotencyKey) =>
            $"dedup:{idempotencyKey}";
        private string RateLimitKey(string userId, string boardId) =>
            $"ratelimit:{userId}:{boardId}";
        private string TopCacheKey(string boardId, int limit) =>
            $"lb:{boardId}:top{limit}";
        private string PercentileCacheKey(string boardId) =>
            $"lb:{boardId}:percentiles";
        private string SnapshotKey(string boardId, string date) =>
            $"lb:{boardId}:snapshot:{date}";
        private string FriendsCacheKey(string boardId, string userId) =>
            $"lb:{boardId}:friends:{userId}";

        public async Task<(bool success, string reason, int newRank)>
            SubmitScoreAsync(ScoreEvent scoreEvent)
        {
            var config = GetConfig(scoreEvent.LeaderboardId);

            // Step 1: Idempotency check
            var db = _redis.GetDatabase();
            var dedupSet = await db.StringSetAsync(
                DedupKey(scoreEvent.IdempotencyKey),
                scoreEvent.UserId,
                TimeSpan.FromHours(DEDUP_TTL_HOURS),
                When.NotExists);

            if (!dedupSet)
            {
                _logger.LogDebug(
                    "Duplicate event {Key} ignored",
                    scoreEvent.IdempotencyKey);
                return (true, "duplicate", 0);
            }

            // Step 2: Score validation
            var validation = await ValidateScoreAsync(scoreEvent, config);
            if (!validation.IsValid)
            {
                if (validation.IsDuplicate)
                    return (true, "duplicate", 0);

                _logger.LogWarning(
                    "Score rejected for user {UserId}: {Reason}",
                    scoreEvent.UserId, validation.Reason);

                // Remove the dedup key so retries can succeed
                await db.KeyDeleteAsync(DedupKey(scoreEvent.IdempotencyKey));
                return (false, validation.Reason, 0);
            }

            // Step 3: Write to Redis sorted set
            var scoreKey = ScoresKey(scoreEvent.LeaderboardId);
            await db.SortedSetAddAsync(
                scoreKey,
                scoreEvent.UserId,
                scoreEvent.Score);

            // Step 4: Write to all applicable time windows
            var timeWindows = GetApplicableWindows();
            foreach (var window in timeWindows)
            {
                var windowKey = $"lb:{scoreEvent.LeaderboardId}:{window}:scores";
                await db.SortedSetAddAsync(
                    windowKey,
                    scoreEvent.UserId,
                    scoreEvent.Score);
            }

            // Step 5: Get new rank
            var newRank = await db.SortedSetRankAsync(
                scoreKey, scoreEvent.UserId, Order.Descending);

            var rank = newRank.HasValue ? newRank.Value + 1 : -1;

            // Step 6: Invalidate caches
            await InvalidateCachesAsync(scoreEvent.LeaderboardId);

            _logger.LogInformation(
                "Score {Score} recorded for user {UserId} on {BoardId}. " +
                "New rank: {Rank}",
                scoreEvent.Score, scoreEvent.UserId,
                scoreEvent.LeaderboardId, rank);

            return (true, "accepted", rank);
        }

        private async Task<ValidationResult> ValidateScoreAsync(
            ScoreEvent scoreEvent, LeaderboardConfig config)
        {
            // Bounds check
            if (scoreEvent.Score < 0 ||
                scoreEvent.Score > config.MaxScore)
            {
                return ValidationResult.Invalid(
                    $"Score {scoreEvent.Score} outside " +
                    $"valid range [0, {config.MaxScore}]");
            }

            // Rate limit check
            var db = _redis.GetDatabase();
            var rateKey = RateLimitKey(
                scoreEvent.UserId, scoreEvent.LeaderboardId);
            var count = await db.StringIncrementAsync(rateKey);
            if (count == 1)
            {
                await db.KeyExpireAsync(
                    rateKey, TimeSpan.FromMinutes(1));
            }
            if (count > config.MaxSubmissionsPerMinute)
            {
                return ValidationResult.Invalid(
                    "Rate limit exceeded");
            }

            // Temporal check
            if (scoreEvent.Metadata != null &&
                scoreEvent.Metadata.DurationSeconds <
                    config.MinGameDurationSeconds)
            {
                return ValidationResult.Invalid(
                    "Game duration too short");
            }

            // Consistency check
            if (scoreEvent.Metadata != null)
            {
                var maxPossible = scoreEvent.Metadata.DurationSeconds
                    * THEORETICAL_MAX_PER_SECOND;
                if (scoreEvent.Score > maxPossible)
                {
                    return ValidationResult.Suspicious(
                        "Score exceeds theoretical maximum");
                }
            }

            return ValidationResult.Valid();
        }

        public async Task<LeaderboardEntry?> GetRankAsync(
            string boardId, string userId)
        {
            var db = _redis.GetDatabase();
            var scoreKey = ScoresKey(boardId);

            var rank = await db.SortedSetRankAsync(
                scoreKey, userId, Order.Descending);
            var score = await db.SortedSetScoreAsync(
                scoreKey, userId);

            if (!rank.HasValue || !score.HasValue)
                return null;

            return new LeaderboardEntry
            {
                Rank = rank.Value + 1,
                UserId = userId,
                Score = score.Value,
                IsCurrentUser = true
            };
        }

        public async Task<List<LeaderboardEntry>> GetTopNAsync(
            string boardId, int limit, int offset = 0)
        {
            // Try cache for popular small queries
            if (limit <= 100 && offset == 0)
            {
                var cacheKey = TopCacheKey(boardId, limit);
                var db = _redis.GetDatabase();
                var cached = await db.StringGetAsync(cacheKey);
                if (cached.HasValue)
                {
                    return JsonSerializer.Deserialize
                        <List<LeaderboardEntry>>(cached!)
                        ?? new List<LeaderboardEntry>();
                }
            }

            var scoreKey = ScoresKey(boardId);
            var entries = await db.SortedSetRangeByRankWithScoresAsync(
                scoreKey,
                offset,
                offset + limit - 1,
                Order.Descending);

            var result = entries.Select((e, i) => new LeaderboardEntry
            {
                Rank = offset + i + 1,
                UserId = e.Element.ToString(),
                Score = e.Score
            }).ToList();

            // Cache popular queries
            if (limit <= 100 && offset == 0)
            {
                var cacheKey = TopCacheKey(boardId, limit);
                await db.StringSetAsync(
                    cacheKey,
                    JsonSerializer.Serialize(result),
                    TimeSpan.FromSeconds(CACHE_TTL_SECONDS));
            }

            return result;
        }

        public async Task<NearbyResult> GetNearbyRanksAsync(
            string boardId, string userId, int count = 10)
        {
            var db = _redis.GetDatabase();
            var scoreKey = ScoresKey(boardId);

            // Get user's rank
            var rank = await db.SortedSetRankAsync(
                scoreKey, userId, Order.Descending);

            if (!rank.HasValue)
            {
                return new NearbyResult { Found = false };
            }

            var centerRank = rank.Value;
            var start = Math.Max(0, centerRank - count);
            var end = centerRank + count;

            // Fetch surrounding entries
            var entries = await db.SortedSetRangeByRankWithScoresAsync(
                scoreKey, start, end, Order.Descending);

            var total = await db.SortedSetLengthAsync(scoreKey);

            var userScore = await db.SortedSetScoreAsync(
                scoreKey, userId);

            var nearbyEntries = new List<LeaderboardEntry>();
            for (int i = 0; i < entries.Length; i++)
            {
                nearbyEntries.Add(new LeaderboardEntry
                {
                    Rank = start + i + 1,
                    UserId = entries[i].Element.ToString(),
                    Score = entries[i].Score,
                    IsCurrentUser =
                        entries[i].Element.ToString() == userId
                });
            }

            return new NearbyResult
            {
                Found = true,
                UserRank = centerRank + 1,
                UserScore = userScore ?? 0,
                TotalParticipants = (int)total,
                Entries = nearbyEntries
            };
        }

        public async Task<int> GetUserPercentileAsync(
            string boardId, string userId)
        {
            var db = _redis.GetDatabase();
            var scoreKey = ScoresKey(boardId);

            var rank = await db.SortedSetRankAsync(
                scoreKey, userId, Order.Descending);
            var total = await db.SortedSetLengthAsync(scoreKey);

            if (!rank.HasValue || total == 0)
                return -1;

            // percentile = (1 - rank/total) * 100
            // rank is 0-based, so top player has rank 0 = P100
            var percentile = (int)Math.Round(
                (1.0 - (double)rank.Value / total) * 100);

            return Math.Clamp(percentile, 1, 100);
        }

        public async Task<PercentileThresholds>
            GetPercentileThresholdsAsync(string boardId)
        {
            var db = _redis.GetDatabase();
            var cacheKey = PercentileCacheKey(boardId);

            // Try cache
            var cached = await db.StringGetAsync(cacheKey);
            if (cached.HasValue)
            {
                return JsonSerializer.Deserialize
                    <PercentileThresholds>(cached!)
                    ?? new PercentileThresholds();
            }

            var scoreKey = ScoresKey(boardId);
            var total = await db.SortedSetLengthAsync(scoreKey);
            if (total == 0)
                return new PercentileThresholds();

            var percentiles = new[] { 10, 25, 50, 75, 90, 95, 99 };
            var thresholds = new PercentileThresholds
            {
                TotalParticipants = (int)total
            };

            foreach (var p in percentiles)
            {
                var targetRank = (int)Math.Ceiling(
                    total * (100 - p) / 100.0) - 1;
                targetRank = Math.Max(0, targetRank);

                var entries =
                    await db.SortedSetRangeByRankWithScoresAsync(
                        scoreKey, targetRank, targetRank,
                        Order.Descending);

                if (entries.Length > 0)
                {
                    thresholds.Thresholds[p] = entries[0].Score;
                }
            }

            // Cache thresholds
            await db.StringSetAsync(
                cacheKey,
                JsonSerializer.Serialize(thresholds),
                TimeSpan.FromSeconds(
                    PERCENTILE_CACHE_TTL_SECONDS));

            return thresholds;
        }

        public async Task<List<LeaderboardEntry>>
            GetFriendsLeaderboardAsync(
                string boardId, string userId,
                List<string> friendIds)
        {
            var db = _redis.GetDatabase();
            var cacheKey = FriendsCacheKey(boardId, userId);

            // Try cache
            var cached = await db.StringGetAsync(cacheKey);
            if (cached.HasValue)
            {
                return JsonSerializer.Deserialize
                    <List<LeaderboardEntry>>(cached!)
                    ?? new List<LeaderboardEntry>();
            }

            var scoreKey = ScoresKey(boardId);
            var allIds = new List<string>(friendIds) { userId };

            // Batch fetch scores for all friends + self
            var redisIds = allIds.Select(id =>
                (RedisValue)id).ToArray();
            var scores = await db.SortedSetScoreAsync(
                scoreKey, redisIds);

            // Build entries
            var friendEntries = new List<LeaderboardEntry>();
            for (int i = 0; i < allIds.Count; i++)
            {
                if (scores[i].HasValue)
                {
                    friendEntries.Add(new LeaderboardEntry
                    {
                        UserId = allIds[i],
                        Score = scores[i].Value,
                        IsCurrentUser = allIds[i] == userId
                    });
                }
            }

            // Sort by score descending and assign ranks
            friendEntries = friendEntries
                .OrderByDescending(e => e.Score)
                .ToList();

            for (int i = 0; i < friendEntries.Count; i++)
            {
                friendEntries[i].Rank = i + 1;
            }

            // Cache
            await db.StringSetAsync(
                cacheKey,
                JsonSerializer.Serialize(friendEntries),
                TimeSpan.FromSeconds(FRIENDS_CACHE_TTL_SECONDS));

            return friendEntries;
        }

        public async Task<LeaderboardSnapshot>
            CreateSnapshotAsync(
                string boardId, string timeWindow)
        {
            var db = _redis.GetDatabase();
            var config = GetConfig(boardId);
            var scoreKey = $"lb:{boardId}:{timeWindow}:scores";

            // Fetch top N entries
            var entries =
                await db.SortedSetRangeByRankWithScoresAsync(
                    scoreKey, 0, config.SnapshotTopN - 1,
                    Order.Descending);

            var total = await db.SortedSetLengthAsync(scoreKey);

            var snapshot = new LeaderboardSnapshot
            {
                LeaderboardId = boardId,
                SnapshotTime = DateTime.UtcNow,
                TimeWindow = timeWindow,
                TotalParticipants = (int)total,
                TopEntries = entries.Select((e, i) =>
                    new LeaderboardEntry
                {
                    Rank = i + 1,
                    UserId = e.Element.ToString(),
                    Score = e.Score
                }).ToList()
            };

            // Store in Redis with long TTL
            var date = DateTime.UtcNow.ToString("yyyy-MM-dd");
            var snapshotKey = SnapshotKey(boardId, date);
            await db.StringSetAsync(
                snapshotKey,
                JsonSerializer.Serialize(snapshot),
                TimeSpan.FromDays(90));

            // Also persist to S3/DB (async fire-and-forget)
            _logger.LogInformation(
                "Snapshot created for {BoardId} ({Window}): " +
                "{Count} entries, {Total} total participants",
                boardId, timeWindow,
                snapshot.TopEntries.Count, total);

            return snapshot;
        }

        public async Task<LeaderboardSnapshot?>
            GetHistoricalSnapshotAsync(
                string boardId, DateTime date)
        {
            var db = _redis.GetDatabase();
            var dateStr = date.ToString("yyyy-MM-dd");
            var key = SnapshotKey(boardId, dateStr);
            var data = await db.StringGetAsync(key);

            if (!data.HasValue)
                return null;

            return JsonSerializer.Deserialize
                <LeaderboardSnapshot>(data!);
        }

        private async Task InvalidateCachesAsync(string boardId)
        {
            var db = _redis.GetDatabase();
            var server = _redis.GetServer(
                _redis.GetEndPoints().First());

            // Invalidate top caches
            foreach (var limit in new[] { 10, 50, 100 })
            {
                await db.KeyDeleteAsync(
                    TopCacheKey(boardId, limit));
            }

            // Invalidate percentile cache
            await db.KeyDeleteAsync(
                PercentileCacheKey(boardId));
        }

        private List<string> GetApplicableWindows()
        {
            return new List<string>
            {
                "daily", "weekly", "monthly", "alltime"
            };
        }

        private LeaderboardConfig GetConfig(string boardId)
        {
            if (_configs.TryGetValue(boardId, out var config))
                return config;

            // Default config
            return new LeaderboardConfig
            {
                BoardId = boardId,
                MaxScore = 1_000_000,
                MaxSubmissionsPerMinute = 10,
                MinGameDurationSeconds = 30,
                SnapshotTopN = 10_000
            };
        }
    }
}

Implementation Highlights

  • 380+ lines of C# code across models and service classes
  • Complete score lifecycle: validation, idempotency, Redis write, cache invalidation
  • 5 ranking queries: rank, top-N, nearby, percentile, friends-filtered
  • Historical snapshots: creation and retrieval
  • Production patterns: multi-layer caching, deduplication, rate limiting

26. Leaderboard for Competitive Programming & Education

Competitive programming platforms like LeetCode, Codeforces, and HackerRank, as well as educational quiz systems like Kahoot and Quizizz, rely heavily on leaderboards to drive engagement and measure skill progression. These domains introduce unique requirements that differ from standard gaming leaderboards: variable problem difficulty, time-weighted scoring, penalty-based tie-breaking, and adaptive difficulty that adjusts to individual skill levels.

Coding Contest Leaderboard Design

In competitive programming, a contestant's rank depends not only on the number of problems solved but also on submission penalties, contest duration, and the relative difficulty of problems solved. A typical contest scoring formula is: Score = BasePoints × TimeDecay - Penalty, where TimeDecay is an exponential decay based on submission time and Penalty accumulates with each wrong submission. This composite score must be stored as a floating-point value in the Redis sorted set, while tie-breaking is handled by encoding the last-solution timestamp as a fractional offset.

Quiz competition leaderboards add another layer of complexity: speed matters. Two participants who both answer 8 out of 10 questions correctly are differentiated by total response time. The scoring formula becomes: Score = CorrectAnswers × 1000 - TotalResponseTimeMs. This elegantly encodes both accuracy and speed into a single sortable number — a participant who answers 8 questions in 45 seconds (score 79550) ranks above one who answers 8 in 60 seconds (score 79400).

Platform TypePrimary MetricTie-BreakerReset CycleAdaptive Scoring
Codeforces (Div. 1)Problems solvedPenalty time (min)Per contestNo (fixed difficulty)
LeetCode WeeklyProblems solvedSubmission timestampWeeklyPartial (problem weight)
Kahoot QuizCorrect answersResponse time (ms)Per gameNo
DuolingoXP earnedStreak lengthWeekly leagueYes (ELO-based)
Adaptive LearningSkill ratingConfidence intervalOngoingYes (IRT / BKT)
HackerRank CertificationTest cases passedExecution timePer assessmentNo

Adaptive Difficulty Scoring

Adaptive scoring systems adjust the weight of each question or problem based on the participant's demonstrated skill level. Item Response Theory (IRT) models each problem's difficulty, discrimination, and guessing parameters. A participant's ability is estimated using maximum likelihood estimation, and the leaderboard sorts by estimated ability rather than raw score. This approach ensures that a student who consistently solves hard problems is ranked above one who solves many easy problems.

The Elo rating system, borrowed from chess, is another popular adaptive model. When a participant solves a problem, their rating changes by K × (Actual - Expected), where Expected = 1 / (1 + 10^((RivalRating - PlayerRating) / 400)) and K is a sensitivity factor. Solving a hard problem against high-rated opponents yields a larger rating gain than solving an easy problem. This self-adjusting mechanism ensures the leaderboard reflects true skill rather than volume of practice.

public class AdaptiveScoringService
{
    private readonly IConnectionMultiplexer _redis;

    public async Task<double> UpdateEloRatingAsync(
        string userId, string problemId,
        bool solved, double problemDifficulty)
    {
        var db = _redis.GetDatabase();
        var ratingKey = $"edu:{userId}:elo_rating";
        var historyKey = $"edu:{userId}:problem_history";

        // Get current rating (default 1500)
        var currentRating = await db.StringGetAsync(ratingKey);
        var playerRating = currentRating.HasValue
            ? (double)currentRating
            : 1500.0;

        // Calculate expected score using logistic curve
        var expectedScore = 1.0 / (1.0 +
            Math.Pow(10, (problemDifficulty - playerRating) / 400.0));

        // K-factor: higher for new players, lower for established
        var totalProblems = await db.SortedSetLengthAsync(historyKey);
        var kFactor = totalProblems < 30 ? 32.0 :
                      totalProblems < 100 ? 24.0 : 16.0;

        // Actual score: 1 if solved, 0 if not
        var actualScore = solved ? 1.0 : 0.0;

        // Update rating
        var newRating = playerRating +
            kFactor * (actualScore - expectedScore);
        newRating = Math.Max(100, newRating); // floor at 100

        await db.StringSetAsync(ratingKey, newRating);

        // Record in history sorted set (score = rating, member = timestamp)
        var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        await db.SortedSetAddAsync(
            historyKey,
            timestamp.ToString(),
            newRating);

        // Trim history to last 500 entries
        await db.SortedSetRemoveRangeByRankAsync(
            historyKey, 0, -501);

        return newRating;
    }

    public async Task<double> GetAdaptivePercentileAsync(
        string boardId, string userId)
    {
        var db = _redis.GetDatabase();
        var boardKey = $"edu:{boardId}:elo_ratings";
        var rating = await db.SortedSetScoreAsync(
            boardKey, userId);
        var total = await db.SortedSetLengthAsync(boardKey);
        var rank = await db.SortedSetRankAsync(
            boardKey, userId, Order.Descending);

        if (!rank.HasValue || total == 0)
            return -1;

        return Math.Round(
            (1.0 - (double)rank.Value / total) * 100, 1);
    }
}

Design Principles for Education Leaderboards

  • Motivation over competition: Show progress ("You moved up 5 ranks this week") rather than absolute position to maintain engagement among lower-ranked students.
  • Skill-based ranking: Use adaptive scoring (Elo, IRT) to ensure rankings reflect genuine understanding rather than time spent practicing.
  • Fair comparison windows: Separate beginner and advanced cohorts into tiered leaderboards to prevent discouragement from comparing against experts.
  • Anti-gaming: Track answer patterns to detect copying, and penalize suspiciously correlated submissions between participants.
  • Granular feedback: Pair rank information with personalized learning recommendations — "You rank #34 but struggle with Dynamic Programming. Try these problems."

27. Leaderboard Anti-Cheat & Integrity

Leaderboard integrity is the foundation of user trust. When players discover that cheaters are polluting the rankings, they disengage — and the entire engagement loop collapses. A comprehensive anti-cheat system combines deterministic validation, statistical anomaly detection, velocity monitoring, and machine learning-based fraud scoring into a layered defense pipeline.

Score Manipulation Detection Pipeline

graph TB subgraph "Score Ingestion" CLIENT[Client Submits Score] API[Score Validation API] end subgraph "Deterministic Checks" BOUNDS[Bounds Validator] IDEMPOTENCY[Idempotency Check] RATE[Rate Limiter] TEMPORAL[Temporal Validator] end subgraph "Statistical Analysis" VELOCITY[Velocity Monitor] ZSCORE[Z-Score Anomaly Detection] CLUSTER[Score Clustering Analyzer] end subgraph "ML Pipeline" FEATURES[Feature Extraction] MODEL[Fraud Detection Model] RISK[Risk Score Calculation] end subgraph "Enforcement" QUEUE{Risk Score ?} ACCEPT[Accept Score] FLAG[Flag for Review] REJECT[Reject & Ban] end CLIENT --> API API --> BOUNDS API --> IDEMPOTENCY API --> RATE API --> TEMPORAL BOUNDS --> VELOCITY IDEMPOTENCY --> VELOCITY RATE --> VELOCITY TEMPORAL --> VELOCITY VELOCITY --> ZSCORE ZSCORE --> CLUSTER CLUSTER --> FEATURES FEATURES --> MODEL MODEL --> RISK RISK --> QUEUE QUEUE -->|Low Risk| ACCEPT QUEUE -->|Medium Risk| FLAG QUEUE -->|High Risk| REJECT

Velocity Checks

Velocity monitoring tracks the rate and pattern of score changes for each player. Sudden, unnatural score jumps — such as a player who averages 500 points per game suddenly scoring 50,000 — are flagged immediately. The system maintains a rolling window of recent scores and computes the mean and standard deviation. A score that exceeds the mean by more than 3 standard deviations triggers an anomaly flag.

More sophisticated velocity checks analyze score trajectories over time. A legitimate player's score follows a gradual improvement curve with occasional setbacks. A cheating player's score graph shows a sharp discontinuity — a sudden plateau at an unusually high level, or a step function jump. The system computes the first derivative (rate of improvement) and flags players whose improvement rate exceeds what is physically possible given the game's scoring mechanics.

Anomaly Detection with Machine Learning

Statistical thresholding catches obvious cheats, but sophisticated players operate just below the detection threshold. Machine learning models trained on historical data from confirmed cheaters and legitimate players can identify subtle patterns that human-designed rules miss. Feature engineering extracts signals such as: average score per session, session duration distribution, score variance over time, time-of-day play patterns, input device fingerprinting consistency, and game-specific behavioral metrics (click patterns, reaction times, decision sequences).

The model outputs a risk score between 0 and 1. Scores below 0.3 are accepted immediately. Scores between 0.3 and 0.7 are flagged for human review. Scores above 0.7 are rejected and the player is placed on a watch list. This tiered approach balances automated efficiency with human judgment for ambiguous cases.

public class AntiCheatIntegrityService
{
    private readonly IConnectionMultiplexer _redis;
    private readonly IFraudDetectionModel _mlModel;
    private readonly ILogger<AntiCheatIntegrityService> _logger;

    private const int VELOCITY_WINDOW_SIZE = 20;
    private const double ZSCORE_THRESHOLD = 3.0;
    private const double ML_REJECT_THRESHOLD = 0.7;
    private const double ML_FLAG_THRESHOLD = 0.3;
    private const int MAX_CONSECUTIVE_HIGH_SCORES = 5;

    public async Task<IntegrityVerdict> EvaluateScoreAsync(
        ScoreEvent scoreEvent)
    {
        var db = _redis.GetDatabase();
        var userKey = $"ac:{scoreEvent.UserId}:recent_scores";
        var statsKey = $"ac:{scoreEvent.UserId}:velocity_stats";

        // 1. Fetch recent scores (last 20 games)
        var recentScores = await db.SortedSetRangeByRankWithScoresAsync(
            userKey, -VELOCITY_WINDOW_SIZE, -1);

        var scoreValues = recentScores
            .Select(e => e.Score).ToList();

        // 2. Compute velocity statistics
        var mean = scoreValues.Any()
            ? scoreValues.Average() : scoreEvent.Score;
        var stdDev = scoreValues.Any()
            ? Math.Sqrt(scoreValues.Average(s =>
                Math.Pow(s - mean, 2))) : 1.0;

        // 3. Z-Score anomaly check
        var zScore = stdDev > 0
            ? (scoreEvent.Score - mean) / stdDev
            : 0;

        if (zScore > ZSCORE_THRESHOLD)
        {
            _logger.LogWarning(
                "Z-Score anomaly for {UserId}: " +
                "score={Score}, z={ZScore:F2}",
                scoreEvent.UserId,
                scoreEvent.Score, zScore);
        }

        // 4. Consecutive high-score check
        var consecutiveHigh = 0;
        for (int i = scoreValues.Count - 1; i >= 0; i--)
        {
            if (scoreValues[i] > mean + 2 * stdDev)
                consecutiveHigh++;
            else
                break;
        }

        // 5. ML feature extraction
        var features = new FraudFeatures
        {
            CurrentScore = scoreEvent.Score,
            RecentMean = mean,
            RecentStdDev = stdDev,
            ZScore = zScore,
            ConsecutiveHighScores = consecutiveHigh,
            SessionDuration = scoreEvent.Metadata?.DurationSeconds ?? 0,
            ScoreVelocity = scoreValues.Count > 1
                ? (scoreEvent.Score - scoreValues.First()) /
                  scoreValues.Count
                : 0,
            TimeSinceLastScore = recentScores.Length > 0
                ? (DateTimeOffset.UtcNow - recentScores.Last().Key)
                    .TotalMinutes
                : double.MaxValue,
            TotalGamesPlayed = await db.SortedSetLengthAsync(userKey)
        };

        // 6. Get ML risk score
        var riskScore = await _mlModel.PredictFraudProbabilityAsync(
            features);

        // 7. Record this score
        var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        await db.SortedSetAddAsync(userKey,
            timestamp, scoreEvent.Score);
        await db.SortedSetRemoveRangeByRankAsync(userKey, 0,
            -VELOCITY_WINDOW_SIZE - 1);
        await db.KeyExpireAsync(userKey,
            TimeSpan.FromDays(30));

        // 8. Determine verdict
        if (riskScore >= ML_REJECT_THRESHOLD)
        {
            _logger.LogWarning(
                "Score REJECTED for {UserId}: " +
                "risk={Risk:F3}, score={Score}",
                scoreEvent.UserId,
                riskScore, scoreEvent.Score);

            // Add to watch list
            await db.SortedSetAddAsync(
                "ac:watch_list",
                scoreEvent.UserId, riskScore);

            return IntegrityVerdict.Rejected(
                $"Risk score {riskScore:F3} exceeds threshold");
        }

        if (riskScore >= ML_FLAG_THRESHOLD)
        {
            return IntegrityVerdict.FlaggedForReview(
                riskScore, "Medium risk — queued for review");
        }

        return IntegrityVerdict.Clean(riskScore);
    }

    public async Task<BatchIntegrityReport>
        RunBatchAnalysisAsync(string boardId)
    {
        var db = _redis.GetDatabase();
        var report = new BatchIntegrityReport
        {
            BoardId = boardId,
            AnalysisTime = DateTime.UtcNow
        };

        // Get all players with recent activity
        var allUserKeys = new List<string>();
        var server = _redis.GetServer(
            _redis.GetEndPoints().First());
        await foreach (var key in server.KeysAsync(
            pattern: "ac:*:recent_scores"))
        {
            allUserKeys.Add(key.ToString());
        }

        foreach (var userKey in allUserKeys)
        {
            var userId = userKey.Split(':')[1];
            var scores = await db.SortedSetRangeByRankWithScoresAsync(
                userKey, 0, -1);

            if (scores.Length < 5) continue;

            var values = scores.Select(s => s.Score).ToArray();
            var mean = values.Average();
            var variance = values.Average(v =>
                Math.Pow(v - mean, 2));

            // Coefficient of variation check
            // (low variance = suspicious)
            var cv = mean > 0
                ? Math.Sqrt(variance) / mean : 0;
            if (cv < 0.05 && values.Length > 10)
            {
                report.SuspiciousUsers.Add(
                    new SuspiciousUser
                {
                    UserId = userId,
                    Reason = "Unnaturally low score variance",
                    CoefficientOfVariation = cv,
                    AverageScore = mean,
                    SampleSize = values.Length
                });
            }
        }

        _logger.LogInformation(
            "Batch integrity scan for {BoardId}: " +
            "{Count} suspicious users flagged",
            boardId, report.SuspiciousUsers.Count);

        return report;
    }
}

public class FraudFeatures
{
    public double CurrentScore { get; set; }
    public double RecentMean { get; set; }
    public double RecentStdDev { get; set; }
    public double ZScore { get; set; }
    public int ConsecutiveHighScores { get; set; }
    public int SessionDuration { get; set; }
    public double ScoreVelocity { get; set; }
    public double TimeSinceLastScore { get; set; }
    public long TotalGamesPlayed { get; set; }
}

public class IntegrityVerdict
{
    public bool IsAccepted { get; set; }
    public bool IsFlagged { get; set; }
    public bool IsRejected { get; set; }
    public double RiskScore { get; set; }
    public string Reason { get; set; }

    public static IntegrityVerdict Clean(double risk) =>
        new() { IsAccepted = true, RiskScore = risk };

    public static IntegrityVerdict FlaggedForReview(
        double risk, string reason) =>
        new() { IsFlagged = true, RiskScore = risk,
                Reason = reason };

    public static IntegrityVerdict Rejected(string reason) =>
        new() { IsRejected = true, RiskScore = 1.0,
                Reason = reason };
}

public class BatchIntegrityReport
{
    public string BoardId { get; set; }
    public DateTime AnalysisTime { get; set; }
    public List<SuspiciousUser> SuspiciousUsers { get; set; }
        = new();
}

public class SuspiciousUser
{
    public string UserId { get; set; }
    public string Reason { get; set; }
    public double CoefficientOfVariation { get; set; }
    public double AverageScore { get; set; }
    public int SampleSize { get; set; }
}

Balancing False Positives and User Experience

The most critical design decision in anti-cheat systems is the false positive rate. Banning a legitimate player is far more damaging than letting a cheater through temporarily. The tiered approach — accept, flag, reject — ensures that ambiguous cases receive human review before punitive action. Additionally, shadow banning (placing cheaters in an isolated leaderboard that only includes other cheaters) allows the system to gather evidence without tipping off the cheater that they have been detected. This reduces the cat-and-mouse dynamic where cheaters adapt specifically to circumvent known detection methods.

Integrity Metrics to Monitor

MetricTargetAlert Threshold
False positive rate< 0.1%> 0.5%
Cheater detection rate> 95%< 85%
Average review time (flagged)< 24 hours> 48 hours
ML model precision> 0.92< 0.85
ML model recall> 0.88< 0.80
Appeal overturn rate< 3%> 10%
Score rejection latency< 100ms> 500ms

28. Conclusion

Designing a real-time leaderboard system is a masterclass in distributed systems engineering. It requires the orchestration of in-memory data structures (Redis sorted sets with skip-list internals), stream processing (Kafka for score ingestion), persistent storage (PostgreSQL for history), multi-layer caching (CDN + application + Redis), and careful attention to consistency, durability, and anti-cheat measures.

The key design decisions are:

  1. Redis Sorted Sets as the primary data structure — providing O(log N) for all critical operations (rank, update, range query).
  2. Kafka-based score ingestion pipeline — decoupling writes from processing, enabling fan-out to analytics and notifications.
  3. Separate sorted sets per time window — trading write amplification for read simplicity and enabling efficient window-specific queries.
  4. Multi-tier caching — CDN for popular boards, in-memory for application-level, Redis for the primary source of truth.
  5. Periodic snapshots — preserving historical leaderboard state for "past results" features and compliance.

Whether you are building a gaming platform, a fitness app, or any system where users compete on a metric, the patterns described in this article provide a solid foundation. The C# implementation above can be adapted to your specific requirements with minimal changes — register your leaderboard configurations, plug in your Redis connection, and you have a working leaderboard service in minutes.

The system scales from a single-board MVP (one Redis instance, one PostgreSQL database) to a global platform (Redis Cluster across regions, Kafka for event streaming, S3 for archival) by progressively adding components as your user base grows. Start simple, measure, and scale where the bottlenecks actually are.

Further Reading

  • Redis documentation: Sorted Sets — redis.io/docs/data-types/sorted-sets
  • Martin Kleppmann — "Designing Data-Intensive Applications" (Chapter 6: partitioning)
  • Apache Kafka documentation — exactly-once semantics
  • Real-world case studies: League of Legends, Fortnite, and Strava engineering blogs

© 2026 Ayodhyya. All rights reserved.
Published on ayodhyya.com