Design a Real-Time Gaming Leaderboard
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | A mobile game shows the top 10 and "your rank" | A competitive platform: 10 modes, weekly seasons, tournaments, spectators | Worldwide: global, regional and friends boards, more players than one board should hold |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Traffic | 1M players; ~500 score submissions/s and ~2K reads/s at peak | 50M players, 10M DAU; 100K submissions/s tournament peak; 50K lobby reads/s | 500M players, 100M DAU in 4 regions; ~500K submissions/s peak |
| Data | One board, ~130 MB in memory | 10 boards, ~48 GB in memory; 100 GB/day of match audit | Exact top tiers plus histograms; ~1 TB/day of match audit |
| Survives | Losing a cache node or an AZ | Losing a node or an AZ during a tournament | Losing a region |
| Targets | 99.9%; reads < 50 ms | 99.99%; reads P99 < 5 ms, writes P99 < 15 ms | 99.99% per region; exact, auditable ranks for prize tiers |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is a Leaderboard?
You Already Know One: the Arcade Scoreboard
Walk up to an old arcade cabinet and the attract screen shows ten names and ten scores. That is a leaderboard: players ranked by score, highest first. Every modern game has one, and it asks the store behind it three things:
| The player does | The system does |
|---|---|
| Finishes a match with a score | Records it, and keeps it only if it beats the player's best |
| Opens the lobby | Shows the top 10 (or top 100) players right now |
| Looks at their own card | Answers "what is my rank?" |
What Makes It Hard
The top 10 is easy. Any database can sort and return ten rows. The hard question is the one every player actually asks: "what is my rank?" A rank means counting everyone above me. For the player in 4,812,331st place, that is 4,812,330 people to count, and their scores keep changing while we count. Ask that question a few thousand times a second and a normal database index can't keep up, because an index knows the order of rows but not how many rows sit before a given one.
The Question the Whole Loop Answers
How do we answer "what is my rank?" exactly and instantly, while scores keep changing?
The answer gets sharper every round:
- Round 1: use a data structure that keeps counts inside its order, a sorted set, and keep a durable copy so memory can be lost.
- Round 2: at esports scale, "exact" gets subtle: ties, season boundaries, cheaters, and 50,000 lobbies asking for the same top 100.
- Round 3: one sorted set per board stops being the right shape. We learn that exact ranks are only needed where money or pride is at stake, and approximate ranks are fine everywhere else.
Round 1 · Mid-level · "One Game's All-Time Leaderboard"
~35 min · SDE II (L5) · 1 region, 3 AZs · 1M players · ~500 submissions/s and ~2K reads/s peak · 99.9% · reads < 50 ms
R1.1 Establish Design Scope
The interviewer says: "Our mobile game needs a leaderboard. Design it." Before we draw anything, we ask questions, and we say out loud what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Is a player's score their best match or the sum of all matches? | Their best single match. | A submission only matters if it beats the player's current best. The store needs "update only if greater" (step 1.3). |
| How many players? | About 1 million have installed the game; a good share play every day. | Small enough for one machine's memory. We derive traffic in R1.7. |
| How fresh must ranks be? | Immediate. A player finishes a match and expects to see their new rank on the results screen. | No batch job that recomputes ranks every few minutes. Rank must be computed on demand. |
| Top how many? | Top 10 in the lobby. And every player sees their own rank. | Two reads: a short top-N list, and a rank lookup for any one player. The second is the hard one. |
| What about ties? | Don't worry about them yet. | We note that ties exist and come back to them. |
| Seasons? | Not yet. One all-time board. | One board, one key. |
| Who sends the score, the phone or our game server? | Matches run on our game servers. | The score can come from a server we control, not from the phone (step 1.4). |
Out of scope for this round:
- Tie-breaking (two players with the same score).
- Seasons, multiple modes, and "players around me".
- Spectators watching the board live.
The interviewer will widen this scope later. Write your out-of-scope list where you can see it: in a multi-round loop, some of it comes back.
R1.2 Functional Requirements, Derived Step by Step
| Phrase from the problem | Requirement |
|---|---|
| "Finishes a match" | submit: record a match result; keep it if it beats the player's best |
| "Shows the top 10" | top: return the N highest players with names and scores |
| "What's my rank?" | rank: return one player's position (1 = best) and score |
Not yet: ties, seasons, players around me, spectators.
R1.3 Non-Functional Requirements: the Questions
Numbers come in R1.7. For now, the questions and why each matters:
- Read latency. The rank shows on the results screen; the target is under 50 ms at our API.
- Write rate. How many submissions a second at the evening peak? That decides whether one machine is enough.
- Exactness. Is "about 4.8 millionth" good enough? For Round 1, no: the rank must be exact.
- Availability. 99.9%. The leaderboard is not the game, but an empty or wrong leaderboard gets noticed fast.
- Durability. If the in-memory board is lost, scores must not be. A player's best score is something they earned.
R1.4 The API
1. Submit a result. Only our game servers call this. The body is the match result the game server produced, and a signature over it (step 1.4 explains why).
httpPOST /v1/games/g1/scores HTTP/1.1 Host: leaderboard.internal.example-games.com Content-Type: application/json X-Result-Signature: hmac-sha256=6f1c9a0e52b7d4... { "match_id": "m_7f3k29", "player_id": "p48213377", "score": 50120, "ended_at": "2026-09-26T19:20:00.000Z", "server_id": "gs-use1-az2-0183" }
httpHTTP/1.1 200 OK Content-Type: application/json { "player_id": "p48213377", "best_score": 50120, "new_best": true, "rank": 4812 }
If the score doesn't beat the player's best, the answer is the same shape with "new_best": false and the old best. Sending the same result twice is harmless: the second time it can't beat itself.
2. Top N.
httpGET /v1/games/g1/leaderboard?top=10 HTTP/1.1 Authorization: Bearer <player token>
httpHTTP/1.1 200 OK Content-Type: application/json { "entries": [ { "rank": 1, "player_id": "p00000912", "name": "Valkyrie", "score": 98450 }, { "rank": 2, "player_id": "p00731144", "name": "IronClad", "score": 97120 } ], "as_of": "2026-09-26T19:20:01.120Z" }
3. One player's rank.
httpGET /v1/games/g1/players/p48213377/rank HTTP/1.1 Authorization: Bearer <player token>
httpHTTP/1.1 200 OK Content-Type: application/json { "player_id": "p48213377", "rank": 4812, "score": 50120, "total_players": 1000000 }
| Status | When |
|---|---|
200 OK | Normal answer |
401 Unauthorized / 403 Forbidden | A score without a valid game-server signature, or a player token that is missing |
404 Not Found | The player has never submitted a score (the app shows "unranked") |
429 Too Many Requests | Over the submission rate limit (with Retry-After) |
Recap
- Three operations: submit (from game servers only), top N, my rank.
- Best score counts; ranks must be exact and immediate.
- Losing memory must not lose scores.
Let's build it, starting with the simplest thing that works.
R1.5 Design Evolution: From a Sorted Table to a Sorted Set
Every step below follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is always the next problem.
Step 1.0: The Baseline
A relational table scores(player_id, best_score) with an index on best_score.
- Top 10:
SELECT player_id, best_score FROM scores ORDER BY best_score DESC LIMIT 10. The index is already in score order, so the database reads ten entries from its end. Fast. - My rank:
SELECT COUNT(*) + 1 FROM scores WHERE best_score > :mine. Correct, and simple.
What it costs us: that COUNT(*) has to walk every index entry above my score. A B-tree index (the usual database index: a sorted tree of keys) knows where my score sits, but its nodes don't record how many entries are below them, so it can't skip ahead. For the top player the count is tiny; for a median player it is ~500,000 entries per request.
Step 1.1: "My Rank" Takes Seconds at 1M Players
The problem: in the evening peak, about 2,000 players a second open their card or finish a match. Each rank query counts hundreds of thousands of index entries. The database's CPU is pinned, queries queue, and the results screen hangs for seconds. What would you do?
Primitive: Distributed Cache Patterns & Eviction
Step 1.2: The Cache Restarted and the Leaderboard Is Empty
The problem: the cache node was replaced during maintenance. When it came back, lb:{g1}:alltime didn't exist. A million players' best scores are gone, and the lobby says "no players yet".
What would you do?
The durable table:
| Attribute | Example | Notes |
|---|---|---|
PK (partition key) | PLAYER#p48213377 | One partition per player: writes spread evenly |
SK (sort key) | BOARD#g1#alltime | Room for more boards later |
best_score | 50120 | A Number attribute |
match_id, ended_at | m_7f3k29, 2026-09-26T19:20:00.000Z | Which match set the best |
Step 1.3: Only the Best Score Counts
The problem: a player finishes two matches a second apart, scoring 50,120 and then 31,000. Two API servers handle them at the same time. The board ends up showing 31,000. What would you do?
Step 1.4: Clients Send Fake Scores
The problem: a week after launch, rank 1 is a player with 999,999,999 points. Someone decompiled the app, found the submit call, and sent their own number. What would you do?
Primitive: Distributed Rate Limiting · Loop: Design a Distributed Rate Limiter
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | SQL table, index on score; COUNT(*) for rank | Rank cost grows with position |
| 1.1 | "My rank" takes seconds | Sorted set: hash map + skip list with spans; O(log N) rank | Everything in memory |
| 1.2 | Cache lost, board empty | DynamoDB as truth; rebuild job + ready marker; replica in another AZ | A dual write |
| 1.3 | Only the best counts | Conditional UpdateItem + ZADD GT: monotonic, idempotent | Nothing atomic across stores |
| 1.4 | Fake scores | Game server signs results (HMAC); rate limits; max-score check | A server authority; a key to rotate |
R1.6 Architecture v1
Synthesizing vector architecture diagram...
Writes go durable-first: the conditional update in DynamoDB, then ZADD GT in the cache. Reads touch only the cache (plus names). If the cache is lost, the rebuild job refills it from DynamoDB and sets the ready marker last.
Key layout
| Key | Type | Holds |
|---|---|---|
lb:{g1}:alltime | Sorted set | member = player ID, score = best score |
lb:{g1}:alltime:ready | String | Rebuild version; present only when the board is complete |
The {g1} in braces is a hash tag: in cluster mode, only the part in braces decides which shard a key lives on, so all of one game's keys stay together. We don't need cluster mode yet, but the naming costs nothing now.
Trace 1: a score submission
Synthesizing vector architecture diagram...
Trace 2: a rank read
textGET /players/p48213377/rank → ZREVRANK lb:{g1}:alltime p48213377 → 4811 (O(log N), well under 1 ms in the engine) → ZSCORE lb:{g1}:alltime p48213377 → 50120 → 200 { rank: 4812, score: 50120 }
R1.7 Numbers
Traffic (assumptions: 40% of players play on a given day; 20 matches each; peak hour is 5× the daily average; each match leads to about 4 leaderboard reads: the results screen plus lobby visits)
| Quantity | Math | Value |
|---|---|---|
| Daily players | 1M × 40% | 400K |
| Submissions per day | 400K × 20 | 8M |
| Submissions/s, average | 8,000,000 ÷ 86,400 | ≈ 93 |
| Submissions/s, peak | 93 × 5 | ≈ 463, plan for 500 |
| Reads/s, peak | 463 × 4 | ≈ 1,850, plan for 2,000 |
Memory. The per-member size of a large sorted set is an estimate: roughly a hash-table entry, a skip-list node with its links, the member string, and allocator rounding. We use ~96 bytes per member for planning (Round 2 checks it bottom-up and gets about 100–115 bytes).
The smallest memory-optimized node, cache.r7g.large, has 13.07 GiB. We use about 1% of it. We still take two of them (primary + replica in another AZ), because the replica is what makes a failover take seconds instead of a rebuild.
Operations per second on the cache: about 500 writes + 2,000 reads (each read is one or two O(log N) commands) ≈ 4,500 commands/s at peak. A single node handles on the order of 100K such commands a second (an assumption we confirm with a load test), so we have 20× headroom.
Rebuild time. 1M items of ~100 bytes is ~100 MB. A parallel Scan reads that in seconds, and adding 1M members in pipelined batches takes a few seconds more (at ≥100K adds a second). A full rebuild is under a minute.
Availability budget. 99.9% over a 30.4-day month is minutes.
Monthly cost (us-east-1 on-demand list prices, 730 hours a month; rounded)
| Item | Math | Monthly |
|---|---|---|
ElastiCache (Valkey), 2 × cache.r7g.large | 2 × $0.1752/h × 730 h | ≈ $256 |
| DynamoDB writes | 8M/day × 30.4 = 243M conditional updates × 1 write unit (a failed condition still consumes the write) × $0.625 per million | ≈ $152 |
| DynamoDB reads and storage | names for top-10 (cached in the API for a few seconds), rebuild scans; ~200 MB stored | ≈ $10 |
| ECS on Fargate (Graviton) | 3 tasks × (1 vCPU, 2 GB) ≈ $0.0395/h each × 730 h | ≈ $87 |
| ALB, CloudWatch, Secrets Manager | ≈ $70 | |
| Total | ≈ $575/month |
Say the headline in the interview: the whole leaderboard for a million players fits in 130 MB and costs about $575 a month. The sorted set turned a database problem into a memory problem, and at this scale memory is cheap.
R1.8 Trade-Offs
| SQL + index (baseline) | DynamoDB index | Sorted set (our choice) | |
|---|---|---|---|
| Top N | Fast: read N entries from the index end | Fast: a Query on an index keyed by board, sorted by score | Fast: O(log N + N) |
| "My rank" | Counts every entry above you: cost grows with your position | No rank at all: you'd count with paginated Queries (1 MB a page), same problem, billed per item read | O(log N), from the spans in the skip list |
| Update cost | A B-tree update on disk | A write, plus an index write; one board key is one hot index partition (about 1,000 writes/s) | O(log N) in memory |
| Durability | Durable | Durable | Memory only: needs a durable copy |
| Where it breaks | Rank reads at a few thousand a second | Rank reads; one-key write limit | Memory, and one engine thread per board (Round 3) |
Two notes on the DynamoDB column. The index's sort key must be a Number: stored as strings, scores sort as text, so "9000" lands above "10000". And there's still no rank: an index answers "who is next", not "how many are before me".
In memory vs durable. We keep both, on purpose: the durable store answers "what is true", the sorted set answers "what is my rank" fast. We never ask the cache to be the only copy of anything.
R1.9 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| Cache primary fails | A few seconds of errors | ElastiCache promotes the replica in another AZ. Replication is asynchronous, so the last few writes acknowledged by the old primary may be missing from the new one. Those players' new bests are in DynamoDB; the nightly reconcile job (below) or their next submission restores them. |
| Both cache nodes lost | Empty board | Readers see no ready marker: top 10 is served from DynamoDB, ranks show "updating". The rebuild job refills the board in under a minute and writes the marker. |
| Dual-write mismatch (crash between DynamoDB and the cache) | One player's rank lags their real best | A reconcile job compares DynamoDB with the board (a paged Scan, ZSCORE for each player) and re-applies ZADD GT where the cache is lower. It is safe to run any time, because ZADD GT can't lower a score. |
| AZ lost | A third of API tasks gone; maybe the primary | Tasks run in three AZs behind the ALB; the replica is promoted; DynamoDB is multi-AZ. |
| Signing key leaked | Improbable scores from one server ID | Rotate the key in Secrets Manager (both keys valid for a short overlap), rate limits cap the damage, and suspicious scores are removed by re-running the reconcile against corrected DynamoDB items. |
Drill: CDC outbox dual-write drift
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | DynamoDB is the source of truth; the board is rebuildable with a ready marker; a replica in another AZ for fast failover; monotonic, idempotent writes so retries converge REL 9 · REL 11 |
| Performance Efficiency | The data structure fits the question: O(log N) rank instead of counting; reads never touch the database PERF 1 · PERF 3 |
| Security | Only game servers submit; results are HMAC-signed with a key only the game servers and the API can read; per-player and per-server rate limits SEC 2 · SEC 3 |
| Cost Optimization | About $575/month; the smallest memory node is already 100× the data COST 6 |
| Operational Excellence | Light this round: alarms on API P99 latency, cache memory and the reconcile job's mismatch count OPS 8 |
| Sustainability | Skipped this round. |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Starts from SQL, and explains why
COUNT(*)for rank gets slower as the table grows (an index has order but no counts). - Names the sorted set and knows what's inside: a hash map for scores and a skip list whose spans make rank O(log N).
- Keeps a durable copy and treats the in-memory board as rebuildable.
- Makes "best score only" a conditional write in each store, and knows the two stores aren't atomic together.
- Refuses client-submitted scores.
- Derives traffic, memory and cost, and sees that the problem is small at this scale.
Follow-up questions
-
"Why not rebuild the board from DynamoDB on every restart and skip the replica?" Answer: we could at 1M players (under a minute). But every restart would show "rank updating" for that minute, and every failover would become a rebuild. The replica costs $128 a month and turns most failures into a few seconds. In Round 2, with 50M members per board (500M across ten), rebuilds take much longer, and the replica stops being optional.
-
"A player asks for rank 500,000 to 500,010. Is a deep page expensive?" Answer: no. A rank range costs O(log N + count): the skip list jumps to position 500,000 using the spans, then walks 10 members. What is expensive is a huge range (say 50,000 members in one call): the engine is single-threaded, so one big reply delays every other command. We cap
topat 100 per call. -
"Two players have the same score. Who ranks higher?" Answer: today, whatever the engine does: equal scores are ordered by member (the player ID) as a byte string, and in reversed ranges the order flips too. That's deterministic but meaningless to players. It's fine for Round 1; Round 2 makes "who scored first" the rule.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
"COUNT(*) WHERE score > mine is fine with an index" | An index has no counts; the query walks every entry above you. |
| "Cache every player's rank" | One improvement changes the rank of everyone it passes. |
| "Read, compare, then write the best score" | Two concurrent requests both pass the check; the lower one can land last. |
| "The phone submits its score" | Anything the app can send, a modified app can send. |
| "The cache is the leaderboard" | Memory is the one place we can lose it; keep a durable copy. |
Round 2 · Senior · "Esports Scale: Modes, Seasons, Ties and Cheaters"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 50M players · 100K submissions/s and 50K lobby reads/s peak · 99.99% · reads P99 < 5 ms, writes P99 < 15 ms
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built an all-time leaderboard for one mobile game with a million players: about 500 score submissions and 2,000 reads a second at peak, one region, three AZs, 99.9%. A database can sort, but it can't cheaply answer 'what is my rank?', because an index has order but no counts. So the board is a sorted set in ElastiCache: a hash map for scores plus a skip list whose spans give rank in O(log N). DynamoDB holds each player's best score and is the source of truth; the board is rebuildable, and a
readymarker says when a rebuild is complete. Both stores update only if the new score is greater: a conditionalUpdateItemandZADD GT, written durable-first; they're monotonic and idempotent, so retries converge. Only our game servers submit, with HMAC-signed results. It's about 130 MB of memory and $575 a month. Three things are still open: ties are ordered by player ID, there's one board with no seasons, and a cheat that plays through a real match isn't caught."
Architecture v1, compact
textgame server ──signed result──► API ──1. UpdateItem IF best < new──► DynamoDB Scores (truth) └─2. ZADD GT──► ElastiCache lb:{g1}:alltime ──► replica (other AZ) app ──► API ──► ZREVRANK / ZRANGE REV (top 10) ──► names from Profiles rebuild job: Scan DynamoDB ──► ZADD in batches ──► set :ready
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | "My rank" takes seconds | Sorted set (hash map + skip list with spans) | Everything in memory |
| 1.2 | Cache lost | DynamoDB as truth; rebuild with a ready marker; replica | A dual write |
| 1.3 | Only the best counts | Conditional update + ZADD GT | Nothing atomic across stores |
| 1.4 | Fake scores | Signed results from game servers; rate limits | A server authority |
Open costs: ties, seasons, cheats inside real matches, and one small board.
R2.1 The Scope Raise
Interviewer: "The game became an esports title. We have 50 million players, 10 million a day, across 10 game modes. Seasons are weekly and roll over at midnight UTC on Monday. When two players tie, whoever got the score first must rank higher; prize money depends on it. Players want to see the five players above and below them. During tournaments, submissions spike to 100,000 a second, and spectators want the top 100 live. Organized cheating has started. Reads must be under 5 ms at P99, writes under 15 ms, and we want 99.99%."
A scope raise is not the end of scoping. Before we fix anything, we ask back, and we say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| What's the highest possible score, and how precise must a tie-break be? | Scores go up to 100 million. "First" should be decided to a tenth of a second or better. | We must fit score and time into one sorted-set score without losing precision (step 2.1). |
| When exactly does a score belong to a season: when the match ended, or when we received it? | When the match ended. A match that ends at 23:59:58 counts for the old season even if it arrives after midnight. | The season comes from a signed end time, not from our clock or a global switch (step 2.3). |
| How do lobbies show the top 100? | The lobby screen refreshes it every second. At peak, about 50,000 lobby screens are open and polling. | 50,000 identical reads a second: a caching problem, not a data-structure problem (step 2.4). |
| What does a spectator need? | A live top 100 during finals, with changes within a second or two. | Push, not poll, for spectators (step 2.4). |
| What does cheating look like now? | Modified clients and bots that play real matches on our servers, and accounts that suddenly score 10× their history. | Signatures can't catch these. We need plausibility checks, a quarantine and an audit trail (step 2.5). |
| Is a short delay acceptable before a suspicious top score shows? | Yes, for scores that would enter the top 1,000. Not for everyone else. | A quarantine only at the top (step 2.5). |
| How long do we keep match history? | Five years, for disputes and anti-cheat analysis. | An audit lake: 100 GB a day (R2.6). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Players | 1M | 50M registered, 10M DAU |
| Boards | 1 all-time board | 10 modes × weekly season |
| Submissions | ~500/s peak | 500M/day; ≈ 5,787/s average; 100K/s tournament peak |
| Reads | ~2K/s | 50K/s lobby top-100 polls at peak; plus rank and window reads |
| Ties | Not handled | Earlier score wins, to 100 ms or better |
| Features | Top N, my rank | + players around me, season rollover, live spectator top 100, cheat resistance |
| Availability | 99.9% (43.8 min/month) | 99.99% (4.4 min/month) |
| Latency | < 50 ms | Reads P99 < 5 ms; writes P99 < 15 ms |
| Data | ~130 MB | ~48 GB of boards; 100 GB/day of audit, ≈ 182.6 TB over 5 years |
The "Not yet" list from R1.2 is now mandatory: ties, seasons, players around me, spectators.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| Score = raw points | Equal scores are ordered by player ID as a byte string. Player p99 beats player p10 in a tie just because of their names. |
| One all-time key | Weekly seasons need a reset every Monday while matches are still finishing. Deleting and refilling one key races with in-flight scores. |
| Every lobby read hits the API and the cache | 50,000 identical top-100 reads a second, each ~15 KB with names and avatars: about 6 Gbps of responses for the same answer. |
| Signed results are trusted | A bot playing real matches on our server produces perfectly signed results. |
| One small node | 10 boards of up to 50M players each is ~48 GB, and 100K submissions/s at peak all land on one engine thread. |
| Write churn ignored | Constant updates free and reallocate skip-list nodes of different sizes; memory fragments, and the process uses more RAM than its data. |
R2.3 New Requirements and API Additions
Boards are now named by mode and season. m03 is a mode; 2026-w39 is ISO week 39 of 2026 (Monday 2026-09-21 00:00 UTC to Monday 2026-09-28 00:00 UTC).
Submit gains a season-proof end time and a short-lived signature; the season is not in the request, because the server derives it from ended_at:
httpPOST /v2/games/g1/modes/m03/scores HTTP/1.1 Content-Type: application/json X-Result-Signature: kid=gs-2026-09;hmac-sha256=9b04e1... { "match_id": "m_9x2Qa1", "player_id": "p48213377", "score": 50120, "ended_at": "2026-09-21T00:20:00.000Z", "match_duration_s": 1140, "server_id": "gs-use1-az2-0183", "signed_at": "2026-09-21T00:20:00.410Z" }
httpHTTP/1.1 200 OK Content-Type: application/json { "season": "2026-w39", "new_best": true, "best_score": 50120, "rank": 42, "status": "RANKED" }
status is RANKED or PENDING_REVIEW (step 2.5). kid names the signing key, so keys can rotate.
Players around me:
httpGET /v2/games/g1/modes/m03/seasons/current/players/p48213377/around?radius=5 HTTP/1.1 Authorization: Bearer <player token>
httpHTTP/1.1 200 OK Content-Type: application/json { "season": "2026-w39", "me": { "rank": 42, "score": 50120 }, "entries": [ { "rank": 37, "player_id": "p00990012", "name": "Kestrel", "score": 50410 }, { "rank": 42, "player_id": "p48213377", "name": "Nova", "score": 50120 }, { "rank": 47, "player_id": "p31000456", "name": "Moth", "score": 49980 } ] }
(entries holds all 11 players; three are shown.) radius is capped at 10.
Top 100, cacheable. The same response for every viewer, with no personal data in it:
httpGET /v2/games/g1/modes/m03/seasons/current/top?limit=100 HTTP/1.1
httpHTTP/1.1 200 OK Content-Type: application/json Cache-Control: public, max-age=2, stale-while-revalidate=2 { "season": "2026-w39", "version": 1790197201500, "entries": [ { "rank": 1, "player_id": "p00000912", "name": "Valkyrie", "score": 98450 } ] }
Spectator stream over WebSocket: wss://live.example-games.com/v2/boards/g1/m03/current. The first frame is a full top 100; later frames are diffs, each with a version, at most one per second.
json{ "type": "diff", "board": "g1:m03:2026-w39", "version": 1790197203000, "changes": [ { "rank": 1, "player_id": "p77120003", "name": "Orca", "score": 99500 } ] }
R2.4 Design Evolution: Ties, Windows, Seasons, Crowds and Cheaters
Step 2.1: Two Players Tie; Whoever Scored First Must Rank Higher
The problem: in the week's final, two players both reach 50,120. Player A's match ended at 00:20:00.000, player B's at 00:20:00.350. Prize money goes to A. Our board orders equal scores by player ID and puts B first. What would you do?
Step 2.2: "Show Me the Players Around Me"
The problem: a player at rank 4,812,331 wants to see the five players above and five below. The board has 50M members. What would you do?
Step 2.3: Season 39 Ends at Midnight, and Scores Are in Flight
The problem: at 00:00:00 UTC on Monday, week 39 ends and week 40 begins. About 20,000 matches are still finishing. The old plan: at midnight, stop writes, copy the board, clear the key, start again. What would you do?
Synthesizing vector architecture diagram...
The write path never reads the pointer: the signed end time alone decides the board, so every API task agrees. The pointer only tells lobbies which season to display.
Step 2.4: 50,000 Lobbies Poll the Top 100 Every Second
The problem: at peak, 50,000 lobby screens each ask for the top 100 once a second. Every response is the same ~15 KB of JSON: MB/s, about 6 Gbps of identical bytes. Spectators in the final want every change within a second or two. What would you do?
Primitive: WebSocket, SSE & Long Polling · Drill: Caching hot product page · Drill: WebSocket reconnect thundering herd
Step 2.5: A Cheater Jumps to Rank 1
The problem: an account created last Tuesday posts 99,500 in a 4-minute match, beating the week's best by 1,000 points. The result is correctly signed: the match really ran on our server, with a bot playing. Spectators see a new rank 1, and prize money is at stake. What would you do?
Primitive: Bot Defense, Sybil Resistance & Registration Abuse · Primitive: Message Queues vs Event Streams
Step 2.6: Memory Churn and One Thread Under 100K Submissions a Second
The problem: the carried-over plan puts all 10 boards (~48 GB) on one primary with two replicas. During the tournament peak, 100,000 submissions a second each ask that one primary for a rank, and the engine's fragmentation ratio (process memory ÷ data memory) climbs to 1.6 as skip-list nodes are freed and reallocated. What would you do?
Primitive: Database Sharding & Partition Keys · Primitive: Circuit Breaker, Bulkhead & Fault-Tolerance Patterns
Go deeper: closing the dual-write gap with the table's change stream. Round 1 wrote DynamoDB, then the cache. At 100K submissions a second, a task crashing between the two writes is no longer rare. And a retry can't fix it: the retry's conditional update fails ("not greater", because the first attempt already stored it), so the API would skip the ZADD. Two fixes, used together:
- On a failed condition, the API asks DynamoDB to return the stored item (
ReturnValuesOnConditionCheckFailure = ALL_OLD). If the stored best came from thismatch_id, it's our own earlier attempt, and the API re-appliesZADD GT. - DynamoDB Streams on the
Scorestable records every change to an item exactly once, in order per item, for 24 hours. A Lambda function reads it and re-appliesZADD GTfor every newbest. Lambda may process a batch more than once, which is harmless:ZADD GTwith the same value is a no-op. The direct write is the fast path; the stream is the guarantee.
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Ties go to player ID | Integer tie-break: , in 100 ms since season start; proven < | Encode/decode; 100 ms resolution |
| 2.2 | Players around me | Rank, then range ; hydrate from a profile cache (fills with SET NX) | Two dependent calls |
| 2.3 | Rollover races | Per-season keys; season from signed ended_at; 15-min grace; read-only pointer; archive then UNLINK | Brief double memory |
| 2.4 | 50K identical polls; spectators | CloudFront, 2 s TTL + stale-while-revalidate; two versioned publishers + WebSocket gateways | ≤ 2–4 s lobby staleness; CDN bill |
| 2.5 | Signed but cheating | Plausibility checks; quarantine for top-1,000 entries; Kinesis → Firehose → S3 audit; review workers | Delay for top scores; false positives |
| 2.6 | One thread, fragmentation | 6 shards by mode, 2 replicas each; ZCOUNT rank on replicas; half-full plan; active defrag; change-stream repair | More nodes |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Three paths. Submissions: the API checks the signature and plausibility, writes DynamoDB and the audit stream in parallel, then updates the board; a change-stream Lambda repairs any board write a crash skipped. Reads: rank and window from replicas; the top 100 from CloudFront, which asks us a few times a second. Spectators: two publishers diff the top 100 and gateways push the newest version.
Trace 1: a tied score
textA: ended 00:20:00.000 → t = 12,000 → 50,120 × 10^7 + 9,987,999 = 501,209,987,999 UpdateItem Scores(p_A, m03#2026-w39) SET best = 501209987999 IF best < 501209987999 → OK ZADD lb:{g1:m03}:2026-w39 GT 501209987999 p_A B: ended 00:20:00.350 → t = 12,003 → 501,209,987,996 same steps → OK; B's value is 3 lower ZRANGE … REV 0 1 → [p_A, p_B]; decoded scores both 50,120; A is rank 1
Trace 2: the Monday rollover
text23:59:58.2 match ends; signed ended_at = 2026-09-27T23:59:58.200Z 00:00:03 result arrives → week(ended_at) = 2026-w39 → ZADD lb:{g1:m03}:2026-w39 (grace until 00:15) 00:00:05 another match ends at 00:00:04 → 2026-w40 → first ZADD creates lb:{g1:m03}:2026-w40 00:00:30 current_season pointer → 2026-w40 (lobbies switch as each task polls; writes unaffected) 00:15:00 2026-w39 frozen; late results → review queue 00:15 board writers now drop any write for w39 (freeze check); DynamoDB still records late results 00:15–02:00 archive job waits for w39's quarantine to empty, pages w39 from a replica → S3; count == ZCARD; top 1,000 → DynamoDB; UNLINK
Trace 3: a cheater quarantined
textresult: p_new, 99,500 in 240 s; account age 6 days; points/min above the mode's ceiling cached 1,000th-place value: 71,300 → this would enter the top 1,000 → PENDING UpdateItem SET pending_best = … ; ZADD lbq:{g1:m03}:2026-w39 … (public board untouched) response: { status: "PENDING_REVIEW" } review worker (from Kinesis): replays telemetry → reject → flag account; re-check its other results
Tables and keys
| Store | Key | Holds |
|---|---|---|
DynamoDB Scores | PK = PLAYER#<id>, SK = BOARD#g1#m03#2026-w39 | best (combined Number), pending_best, match_id, ended_at |
DynamoDB SeasonArchive | PK = BOARD#g1#m03, SK = SEASON#2026-w39 | top 1,000, total players, S3 location of the full standings |
| ElastiCache | lb:{g1:m03}:<season> | Public board (sorted set) |
| ElastiCache | lbq:{g1:m03}:<season> | Quarantine (sorted set) |
| Profile cache | profile:<player_id> | name, avatar, country; 15-min expiry |
| Parameter Store | /leaderboard/g1/current_season | e.g. 2026-w39; reads only |
R2.6 Numbers and Cost
Traffic (the current track's figures, re-derived)
| Quantity | Math | Value |
|---|---|---|
| Submissions per day | 10M DAU × 50 matches | 500M |
| Submissions/s, average | 500,000,000 ÷ 86,400 | ≈ 5,787 |
| Submissions/s, tournament peak | given (≈ 17× the average) | 100,000 |
| New bests reaching the board, peak | assume 20% of submissions | 20,000 ZADD/s |
| Lobby top-100 polls, peak | given | 50,000/s |
Board memory. The track's estimate, and a check:
Every replica holds a full copy, so 64.8 GB is per node, not a total. The ~96 B is an estimate. A bottom-up count for the default large-set layout: a hash-table entry (24 B, rounded up by the allocator to ~32), a skip-list node (score, member pointer, back pointer and on average ~1.33 levels of 16 B, rounded up: ~53), the member string p48213377 with its header (~16), and hash-bucket slots (~8–16). That's roughly 100–115 B, depending on engine version. At 113 B the ten boards would be ~56.5 GB. We measure MEMORY USAGE on a sampled board before buying nodes. The 50M per board is also an upper bound: it assumes every registered player plays every mode every week.
Sizing, carried plan vs our plan. ElastiCache reserves 25% of a node's memory for non-data use by default; the engine's memory limit is the other 75%.
One primary + 2 replicas, cache.r7g.4xlarge | Our plan: 6 shards × 3 nodes, cache.r7g.xlarge | |
|---|---|---|
| Node memory | 105.81 GiB → limit ≈ 79.4 GiB | 26.32 GiB → limit ≈ 19.7 GiB |
| Data per primary | 48 GB ≈ 44.7 GiB = 56% of the limit | two boards: 9.6 GB ≈ 8.9 GiB = 45% (the tournament shard, one board: 23%) |
| Write threads | 1 | 6 |
| Monthly (Valkey list price) | 3 × $1.396/h × 730 ≈ $3,060 | 18 × $0.3496/h × 730 ≈ $4,590 |
Both fit. We pay about $1,530 more a month for six write threads, failovers and rebuilds of at most two boards each, and parallel rebuilds.
Why old boards must go within hours: if week 39 stayed a full week, each shard would briefly hold two full weeks: 19.2 GB ≈ 17.9 GiB, 91% of the limit. Deleting within two hours keeps the overlap to a few percent.
Network
| Flow | Math | Value |
|---|---|---|
| Ingress, submissions at peak | 100,000 × 500 B (signed result) | 50 MB/s ≈ 400 Mbps |
| Top-100 egress at peak, no edge cache | 50,000 × 15 KB | 750 MB/s ≈ 6 Gbps |
| Top-100 origin egress with CloudFront | ≤ 75 req/s × 15 KB (step 2.4) | ≈ 1.1 MB/s ≈ 9 Mbps |
| Board ops at the tournament peak | The tournament hash tag pins its board to one shard: ~20K ZADD/s on that primary; ~100K ZCOUNT + window reads/s on its 2 replicas | ~50K reads/s per replica on that shard; the other shards are far quieter |
Latency budgets (P99, in-region; the DynamoDB and Kinesis figures are assumptions we confirm under load)
| Write path | ms | Read path (around me) | ms |
|---|---|---|---|
| Signature + plausibility | 0.5 | Auth, parse | 1 |
| DynamoDB update ∥ Kinesis put: max(8, 10) | 10 | ZREVRANK | 1 |
ZADD GT (primary) ∥ ZCOUNT (replica), in parallel | 1 | ZRANGE REV | 1 |
| API overhead | 2 | Profile hydration | 1 |
| Total | 13.5 < 15 | Total | 4 < 5 |
The DynamoDB write and the Kinesis put don't depend on each other, so they run in parallel and the step costs the slower of the two, not the sum. The read path's steps do depend on each other, so they add.
50K ZCOUNTs a second per replica on a 50M-member set is within what we expect of one engine thread, but it is a load-test question; each extra replica for the tournament shard would add ≈ $255 a month. And the Kinesis P99 of 10 ms is our assumption, not a published figure: if Kinesis P99 exceeds ~12 ms, the API writes the audit record asynchronously after the response, at least once from a local buffer with retries (the lake deduplicates by match_id), and we alarm on buffer drops, since a crash can lose a buffered record that the game server won't resend.
Audit storage
Availability budget. 99.99% over a 30.4-day month is minutes.
Monthly cost (us-east-1 on-demand list prices; rounded)
| Item | Math | Monthly |
|---|---|---|
| CloudFront, top-100 polls | assume the average is 20% of peak: 10K req/s × 2.63M s = 26.3B requests × $0.01 per 10,000 ≈ $26.3K; 10K × 15 KB × 2.63M s ≈ 394 TB out: 10 TB at $0.085, 40 TB at $0.080, 100 TB at $0.060, 244 TB at $0.040 ≈ $19.8K | ≈ $46K |
| DynamoDB writes | 500M/day × 30.4 = 15.2B conditional updates × $0.625 per million | ≈ $9.5K |
| ElastiCache boards | 18 × cache.r7g.xlarge (above) | ≈ $4.6K |
| Compute | API ~40 tasks (2 vCPU, 4 GB) on average, scaling out for tournaments; publishers, gateways, review workers | ≈ $3K |
| Kinesis + Firehose | Firehose bills each record in 5 KB increments: 500M/day × 5 KB × 30.4 ≈ 76 TB × $0.029/GB ≈ $2.2K, plus Parquet conversion (~$0.1K); ~50 Kinesis shards for the 50 MB/s peak ≈ $0.55K, 7-day retention ≈ $0.73K, PUT units ≈ $0.2K | ≈ $3.8K |
| Spectator push | assume 200K viewers × 1 KB/s during 20 event-hours: ~14.4 TB out through the ALB at $0.09/GB | ≈ $1.3K |
| Profile cache, DynamoDB reads and storage, Lambda | 2 × cache.r7g.large; misses; ~300 GB of recent seasons | ≈ $0.7K |
| S3 audit lake | a year in: ~36.5 TB, 90 days in Standard, the rest in Glacier Instant Retrieval; ≈ $0.9K by year five | ≈ $0.3K |
| ALB, CloudWatch, misc. | ≈ $2K | |
| Total | ≈ $71K/month |
Say the headline: serving the same top 100 to 50,000 lobbies a second is about two-thirds of the bill. The leaderboard's memory is $4.6K. The levers are on the lobby side (R2.7).
R2.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Float with a fraction vs integer composite | Integer composite, 100 ms time units, proven below | Encode/decode everywhere; ties inside 100 ms fall to player-ID order. A fraction wastes precision and doesn't round-trip exactly. |
| Integer packing (64-bit) | Only in stores with true integers (DynamoDB Number, SQL BIGINT) | Can't be a sorted-set score; mixing two encodings means one conversion to test |
| Edge-cache TTL | 2 s + 2 s stale-while-revalidate | Lobbies up to ~4 s stale. 1 s halves staleness and doubles origin requests (still tiny); 10 s would cut the CDN request bill only if the app also polled less often. The real cost lever is the poll interval and a compressed response. |
| Quarantine vs instant display | Quarantine only for would-be top-1,000 scores from untrusted players or failed checks | A breakout player waits; everyone else is instant |
| One big node vs 6 shards | 6 shards by mode | $1,530/month more; a cluster-aware client |
| Direct board write vs stream only | Both: direct for speed, change stream as the guarantee | Every new best is applied twice (idempotently) |
R2.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| Primary fails during a tournament | A few seconds of write errors on one shard | ElastiCache promotes a replica. Writes the old primary acknowledged but hadn't replicated are missing, and the repair Lambda had already processed them, so it won't resend. We re-apply: a small job takes the players seen in the last 5 minutes of the Kinesis match stream, reads each one's current best (never pending_best) from DynamoDB, and re-applies ZADD GT with that value. We don't replay the stream's scores directly: it carries every result, including pending and rejected ones, so a direct replay would publish quarantined scores. Re-applying a maximum is always safe. |
| An AZ is lost | A third of API tasks; some shards' primaries | Each shard has a replica in each of the other AZs; tasks in three AZs with headroom to carry peak on two. |
| The season pointer flips early | Lobbies show an empty new season | Writes are unaffected (they use ended_at); readers ignore a pointer whose season hasn't started; fix the parameter. |
| The audit stream lags | Kinesis iterator age climbs; reviews slow | Boards are unaffected; quarantined players wait longer. Records are kept (we set 7 days of retention), so nothing is lost; add review workers. |
| Kinesis put fails at submit | Submit returns 503 | The game server retries the same signed result; every step is idempotent (the conditional update, ZADD GT, and the lake deduplicates by match_id). |
| Fragmentation spike | Process memory well above data memory | Headroom absorbs it; active defrag reclaims it; alarm at a ratio of 1.5 and at 70% memory. |
R2.9 Production Gotchas
| Gotcha | Symptom | Cause | Fix |
|---|---|---|---|
| Unix-ms tie-break in the score | Players minutes apart show as tied | A double's 53 bits can't hold score digits plus 13 timestamp digits | Season-relative integer time, with a proven budget |
| Resetting one key at rollover | Engine freezes for seconds at midnight; some scores land in the wrong week | Synchronous DEL of a huge key (Redis OSS, Valkey 7.2); a global switch seen at different times | Per-season keys; season from signed ended_at; UNLINK after archiving |
| Clients submitting scores | Impossible scores at rank 1 | The app can be modified | Game servers submit signed results |
KEYS * in production | Every command stalls | KEYS walks the whole keyspace in one blocking call | Keep a board registry in DynamoDB; use SCAN for ad-hoc inspection |
| Unbounded ranges | P99 spikes for everyone | A 50,000-member ZRANGE blocks the single thread while it builds the reply | Cap pages at 100; send people to "around me" |
| Profile JSON as the member | Memory several times the plan; renames break ranks | The member is the whole profile | Member = player ID; hydrate names separately |
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | Per-season keys with no reset; six shards with replicas in every AZ; change-stream repair and a re-apply of stored bests after failover, both safe because every write is a maximum; the edge cache shields the origin REL 5 · REL 10 · REL 11 |
| Performance Efficiency | Tie-break encoded in one sortable number; ZCOUNT rank on replicas; latency budgets with parallel steps costed as max; CloudFront for identical reads; push for spectators PERF 1 · PERF 3 · PERF 4 |
| Security | Signed results with named, rotatable keys; plausibility checks; quarantine for the prize zone; every match in the audit lake for investigation SEC 2 · SEC 4 |
| Cost Optimization | ≈ $71K/month, derived; the lobby top-100 is two-thirds of it; memory sized from a measured per-member cost; old boards deleted within hours COST 5 · COST 8 |
| Operational Excellence | Alarms on write and read P99, fragmentation ratio, memory, replication lag, quarantine backlog and audit-stream age OPS 8 |
| Sustainability | Light this round: one cached response serves 50,000 viewers; Graviton nodes; API tasks scale down between tournaments SUS 2 |
R2.11 Round 2 Rubric and Follow-Ups
What a senior (L6) answer adds over L5
- Treats the score as an encoding problem: proves the tie-break fits in , states the resolution, and knows how equal scores are ordered by default (and that reverse ranges reverse it).
- Makes the season a property of the match (signed end time), not of a clock or a switch.
- Separates "the same answer for everyone" (edge cache) from "a personal answer" (rank), and knows what the CDN does and doesn't remove.
- Designs anti-cheat as layers with honest limits, and puts the strictest path where the money is.
- Checks the carried memory estimate, sizes with reserved memory and headroom, and spreads boards for write threads, not just for memory.
- Uses idempotent, monotonic writes so repairs and replays are always safe.
Follow-up questions
-
"Most submissions aren't new bests. Can we avoid paying for a DynamoDB write on each?" Answer: yes: read the stored best first (an eventually consistent read costs a tenth of a write) and only send the conditional update if the result looks higher. The read is a filter, not the guard: two racing requests can both pass it, and the conditional update still decides. At 80% non-improving results, that's roughly $9.5K → $1K of reads plus $1.9K of writes a month. Round 3 does this.
-
"Why not store the top-100 response in the cache and skip CloudFront?" Answer: that saves our cache a few commands, but our servers would still send 6 Gbps of responses. The expensive part is the bytes and requests at 50,000 a second, and only a layer in front of us removes them from our servers.
-
"A tournament needs a separate board that starts at 18:00 and ends at 21:00. What changes?" Answer: it's a season with custom boundaries: its own key, its own start time for the tie-break offset (3 hours in 100 ms units is 108,000, far under ), and the same signed-end-time rule for eligibility.
Interview gotchas from this round
| Gotcha | Why it's wrong |
|---|---|
| "Timestamps in milliseconds make ties exact" | Only if score and time fit in 53 bits together; with Unix time they don't. |
| "Equal scores keep insertion order" | They're ordered by member bytes, reversed in reverse ranges. |
| "Flip a global flag at midnight" | Tasks see it at different moments; the match's end time must decide. |
| "A 2-second CDN cache absorbs everything" | Only for responses identical across viewers, and the CDN's own egress is still billed. |
| "Signed scores stop cheating" | They stop forged scores; bots on real servers need plausibility checks and review. |
Round 3 · Architect · "Half a Billion Players, Friends and Regions"
~45 min · Principal (L7) · 4 regions · 500M players, 100M DAU · ~500K submissions/s peak worldwide · 99.99% per region · exact, auditable ranks for prize tiers
R3.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 3. If you're starting here, it's everything you need from Rounds 1 and 2.
Round 2 in 60 seconds. "We run an esports leaderboard in one region: 50 million players, 10 million a day, 10 modes with weekly seasons, 500 million submissions a day, 100,000 a second at tournament peak, 99.99%, reads under 5 ms and writes under 15 ms at P99. Each board is a sorted set per mode and season. Ties go to whoever finished first: the score is an integer, raw score times 10^7 plus the inverted time since season start in 100 ms units, proven below 2^53. The season comes from the game server's signed end time, with a 15-minute grace window; old boards are archived to S3 and unlinked within hours. DynamoDB holds each player's best and is the truth; a change stream repairs any board write a crash skipped. Ten boards, about 48 GB, sit on six shards with two replicas each; ranks are counted on replicas. Lobbies get the top 100 from CloudFront with a 2-second TTL; spectators get versioned diffs over WebSocket. Suspicious scores that would enter the top 1,000 are quarantined, and every match goes to an audit lake. About $71K a month, two-thirds of it serving the lobby top 100. Three things are still open: it's one region, a board is one sorted set on one shard, and there are no friends boards or provable payouts."
Architecture v2, compact
textgame server ─signed─► API ─┬─ UpdateItem IF best < new ─► DynamoDB Scores ─streams─► repair Lambda ─┐ ├─ PutRecord (parallel) ─► Kinesis ─► Firehose ─► S3 audit; review workers │ └─ ZADD GT ─► ElastiCache: 6 shards × 3 nodes, lb:{g1:mode}:<week> ◄───────┘ app ─► API ─► ZCOUNT / ZREVRANK + ZRANGE REV on replicas ─► profile cache (SET NX fills) lobby ─► CloudFront (2 s) ─► API spectators ─► WebSocket gateways ◄─ 2 versioned publishers
Steps so far
| Step | Problem | Component |
|---|---|---|
| 1.1–1.2 | Slow rank; lost cache | Sorted set; DynamoDB as truth; rebuild with a ready marker |
| 1.3–1.4 | Best only; fake scores | Conditional update + ZADD GT; signed results |
| 2.1 | Ties | Integer tie-break below |
| 2.2 | Around me | Rank, then range; hydrate |
| 2.3 | Rollover | Per-season keys; season from signed end time |
| 2.4 | Lobby storms; spectators | Edge cache; versioned push |
| 2.5 | Cheaters | Checks, quarantine, audit lake |
| 2.6 | One thread | Shards by mode; replicas; headroom |
Open costs: one region, one sorted set per board, no friends, no provable payouts.
R3.1 The Scope Raise
Interviewer: "We're global. 500 million players, about 100 million a day, in four regions, and at a world final submissions peak around 500,000 a second. Players want three boards: global, their region, and their friends. Prize payouts for the top 1,000 must be exact and provable if someone disputes them. And the leaderboard must survive losing a region."
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Does every player need an exact rank? | The top 1,000 must be exact and provable; that's where the money is. Near the top, players care about exact positions. Someone ranked 60 millionth is happy with "top 12%". | Exact where it matters, approximate below (step 3.2), and a separate, provable computation for payouts (step 3.5). |
| Where are players, and may a read go to another region? | Four regions: North America, Europe, Northeast Asia, Southeast Asia. Every read should be served in the player's own region. | Everything a read needs must be in every region (step 3.4). |
| How fresh must the global board be? | Within a few seconds. Your own regional board should feel instant. | The global board can be fed asynchronously (step 3.4). |
| How many friends does a player have, and how fast must the friends board load? | About 100 on average; we rank at most 1,000. Under 50 ms. | Compute it on read (step 3.3). |
| What must a payout prove? | That anyone can recompute the standings from the match records and get the same answer, and that we can't have changed them afterwards. Keep them five years. | The event log is the truth; standings are recomputed, signed and locked (step 3.5). |
| If a region is lost, what may players see? | They keep playing through another region. The global board may say a region's results are delayed. Losing the last second of results is acceptable only if they're recovered when the region returns. | A partner region takes over; stale data is labeled and repaired (step 3.6). |
| Must the lobby top 100 still refresh every second? | Every 5 seconds is fine. Spectators still get live updates. | A fifth of the CDN requests per player (R3.6). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Players | 50M, 10M DAU | 500M, 100M DAU |
| Footprint | 1 region, 3 AZs | 4 regions, each serving its own players |
| Submissions | 500M/day; 100K/s peak | 5B/day; ≈ 57,870/s average; ~500K/s peak |
| Boards | Mode × season | + scope: global, regional, friends |
| Exactness | Every rank exact | Exact near the top; approximate (a percentile) below; provable for the top 1,000 |
| Survive | An AZ | A region |
| Availability | 99.99% | 99.99% per region |
| Data | ~48 GB of boards | Exact tiers + histograms (~3 GB per region); ~1 TB/day of match audit |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| One sorted set per board | A global board of the flagship mode could hold 500M members: 48 GB of data in one key. It still fits a large node, but one key lives on one shard, so one engine thread takes all its writes, a failover resyncs 48 GB, and a rebuild re-adds 500M members one at a time (R3.6: ~40 minutes). |
| Rank in every submit response | ~500K rank queries a second at peak, most of them for one or two popular modes. |
| One region | Players on other continents pay a long round trip; a regional outage is a total outage. |
| Global board in one place | A global board updated from four regions either takes cross-region writes on the hot path or lags. |
| No friends boards | "A sorted set per player" is the obvious design and the wrong one (step 3.3). |
| Payouts from the live board | The live board is a cache: it can miss a write in a failover, it breaks ties at 100 ms, and it can't prove anything after the fact. |
R3.3 New Requirements and API Additions
Boards have a scope, and ranks have a precision.
httpGET /v3/games/g1/modes/m03/seasons/current/players/p48213377/rank?scope=global HTTP/1.1 Authorization: Bearer <player token>
httpHTTP/1.1 200 OK Content-Type: application/json { "scope": "global", "season": "2026-w39", "score": 31250, "precision": "approximate", "percentile": 12.3, "rank_estimate": 61500000, "error_bound": 50000, "as_of": "2026-09-23T21:04:11Z" }
For a player near the top, the same call returns "precision": "exact" and "rank": 4812. scope is global, region or friends; region also returns "region": "ap-northeast-1", and friends returns the ranked list of friends.
Certified standings for a closed season (public, read-only):
httpGET /v3/games/g1/modes/m03/seasons/2026-w39/standings/certified HTTP/1.1
httpHTTP/1.1 200 OK Content-Type: application/json { "season": "2026-w39", "cutoff": "2026-09-28T00:15:00Z", "tie_rule": "score desc, ended_at asc (ms), match_id asc", "entries_url": "https://standings.example-games.com/g1/m03/2026-w39/standings.json", "sha256": "4b1f…9e0a", "signature": "MEUCIQ…", "key_id": "alias/standings-signing", "algorithm": "ECDSA_SHA_256" }
Anyone with the public key can check that the standings file hashes to sha256 and that the signature matches.
R3.4 Design Evolution: Tiers, Precision, Friends, Regions and Proof
Step 3.1: One Board No Longer Belongs on One Shard
The problem: the flagship mode's global board has 500M members. As one sorted set it lives on one shard: one thread takes ~50,000 new bests a second and a quarter of a million rank queries at peak, and after a bad failure a rebuild has to re-add 500M members through that same thread. What would you do? Assume, for now, that every rank must be exact.
Primitive: Database Sharding & Partition Keys · Drill: Sharding tenant hotspot
Step 3.2: Most Players Don't Need an Exact Rank
The problem: step 3.1 keeps a sorted set entry for every one of 500M players so we can tell the player in 61,482,117th place exactly that. They'd be just as happy with "top 12%". Meanwhile those entries are ~99.8% of our memory and most of our rank queries. What would you do?
textboard g1:m03:2026-w39 (global) exact tier (sorted set, top 1,050,000) ranks 1 … 1,050,000 exact histogram bucket 9,999 [98,110 … max] count 50,112 ┐ bucket 9,998 [96,870 … 98,109] count 49,871 │ rank ≈ Σ counts above … │ + position in my bucket bucket 0 [0 … 210] count 50,006 ┘
Step 3.3: "Rank Me Among My Friends"
The problem: every player wants a board of just their friends, updated within seconds, in under 50 ms. What would you do?
Step 3.4: Players in Four Regions, One Global Board
The problem: a player in Tokyo sets a new best. Their regional board should update at once, the global board within seconds, and a player in Dublin must see global ranks from Dublin. What would you do?
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active · Drill: Replication multi-region consistency
Step 3.5: Prize Payouts Must Be Provably Correct
The problem: the world final paid $1M across the top 1,000. The player in 1,001st place says a replayed match should have put them in. Legal asks: what did we pay from, can we recompute it, and can we show it wasn't changed? What would you do?
Primitive: Event Sourcing & CQRS · Drill: Event sourcing projection rebuild lag
Step 3.6: A Region Is Gone
The problem: ap-northeast-1 goes dark in the middle of a live final. 125 million players call it home. Its boards, its API and its last second of new bests are unreachable. What would you do?
Synthesizing vector architecture diagram...
The partner already holds the data and the standby board. Only the last unreplicated second is missing, and the reconcile restores it by taking the maximum over both regions' match logs.
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | One board, one shard | Score-range tiers; rank = Σ ZCARD above + ZREVRANK in tier; crossings placed by the stored best, add then remove; no additive counters | Rebalancing; brief off-by-one; memory for everyone |
| 3.2 | Exact for nobody who cares | Exact top ~1M tier + 10,000-bucket histogram; state and positions saved together; snapshot publishing | Percentiles below the top |
| 3.3 | Friends boards | Compute on read: parallel BatchGetItem from the local replica; 30 s cache; no DAX | Cost grows with friends × views |
| 3.4 | Four regions, one global board | Home-region writes; PlayerBest global table (only new bests); every region builds global views from its replica's stream | Seconds of lag; 4× writes |
| 3.5 | Provable payouts | Match log in S3 as truth; once-per-name Step Functions close; max-based recompute with ms ties; KMS signature; Object Lock | A batch job; payouts wait for sealing |
| 3.6 | Region lost | Paired failover; standby partner boards; heartbeat staleness; LWW then max-reconcile | Standby capacity; reconcile |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Each region is a complete leaderboard for its own players, plus standby boards for its partner and its own copy of the global views. The only things that cross regions are the best-score table's replication, the audit lake's copy to the partner, and heartbeats. No read ever leaves the region.
Trace 1: a Tokyo player's new best reaches the global board
textt = 0 ms game server (ap-northeast-1) → POST score; signature, checks GetItem PlayerBest (filter): stored 31,100 < new 31,250 → continue UpdateItem IF best < new → OK ∥ PutRecord match-events rank from the local histogram snapshot → "top 12.3%" in the response t ≈ 1 s replicated to us-east-1, eu-west-1, ap-southeast-1 t ≈ 1–2 s each region's stream: aggregator moves one count bucket 8,760 → 8,771; the board consumer skips the exact tier (31,250 < floor 88,400) t ≈ 2 s API nodes load the next histogram snapshot; the player's global percentile updates everywhere
Trace 2: a payout computation
textMon 00:15 grace window closes for 2026-w39 Mon 00:30+ each region seals its log (Firehose flushed past the cutoff; manifest written) start-execution name=season-close-g1-m03-2026-w39 (a repeat start can't create a second run) Athena: per player, max(score) over valid matches, and the earliest ended_at (then smallest match_id) at that score ORDER BY score DESC, ended_at ASC, match_id ASC exclude disqualified / unapproved; top 1,000 diff vs live exact tier → report (e.g. 3 players reordered by ms ties) SHA-256(standings.json) → KMS Sign (ECDSA_SHA_256) → S3 Object Lock (compliance, 5 years) publish certified standings; finance pays from this file only
R3.6 Numbers and Cost
Traffic (assumptions: 20% of players daily, as in Round 2; 50 matches each; 20% of results are new bests; the peak is a world final overlapping evening peaks)
| Quantity | Math | Value |
|---|---|---|
| Submissions per day | 100M × 50 | 5B |
| Submissions/s, average | 5,000,000,000 ÷ 86,400 | ≈ 57,870 |
| Submissions/s, peak | given (≈ 8.6× average) | ~500,000 |
| New bests per day | 5B × 20% | 1B (≈ 11,574/s; ~100K/s peak) |
| Region | Share | Players | DAU |
|---|---|---|---|
| us-east-1 | 30% | 150M | 30M |
| eu-west-1 | 25% | 125M | 25M |
| ap-northeast-1 | 25% | 125M | 25M |
| ap-southeast-1 | 20% | 100M | 20M |
Step 3.1's design (exact for everyone), per region. Global boards: 10 modes × 500M × 96 B = 480 GB. Own regional boards, us-east-1: 30% of that, 144 GB; its partner's standby boards (eu-west-1, 25%, step 3.6): 120 GB. Total ≈ 744 GB of data. Keeping each shard at half of a cache.r7g.4xlarge's ~85 GB limit (42.6 GB), that's , so 18 shards × 3 nodes = 54 nodes. eu-west-1 is the same (480 + 120 + 144 GB); the two Asian regions hold 480 + 120 + 96 = 696 GB, 17 shards, 51 nodes each. That's 210 nodes: 210 \times \1.396 \times 730 \approx **\214K a month**. Rebuilding one 500M-member board through one thread at an assumed 200,000 adds a second takes s ≈ 42 minutes, against a monthly downtime budget of 4.4 minutes. Split across 10 tiers in parallel, about 4 minutes.
Step 3.2's design, per region.
| Part | Math | Size |
|---|---|---|
| Global exact tiers | 10 modes × 1.05M × 96 B | ≈ 1.0 GB |
| Own + partner regional exact tiers | 2 × 10 × 1.05M × 96 B | ≈ 2.0 GB |
| Histograms | 10 modes × 5 scopes (global + 4 regions) × 80 KB | 4 MB |
| Total data | ≈ 3 GB |
Two shards of cache.r7g.xlarge (limit ≈ 19.7 GiB each) with two replicas each: under 10% full, and two write threads. 6 \times \0.3496 \times 730 \approx $1{,}530 per region, **≈ \6.1K** for four. About 35× cheaper than exact-for-everyone, and a rebuild is 1M members per tier, a few seconds.
Histogram precision. ~50,000 players per bucket globally (once the season's distribution resembles last season's) → rank error at most the player's bucket population, ~50,000; a 0.1% percentile step is 500,000 players, 10× the error. For a regional board of 100M, buckets hold ~10,000 players.
Friends boards (assume 2 views per daily player per day, 100 friends on average, 20% served from the 30 s cache)
Latency. Exact rank: one ZREVRANK on a replica, ~1 ms. Approximate rank: a lookup in the API's memory, well under 1 ms. Friends: friend list ~5 ms (an assumption for the social service), then up to 10 parallel BatchGetItems, the slowest ~10 ms, plus sorting: ~15–20 ms, under the 50 ms target.
Audit log. TB a day, ≈ 1.83 PB over five years, plus the partner copy.
Monthly cost (list prices, us-east-1 rates where regions differ; rounded; storage a year in)
| Item | Math | Monthly |
|---|---|---|
| DynamoDB new bests, incl. replication | 1B/day × 30.4 = 30.4B writes, each billed in the home region and the 3 replicas: 121.6B × $0.625 per million | ≈ $76K |
| CloudFront, lobby top 100 | polls scale with players and every 5 s instead of 1: 10K/s × 10 ÷ 5 = 20K/s → 52.6B requests × $0.01 per 10,000 ≈ $52.6K; with compression (assume ~4 KB), ~210 TB out ≈ $12.5K; more at Asian edge prices | ≈ $65K+ |
| Friends-board reads | above | ≈ $30K |
| Kinesis + Firehose, 4 regions | ~10× Round 2's volume: Firehose's 5 KB-per-record billing on 5B records a day ≈ $22K, plus shards, retention and Parquet conversion | ≈ $38K |
| Compute | API for 500K/s peak with regional headroom for a partner's players; consumers, aggregators, gateways | ≈ $15K |
| DynamoDB filter reads | 5B/day × 30.4 × 0.5 RRU × $0.125 per million | ≈ $9.5K |
| ElastiCache | boards $6.1K; profile and friends-result caches, 3 × cache.r7g.xlarge per region, $3.1K | ≈ $9.2K |
| S3 audit lakes + partner copies | ~365 TB, mostly Glacier Instant Retrieval, × 2 copies; replication transfer ~30 TB × $0.02/GB | ≈ $7K |
| Global-table transfer, storage | ~18 TB/month of replicated items × $0.02/GB; ~0.9 TB × 4 replicas stored | ≈ $1.4K |
| Step Functions, Athena, KMS, Route 53, ALBs, CloudWatch | ≈ $15K | |
| Total | ≈ $266K/month |
A little over a quarter of a cent per daily player per month. The biggest lines are the ones every player touches: writing each new best four times, and serving the lobby. Step 3.2 is the line that didn't appear: exact-for-everyone would have added ~$208K of memory for a precision no one below the top can see.
R3.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Score-range vs hash sharding | Score-range tiers (and after step 3.2, a single exact tier plus buckets) | Tier rebalancing and cross-tier moves. Hash sharding never moves writes but costs ZCOUNTs per rank; choose it when writes per shard are the bottleneck and rank reads are rare. |
| Exact vs approximate | Exact for the top ~1M, a percentile below | "Top 12.3%" instead of a number; a histogram that must be exactly-once |
| Friends: compute on read vs precompute | Compute on read, 30 s cache | ~$30K/month that grows with friends × views, instead of ~1.16M writes a second and TBs of boards |
| Global board: central merge vs everywhere | Build the global views in every region from the replicated table | Four copies that can differ by seconds; 4× write cost. A central merger would be a single point of failure and cross-region reads. |
| Payout from live board vs log | Recompute from the log, signed and locked | A batch job and a documented second tie rule (ms) |
| Regional writes vs global writes | Home-region writes; one writer per item | During failover, LWW decides, and we repair with a max-merge |
Closing the loop. The opening question was: how do we answer "what is my rank?" exactly and instantly while scores keep changing? The answer is now:
- Instantly: a sorted set gives O(log N) rank for the players who get an exact answer; a histogram in the API's memory answers everyone else without touching the cache.
- Exactly, where it matters: exact near the top, with ties encoded into a proven 53-bit integer; exact and provable for payouts, recomputed from the log and signed.
- While scores keep changing: every write is a maximum, so retries, replays, repairs and region reconciles are all safe; the only numbers we add up (histogram counts) are counted exactly once by saving state with stream positions.
R3.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| Tier-crossing inconsistency (step 3.1 layout) | A player briefly in two tiers | The consumer places each player by their current stored best (add to that tier, remove from every other), so late or repeated records can't leave a stray copy; ranks below are off by one for that moment. As a backstop, a sweep compares tier membership with PlayerBest. |
| Histogram aggregator lag or crash | Percentiles stop moving; snapshot age climbs | It restarts from its saved state and positions (no double count); the stream holds 24 hours; the API labels ranks older than 60 s as "updating". |
| Replication lag spike | ReplicationLatency climbs for one region | Global views lag for that region's players; heartbeats mark them delayed; local boards are unaffected. |
| Region outage during a live final | Health checks fail; players move to the partner | Step 3.6: standby board, stale labels, reconcile by max on return; the final's payout waits for the region's log. |
| Disputed payout | A player claims a wrong placement | Re-run the recompute from the locked log inputs (same query, same answer); show the signed file and the tie rule; if a result was wrongly excluded, issue a corrected, newly signed standings file alongside the old one. |
| Exact-tier trim removes a player who then improves | None visible | Any new best at or above the floor is re-added; below it, the histogram answers. |
R3.9 Runbook and Incident Response
Golden signals, per region OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Submit P99 | > 15 ms for 5 min | P2 | Which step grew: DynamoDB, Kinesis or the board? |
| Rank P99 (exact tier) | > 5 ms for 5 min | P2 | Replica CPU; add a replica |
| Cache memory | DatabaseMemoryUsagePercentage > 70% | P2 | Check the trim job; exact tiers should be ~1.05M members |
| Fragmentation ratio | > 1.5 | P3 | Confirm active defrag is running; memory headroom |
| Board consumer lag | Lambda IteratorAge > 5 s | P2 | Scale; check for poison records |
| Histogram snapshot age | > 10 s | P2 | Aggregator health; it resumes from saved positions |
Global-table ReplicationLatency | > 30 s for 5 min | P2 | Source region health; expect "delayed" labels |
| Quarantine backlog | > 1,000 pending or oldest > 30 min | P3 | Add review capacity; a cheating wave? |
| Season-close execution | not SUCCEEDED by 03:00 UTC Monday | P2 | Which region hasn't sealed? If it failed, redrive it (CLI 6b); a new start with the same name won't run |
Zero-downtime season rollover (nothing is switched on the write path; each step can be repeated) OPS 7
- Before Monday 00:00: confirm next season's histogram boundaries are loaded (last season's quantiles) and every region's API has the season schedule.
- 00:00: nothing to do for writes; each result's signed
ended_atpicks its week. - 00:00–00:01: point
current_seasonat the new week (CLI 5), for lobby reads. - 00:15: grace ends; the old week is frozen in every region.
- 00:30+: start the season-close workflow per board (CLI 6); it waits for all four regions to seal.
- After it succeeds: archive and
UNLINKthe old week's exact tiers; drop its histograms; verify the certified file's signature (CLI 9) and lock (CLI 10).
Go deeper: CLI playbook
Plain commands an on-call engineer runs, one at a time. Replace names, IDs and times with real ones.
text# 1. Board cache memory (per node) aws cloudwatch get-metric-statistics --region ap-northeast-1 --namespace AWS/ElastiCache --metric-name DatabaseMemoryUsagePercentage --dimensions Name=CacheClusterId,Value=lb-boards-apne1-0001-001 --statistics Maximum --period 60 --start-time 2026-09-27T20:00:00Z --end-time 2026-09-27T21:00:00Z # 2. Board consumer lag (Lambda reading DynamoDB Streams) aws cloudwatch get-metric-statistics --region ap-northeast-1 --namespace AWS/Lambda --metric-name IteratorAge --dimensions Name=FunctionName,Value=board-consumer --statistics Maximum --period 60 --start-time 2026-09-27T20:00:00Z --end-time 2026-09-27T21:00:00Z # 3. Replication lag from us-east-1 into eu-west-1 aws cloudwatch get-metric-statistics --region us-east-1 --namespace AWS/DynamoDB --metric-name ReplicationLatency --dimensions Name=TableName,Value=PlayerBest Name=ReceivingRegion,Value=eu-west-1 --statistics Average --period 60 --start-time 2026-09-27T20:00:00Z --end-time 2026-09-27T21:00:00Z # 4. Which season do lobbies show? aws ssm get-parameter --region us-east-1 --name /leaderboard/g1/current_season # 5. Point lobbies at the new season (reads only) aws ssm put-parameter --region us-east-1 --name /leaderboard/g1/current_season --value 2026-w40 --type String --overwrite # 6. Start the season close (the name is unique, so a repeat can't start a second run) aws stepfunctions start-execution --state-machine-arn arn:aws:states:us-east-1:111122223333:stateMachine:season-close --name season-close-g1-m03-2026-w39 --input '{"game":"g1","mode":"m03","season":"2026-w39"}' # 6b. Resume a failed season close from where it failed aws stepfunctions redrive-execution --execution-arn arn:aws:states:us-east-1:111122223333:execution:season-close:season-close-g1-m03-2026-w39 # 7. Board cluster status (shards, failover state) aws elasticache describe-replication-groups --region ap-northeast-1 --replication-group-id lb-boards-apne1 # 8. Is a region's API health check failing? aws route53 get-health-check-status --health-check-id 11111111-2222-3333-4444-555555555555 # 9. Verify the standings signature (the message is the 32-byte SHA-256 digest of the file) aws kms verify --region us-east-1 --key-id alias/standings-signing --message fileb://standings-2026-w39.sha256 --message-type DIGEST --signing-algorithm ECDSA_SHA_256 --signature fileb://standings-2026-w39.sig # 10. Confirm the standings file is locked aws s3api get-object-retention --bucket lb-certified-standings --key g1/m03/2026-w39/standings.json
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Paired regions with standby boards; heartbeats label stale regions; LWW after failover repaired by a max-merge; the log survives a region; season close runs once by execution name REL 9 · REL 10 · REL 13 |
| Performance Efficiency | Rank for 99.8% of players answered from memory in the API; exact tier small enough for fast failovers; no cross-region read; parallel friend reads costed as max PERF 1 · PERF 3 · PERF 4 |
| Security | Payout standings signed with a KMS key that never leaves KMS; Object Lock so they can't be altered; disqualifications applied before payout; the full match log for investigations SEC 4 · SEC 8 |
| Cost Optimization | ≈ $266K/month, derived; approximate ranks avoid ~$208K of memory; only new bests replicate; filter reads before conditional writes COST 6 · COST 8 |
| Operational Excellence | Per-region golden signals with first actions; a rollover with nothing switched on the write path; a CLI playbook for region and replication incidents OPS 7 · OPS 8 · OPS 10 |
| Sustainability | Memory for exactness only where it's seen; old boards deleted within hours; the audit lake tiered to colder storage; lobbies poll every 5 s SUS 2 · SUS 3 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Asks where exactness matters, and designs exact, approximate and provable ranks as three different products.
- Compares score-range and hash sharding by the cost of a rank query, not just by write balance.
- Refuses additive counters fed by at-least-once consumers; uses
ZCARD, or saves state with stream positions, and publishes snapshots. - Places data by who writes it (one home region per player) and knows what global tables do with conditions and conflicts, including during failover.
- Makes the log the truth for money, and relies on maxima being idempotent to make every replay safe.
- Prices the alternatives and shows which line the design removed.
Follow-up questions
-
"A player moves from Europe to Asia. What happens?" Answer: their home region changes (written by the old home region, then owned by the new one). Their
PlayerBestitems stay where they are in the global table, but future writes come from the new region. Their regional-board membership moves: the stream consumer sees the home-region attribute change and re-places them by their current stored item: add to the new region's exact tier (if they qualify) and histogram, then remove from the old region's, so a late or repeated record can't leave them in both. -
"Why not keep the regional boards exact for everyone? 150M players per region is only ~14 GB." Answer: we could, per board. But it's 10 modes × own and partner regions, and those boards bring back the problems of step 3.1: large failovers, slow rebuilds and rank queries on the cache for every player. We'd do it only if the product shows exact regional ranks deep down; the measured question is whether anyone looks at them.
-
"The esports league wants live exact ranks for the top 10,000 in a regional qualifier. Does the design allow it?" Answer: yes: the exact tier is 1M deep, so 10,000 is well inside it. For the qualifier's payout, the same season-close workflow runs over the qualifier's board and window.
Interview gotchas from this round
| Gotcha | Why it's wrong |
|---|---|
| "Keep a counter per tier with increment and decrement" | Retries from an at-least-once consumer double-count, and the drift never heals. |
| "Hash-shard and merge" | Every rank becomes O(log n) calls. |
| "A sorted set per player for friends" | ~1.16M writes a second and TBs of boards that are rarely opened. |
| "Pay from the live board" | A cache with 100 ms ties and four slightly different copies proves nothing. |
| "Global tables never conflict because each player writes in one region" | During a failover the partner writes too, and last-writer-wins can keep the lower score. |
| "Put DAX in front of a global table" | Replicated writes bypass the local DAX; it serves stale items until they expire. |
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping: best or sum? how many? how fresh? who submits? | Restate Round 1 in 60 seconds | Restate Round 2 in 60 seconds |
| 5–15 min | Requirements + API (signed submits from game servers) | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Steps 1.0–1.4: COUNT(*) → sorted set → durable truth → conditional writes → signed results | Steps 2.1–2.6: tie-break in 53 bits, around me, per-season keys, edge cache and push, anti-cheat, shards | Steps 3.1–3.6: score-range tiers, exact vs approximate, friends on read, regions, provable payouts, region loss |
| 40–50 min | Numbers: traffic, memory, cost | Numbers: memory check, sizing, latency budgets, CDN, cost | Numbers: exact vs approximate memory, friends, replication, cost |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I design: does a player's best score count or the sum, who submits scores, and how exact and how fresh must a rank be?"
- When the scope is raised: "Here's what breaks, and I'll fix it in this order: anything that can put a wrong score or a wrong payout in front of players, then anything that can take the board down, then cost."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What if the cache loses the board?" (REL 9) | DynamoDB holds every best score; the board is rebuilt from it, and a ready marker says when it's complete. | 1 | Step 1.2 |
| "What if a primary fails mid-tournament?" (REL 11) | A replica is promoted; a job re-applies the stored best from DynamoDB for every player seen in the last minutes of the match stream (never the stream's raw scores, which include quarantined ones); every write is a maximum, so it's safe. | 2 | R2.8 | |
| "What if a region goes down?" (REL 13) | Its players move to a partner that already builds their board; stale results are labeled and reconciled by maximum on return. | 3 | Step 3.6 | |
| Performance | "How is 'my rank' fast?" (PERF 3) | A sorted set's skip list stores spans, so rank is O(log N); below the top, a histogram in the API's memory. | 1–3 | Steps 1.1, 3.2 |
| "How do you serve 50,000 lobbies a second?" (PERF 4) | One identical response cached at CloudFront for 2 seconds; the origin sees a few requests a second. | 2 | Step 2.4 | |
| Cost | "What does it cost, and where does it go?" (COST 3) | About $575, $71K and $266K a month; serving the lobby and writing each new best are the big lines, not memory. | 1–3 | R1.7, R2.6, R3.6 |
| "Why not exact ranks for everyone?" (COST 6) | It costs ~35× as much in cache nodes for a precision nobody below the top can see. | 3 | Step 3.2 | |
| Operations | "How do you roll a season over with no downtime?" (OPS 7) | Per-season keys; the signed end time picks the week; nothing on the write path switches. | 2–3 | Step 2.3, R3.9 |
| "How do you know the board is healthy?" (OPS 8) | Submit and rank P99, memory and fragmentation, consumer lag, snapshot age, replication lag, quarantine backlog. | 2–3 | R2.10, R3.9 | |
| Security | "How do you stop fake scores?" (SEC 2) | Only game servers submit, with signed results; plausibility checks and a quarantine guard the prize zone. | 1–2 | Steps 1.4, 2.5 |
| "How do you prove a payout?" (SEC 8) | Recompute from the match log, sign with KMS, and lock the file with S3 Object Lock. | 3 | Step 3.5 | |
| Sustainability | "Where is this system wasteful?" (SUS 3) | Exact ranks nobody sees, and old boards kept for a week; we keep exactness at the top and delete boards within hours. | 2–3 | Steps 2.3, 3.2 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Data structure | Sorted set; knows why an index can't count and a skip list can. | Encodes the tie-break in 53 bits with a proven budget; knows default tie order. | Changes the shape: score-range tiers, then exact tier + histogram. |
| Correctness under change | Conditional writes in both stores; durable-first. | Season from the signed end time; stream repair; idempotent replays. | No additive counters from at-least-once sources; max-merge after failover; exactly-once histograms. |
| Scale of reads | O(log N) rank; bounded ranges. | CDN for identical answers; push for spectators; rank counted on replicas. | Rank from memory for 99.8% of players; friends computed on read. |
| Trust | Server-signed results. | Plausibility checks, quarantine, audit lake, honest limits. | Payouts recomputed from the log, signed and locked. |
| Numbers | Traffic, memory, cost. | Checks the per-member estimate; sizes with reserved memory; latency budgets with max for parallel steps. | Prices exact vs approximate, replication and friends; finds the lines the design removed. |
| Evolving under new scope | Builds from the baseline, one problem at a time. | Opens with what breaks; fixes correctness before capacity. | Decides what must be exact and where data lives, and says what the business must decide. |