← all topics Game matching board · say it in this order 1 Problem2 Entities + API 3 High-level design4 Deep dives 5 Final + levelsappendixreset

Design a Game Matching (Matchmaking) System

Hello Interview flow, board rules · one idea per line · click a chip to mark where you are
SAY THIS SENTENCE FIRST

"The scale math makes this a correctness and fairness problem, not a throughput problem: even at peak, every waiting room fits in one process's memory. So the truth lives in Postgres, one single-threaded matcher owns each (game, region) room, a guarded compare-and-set referees every claim, and a 5-minute deadline wall is enforced by machinery, not hope."

STEP 1 OF 5

Understanding the problem

4 min
🎮 What are we building?
  • A user picks a game and enters a waiting room for it.
  • The system groups players with similar skill into a match, and the game starts once the match is formed.
  • Skill is a number from 0 to 100 that we fetch from an external skill API.
  • Every user is willing to wait about 5 minutes at most. If we cannot find a good match by then, the game starts anyway.
  • People who joined earlier should be matched earlier, like a queue.

Functional Requirements

  1. Users should be able to join a game's waiting room and get matched with players of similar skill.
  2. Users should be matched roughly in the order they joined, and nobody should wait forever.
  3. The system should start the game by the deadline (about 5 minutes) even if the match quality is not ideal.
  4. Users should be able to cancel while waiting, and should hear about their match the moment it forms.

Below the line (out of scope):

  • Hosting the game servers and running the game session itself. We ask that system for a server address and hand off.
  • Computing the skill number. The skill API owns that.
  • Party queues, where friends want to be matched together. It is worth one sentence as an extension if asked.

Non-Functional Requirements

Do the scale math first, because it changes the whole question
  • 1M daily users, each joining about 5 games a day. That comes out to about 60 joins per second on average, and maybe 300-600 per second at peak if we assume a 5-10x peak factor.
  • How many people are waiting at once? That is arrival rate multiplied by average wait. At the PEAK rate of 300-600 per second, with a 30-60 second average wait, about 10-40K people are waiting at the busiest moment.
  • Each waiting player is a couple hundred bytes of state, so even 40K of them is a few megabytes. Every waiting room in the system fits comfortably in one process's memory.
  • There are 100K games, but most have nobody waiting. Maybe a thousand games have an active waiting room at any moment.
  • So say this out loud: this is a correctness and fairness problem, not a throughput problem.
  1. A match request must end in exactly one outcome: one match, or a cancel. Two matches claiming the same request is the disaster case.
  2. The 5-minute promise is enforced by machinery. Honestly stated, the bound is 5 minutes plus a small failover slack (lease timeout plus rebuild time, a few seconds) if a matcher dies at the wrong moment.
  3. Joining should survive soft dependencies failing (the skill API). Postgres is the one hard dependency; it runs with a SYNCHRONOUS standby, which these tiny write volumes easily afford, so a failover can never un-commit a match a player was already told about.
  4. When a match forms, players should hear about it within a second or two. The budget behind that: one matcher tick (500ms) + the server-allocation call (a few hundred ms, it sits on the critical path) + one push.
DONE WHEN: interviewer nods at "correctness and fairness problem, not a throughput problem". That sentence licenses the single-threaded matcher later.
STEP 2 OF 5

Entities + API

4 min

Defining the Core Entities

  • Match request is one waiting player. It stores a snapshot of their skill (plus a flag if that snapshot was estimated), when they joined, a hard deadline of join time + 5 minutes, a status that moves WAITING → MATCHED or CANCELLED, and the match id once matched.
  • Match is the formed group: which game, which players, how far apart their skills ended up, and whether it formed normally (FILLED) or because time ran out (TIMEOUT). Timed-out players still become MATCHED; the TIMEOUT tag lives on the match, not on the player.
  • Game config says how many players a match needs (K, somewhere from 2 to 10, and K includes the anchor player), how fast the skill window widens, and whether bots may fill empty seats. 100K games get a global default curve with per-game overrides.
  • Skill is fetched from the external skill API once, when the player joins, and saved on the request. The matcher never calls the API itself. External answers get recorded at the boundary, not re-asked in a loop.

API or System Interface

POST   /v1/games/{gameId}/queue     { region }   // region = where the player is; we match nearby first
  → 202 { requestId, skillSnapshot, deadline }   // deadline is stamped now and never moves
  → 409, body: { error: ALREADY_QUEUED, requestId }   // unique index on (userId, gameId) where status=WAITING; a retry gets the existing id back
GET    /v1/queue/{requestId}                        // polling fallback for status
WS     /v1/ws                                       // push: MATCHED { matchId, serverAddr }
DELETE /v1/queue/{requestId}                        // cancel; 200 while WAITING; 409 ALREADY_MATCHED or ALREADY_CANCELLED
PUT    /v1/queue/{requestId}/heartbeat              // stop sending these and we cancel you
  • We return 202 rather than 201 because joining starts a process rather than creating a finished thing. The real answer arrives later, over the socket.
  • There is only one push type. A timed-out start is still a MATCHED push, because a timeout still forms a real match with a real server address.
  • The 409 on cancel exists because a cancel can race the match forming. Deep dive 1 covers who wins.
DONE WHEN: you have said "the match_requests table in Postgres is the source of truth; everything else can be rebuilt from it", and the API is on the board with the 202, the cancel 409, and the heartbeat.
STEP 3 OF 5

High-level design

10 min, end to end, no dives yet

1) Users should be able to join a game's waiting room

gm1
  • When a player joins, the API asks the skill API once, saves the answer on the request, stamps the deadline, and inserts the request into Postgres with status WAITING.
  • That Postgres table, match_requests, is the source of truth. Everything else in the system can be rebuilt from it.
  • The skill call gets a 300ms timeout inside the join path. Down OR slow, same fallback: use the player's last known skill, or 50 as a default, and set the estimated flag. Match quality degrades a little; availability and join latency do not.
  • The matcher actually uses that flag: an estimated player starts with a wider window (say ±15), because pretending we know an estimated number to ±5 would manufacture bad matches.

2) Users should be matched by skill, in join order

gm2
  • Each (game, region) pair has exactly one matcher: a single-threaded loop that keeps that waiting room in memory. Joins, cancels, and heartbeats reach it as messages.
  • Every 500ms (the tick), the matcher starts from the player who has been waiting the longest. Call them the anchor.
  • The anchor has an acceptable skill range that grows the longer they wait: roughly plus or minus 5 when they just joined, 15 after a minute, 30 after three minutes, and anything goes as the deadline approaches.
  • The matcher walks the queue in join order and collects players whose skill falls inside the anchor's range, until it has K players including the anchor. To keep the match itself coherent, it also drops a candidate whose distance from the players already collected exceeds the window, so two teammates cannot be twice the window apart.
  • Notice the window is one-sided, and that is a choice: admission is judged by the anchor's widened range, so a freshly joined player can be pulled into a wide match sooner than their own young window would allow. We accept that because it rescues long-waiting players faster; a stricter variant also checks the candidate's own window and pays for it with longer waits.
  • If it finds K, it claims all of them atomically (deep dive 1), gets a server address, notifies everyone, and removes them from the queue.
  • If the anchor cannot be matched this tick, the matcher does NOT stop: it tries the next-oldest player as anchor, up to a bounded depth (say the 10 oldest). This matters because a lone skill-5 outlier at the head must not block six skill-90 players behind them from matching each other. Head-of-line blocking is the classic failure of naive FIFO matching, and this is the fix.
  • The outlier anchor is not abandoned either: their window keeps widening, and their deadline is absolute. Age buys reach; the wall catches whoever reach never helped.

3) The system should start the game by the deadline

gm3
  • The matcher keeps requests indexed by deadline as well as by join order, and checks the SOONEST deadline every tick. This matters once priority reordering exists, because the front of the queue is then not always the soonest deadline.
  • When someone's deadline fires, the skill window no longer applies. We still prefer the closest-skill players who are waiting, but we accept whatever spread we must, and we start the game.
  • If fewer than K players are waiting, we start with fewer, or fill the empty seats with bots where that game allows it. The match records that it formed by TIMEOUT, which feeds the tuning metric in deep dive 2.

4) Users can cancel, and are notified instantly

gm4
  • Cancelling and being matched are both a compare-and-set on the same WAITING status in Postgres. Whichever commits first wins, and the loser is told what happened.
  • The cancel API does the CAS synchronously, so it can answer 200 or 409 immediately, and then sends the matcher a message so the in-memory queue evicts that player rather than anchoring on a ghost.
  • Notification is a push over the player's WebSocket. The matcher does not hold sockets; it publishes the MATCHED event to a pub/sub channel keyed by user, and whichever gateway node holds that player's socket delivers it. If the gateway restarted, the client reconnects and the GET status endpoint catches it up.
  • A duplicate push is harmless because it carries the same match id. Polling is the fallback for everything.
DONE WHEN: the interviewer picks a box to open. Let them steer from here.
  • If they just nod and wait: "the riskiest part is the claim transaction, shall I open exactly-one-match?"
  • Running behind: protect dives 1 and 3. Dive 5 exists for the goalpost move to 100K QPS; do not open it unprompted.
STEP 4 OF 5

Potential deep dives

~20 min, interviewer steers
TRIGGER: "two matches claim the same player" / "walk me through the claim"

1) How do we guarantee a player lands in exactly one match?

ONE REFEREE · the guarded CAS in Postgres; the single writer is an optimization on top of it
  • The race we are afraid of is two writers reaching for the same WAITING player at the same moment: a stale matcher during a lease handover, or a cancel racing a claim. Adjacent-region matching (deep dive 4) adds one more way for two rooms to want the same player.
Bad Solution: many matcher threads scanning a shared table with no referee
  • Each thread reads the table, picks players it likes, and writes matches.
  • Sooner or later two matches claim the same player. One match starts a player short, and nobody can explain why.
Good Solution: let the database referee the claim
BEGIN;
SELECT 1 FROM leases WHERE partition=$p AND epoch=$my_epoch FOR SHARE;
-- zero rows means we are a zombie: ROLLBACK. Takeover bumps the epoch with an
-- UPDATE, which conflicts with this FOR SHARE, so a stale owner cannot slip through.
INSERT INTO matches (id, game_id, players, formed_by) VALUES (...);
UPDATE match_requests SET status='MATCHED', match_id=$1
  WHERE id IN ($2...$k+1) AND status='WAITING';
-- row count must equal K, or ROLLBACK and retry without the missing player
INSERT INTO match_outbox (match_id) VALUES ($1);
COMMIT;
  • One transaction creates the match, claims all K players, and records an outbox entry saying "this match still needs a server and notifications".
  • If someone cancelled meanwhile, the row count comes back short of K, and the whole transaction rolls back.
  • If you wanted many competing matchers instead, the shape changes: a SELECT ... WHERE status='WAITING' ORDER BY joined_at FOR UPDATE SKIP LOCKED picks candidates first, then the update claims them. Note what SKIP LOCKED costs you: skipping locked rows quietly abandons strict join order, the exact fairness this design is built on.
Great Solution: one single-threaded matcher per waiting room, with the CAS kept as the referee
  • Give each (game, region) exactly one writer. Match forming, cancels, and timeouts all become messages into that one loop, so in steady state there is nobody to race.
  • Be precise about which mechanism does what: the guarded UPDATE is the correctness mechanism, and the single writer is a contention and simplicity optimization. A paused-then-waking zombie matcher, or an adjacent region reaching for your player, still exists; the CAS is what stops them, and the lease carries an epoch number so a stale owner's claims are recognizably stale.
  • We can afford one thread because of the scale math: one waiting room fits in memory, and matching wants to scan a queue, which a single owner does naturally.
TRIGGER: "nobody close in skill is online" / "how do you tune quality vs the promise"

2) How do we balance match quality against the 5-minute promise?

ONE MECHANISM · age widens reach; the wall is enforced separately, so the promise never depends on the window
  • Here is the tension in one sentence: a strict skill window makes great matches and broken promises, and a loose one keeps the promise while ruining the matches.
Bad Solution: one fixed window
  • Say plus or minus 10, forever. At quiet hours there is nobody within 10, so players sit there until the deadline surprises everyone.
Good Solution: widen the window as the player waits
  • Everyone starts picky and gets less picky. You get quality when the room is busy and certainty when it is not.
Great Solution: greedy widening, chosen over batch optimization, with the tradeoff measured
  • Drive each tick from the oldest matchable player, whose window is widest, and enforce the deadline separately with the soonest-deadline check. Order and widening become one mechanism; the promise never depends on the window.
  • Name the serious alternative before someone names it for you: a periodic batch optimizer that, every few seconds, assigns the whole pool to matches minimizing total skill spread subject to wait limits. That formulation fixes greedy's blind spots (head-of-line waste, first-K instead of closest-K). We decline it here because K is small, rooms are small, strict join order is a stated requirement, and greedy with widening plus multi-anchor retry approximates it well at this scale. For team games with roles, revisit that choice.
  • Then watch one number: the fraction of matches that formed by timeout. If it climbs, the window curve is too strict or the game's population is too thin. That single metric, alongside p95 wait and per-match skill spread, drives every tuning conversation.
TRIGGER: "the matcher crashes" / "a match commits and THEN the matcher dies"

3) What happens when things die?

3 SEPARATE FAILURES · process death, post-commit death, client death; separate them out loud
  • Three separate failures hide in this question: the matcher process dies, a match commits and THEN the matcher dies, and the player's client dies.
Bad Solution: the memory is the only copy
  • The crash silently unqueues thousands of people. They wait forever for a match nobody is building.
Good Solution: truth in Postgres, memory as a cache of it
  • Every join was written to the table before the player got their 202, so a restarted matcher rebuilds its room by reading the WAITING rows.
  • The claim transaction either committed everything or nothing, so the queue itself cannot be half-claimed.
Great Solution: leases for ownership, an outbox for the post-commit gap, heartbeats for ghosts
  • A matcher owns its partition through a lease row in Postgres with an epoch number. If the process dies, the lease expires, a peer takes the partition at the next epoch, and rebuilds the room from the table. Nobody is lost, and a zombie's stale epoch is refused.
  • The database commit is not the finish line. A match can commit and the matcher can die before allocating a server or notifying anyone, leaving K players MATCHED with no game. That is why the claim writes an outbox row: the takeover matcher sweeps unprocessed outbox entries, finishes the allocation call, and re-sends the notifications.
  • Both repeats are safe because each carries the match id: the allocation call uses it as an idempotency key (asking twice returns the same server), and a duplicate push is ignored by the client.
  • Player heartbeats flow to the matcher and live in memory; they are NOT written to Postgres per beat, because 40K waiters beating every 10 seconds would be 4K writes per second, dwarfing the 60-600 joins per second we sized for. On takeover, everyone gets a fresh grace period.
  • When the beats stop, the matcher cancels that player with the same CAS as a user cancel. The point is to evict them BEFORE a match forms around them, so K-1 real players never start a game with a ghost teammate.
TRIGGER: "what about players in different regions" / "paid users skip the line" / "one game goes viral" / "match on more than skill"

4) Regional matching, activity, priority, and the viral game

ONE RULE + 3 EXTENSIONS · match local first, ownership moves only on acknowledgement; each extension reuses an existing mechanism instead of adding one
  • Regional: region is part of the partition key, so matching starts local. When a player has waited long enough, their home matcher offers them to the adjacent region's matcher and keeps ownership (and the deadline) until an acknowledgement comes back; only then does the hand-off complete. A lost offer therefore costs nothing: the player simply stays home and the offer repeats next tick. The CAS referee backstops the brief window where both rooms know the player.
  • Activity level (how recently and how much someone plays): make matching distance a weighted sum of skill difference and activity difference, compared against the same widening threshold. One mechanism handles any number of dimensions.
  • Priority (paid tiers, tournament re-queues): give those players a head start on the queue clock, capped at about 30 seconds, and leave their 5-minute deadline untouched. The cap is the no-starvation argument, and the deadline index from HLD-3 is what keeps the promise intact while the queue order bends.
  • The viral game: check numbers before designing. Even if one game somehow carried the entire system's peak of 600 events per second, that is light work for one thread. If it ever truly breaks, split that one game's room into skill bands with FIFO inside each band.
  • One product decision to surface: a player can queue for two different games at once and win both. If the product wants exclusivity, a MATCHED push triggers cancels of the player's other WAITING requests; if not, it is allowed and documented.
TRIGGER: "what changes if joins hit 100K QPS"

5) What changes if joins hit 100K QPS?

REDO THE MATH FIRST · the biggest flow is heartbeats, and it already bypasses the database
  • Redo the scale math before touching the architecture, because every choice so far leaned on the numbers being small. 100K joins per second is a different planet from our 300-600 peak: the waiting population becomes arrival rate times average wait, so 100K/s x 30-60s means 3-6M players waiting at once. Their state is still only about 1GB in aggregate, so memory is not the problem. Write throughput is.
  • Hunt for the biggest number first, because it is not joins. If every waiting player heartbeats every 10 seconds, that is 300-600K heartbeats per second arriving, three to six times the join rate. Deep dive 3 already keeps heartbeats out of Postgres (they live in matcher memory), and at this scale that decision stops being a nicety and becomes the design: the biggest flow never touches the database. What remains is to carry it through the front door and size every tier for it.
  • The downstream numbers, using K=5: about 20K matches per second, so 20K server allocations per second, 100K MATCHED pushes per second, and 3-6M concurrent WebSocket connections that all want an answer within a second or two.
  • Say this out loud: the problem was correctness at small scale; at 100K QPS it is correctness AND throughput, and the win condition is keeping the exactly-one-outcome machinery while spreading the writes.
gm5
Bad Solution: buy a bigger Postgres and add read replicas
  • Replicas absorb reads, but this workload is writes: 100K join inserts, about 20K claim transactions touching K rows each, plus cancels, every second. All of it still lands on the one primary and its one WAL, and a single well-tuned primary tops out somewhere in the tens of thousands of small transactions per second. The write volume is past the wall even with heartbeats already kept out of the database.
  • The synchronous standby we rightly insisted on adds a network round trip to every commit, so the latency floor rises exactly when the volume explodes.
  • Vertical scaling also leaves one blast radius: when that primary fails over, every waiting room in the world stalls at once.
Good Solution: shard on the key we already partition by
  • Shard Postgres by hash of (game, region), the same key the matchers already partition on. This is the load-bearing observation: outside the adjacent-region case, all K players of a match live in the same (game, region) partition, so the guarded claim transaction from deep dive 1 stays single-shard. The CAS referee survives sharding untouched.
  • With 64 shards, each shard sees roughly 1.6K inserts per second plus a few hundred claim transactions touching K rows each, plus cancels. That is comfortable for a primary with a synchronous standby, and now a failover stalls 1/64th of the world instead of all of it.
  • Heartbeats stay exactly where deep dive 3 put them: in matcher memory, never in Postgres. Nothing migrates here; there is only a decision to defend when someone suggests persisting liveness for the failover case, and the takeover grace period from deep dive 3 already covers it.
  • The outbox and its sweeper go per-shard, and the pub/sub layer shards by userId. The one cross-shard case is the adjacent-region hand-off from deep dive 4: keep it an ownership hand-off, and form the rare cross-region match as two single-shard steps driven by the outbox (reserve the player at home, then form the match), never a distributed transaction. The reserve step is the same guarded CAS with the forming matchId stamped, so a crash between the two steps is the familiar outbox-crash story from deep dive 1: the sweeper finishes the match or frees the player.
Great Solution: the shards plus a number and a bulkhead for every tier
  • API tier: stateless, so it is arithmetic, but count the real ingress: 100K joins plus 300-600K heartbeats plus cancels is 400-700K requests per second, and heartbeats are most of it. The staff move is to take heartbeats off HTTP entirely: 3-6M players are already holding WebSockets, so liveness rides the socket's ping/pong at the gateway for free, and the API tier sizes for the remaining roughly 100K rps, about 100 nodes at 1K each.
  • Skill API: asked once per join is now 100K reads per second against someone else's service. The snapshot-once rule already saved us from polling; add a short-TTL read-through cache and batch the lookups, and keep the estimated-skill fallback for when it degrades.
  • Join ingestion: joins fan into rooms through a partitioned log keyed by (game, region). The log is transport, not truth: failover recovery still rebuilds the room from the table, exactly as deep dive 3 says.
  • Matchers: the model does not change, one single-threaded loop per room. Call it 10K active rooms at this scale, ten times today's thousand; that averages 10 joins per second per room, which is nothing. Raw heartbeats would be the real message load (a viral room with half a million waiters would take tens of thousands of beats per second into one loop), so gateways aggregate liveness into a per-room digest every few seconds and the loop consumes digests, not beats. The viral-room math from deep dive 4 does change: at 10K joins per second one room holds 300-600K waiting players; the per-tick work is the oldest anchor's window scan, not a full pass over the room, and if that scan ever stops fitting the 500ms tick, the room splits into skill bands with FIFO inside each band.
  • Gateways: 3-6M sockets at about 50K per node is 60-120 nodes. The failure that matters here is the reconnect storm after an AZ loss, so reconnects carry jitter and a resume token, and a reconnect never re-joins a queue, it re-attaches to an existing requestId.
  • Allocation: 20K matches per second against a pre-warmed server pool. While the pool has headroom the call stays on the critical path for latency; when it runs dry, the outbox absorbs the burst and notification lags instead of matches failing.
  • Bulkheads and alarms: a degraded shard 503s joins for its own rooms and touches nothing else, and the alarm is per-room p99 time-to-match against the 5-minute wall, not a global average that hides one dying shard.
  • Close by naming what survived and what was traded. Survived: exactly one outcome per request (single writer plus CAS, now per shard), the 5-minute wall, and join-order fairness, which was always per-room anyway. Traded: there is no longer one place to query the whole world, so global reporting becomes an async rollup, and cross-region matches now form in two steps instead of one transaction.
FLASHCARDS · THE FIVE HARDEST PROBESshow all (interview mode)
Two matches claim the same player. Show me exactly where that becomes impossible.
In the claim transaction's guarded UPDATE: SET status='MATCHED' WHERE id IN (...) AND status='WAITING', and the row count must equal K or the whole transaction rolls back. Both racing claims target the same WAITING rows; whichever commits first flips them, and the loser's count comes back short, so its match, its player updates, and its outbox row all vanish together. The single writer per (game, region) means in steady state there is nobody to race, but it is an optimization, not the safety: a zombie matcher waking after a lease handover, or an adjacent region reaching for the same player, is stopped by the CAS, and the lease epoch check (FOR SHARE against the takeover's epoch bump) makes the zombie's transaction roll back before it writes anything.
A cancel races the claim. Who wins, and what does each side see?
Cancel and claim are both a compare-and-set on the same WAITING status in Postgres, so whichever commits first wins, full stop. Cancel first: the claim's guarded UPDATE matches K-1 rows instead of K, the transaction rolls back, and the matcher retries without the missing player; the canceller already got their 200. Claim first: the cancel's CAS matches zero rows and the API returns 409 ALREADY_MATCHED, which is honest, because the player is in a formed match with a server on the way. The cancel API does the CAS synchronously so it can answer immediately, then messages the matcher so the in-memory queue evicts that player rather than anchoring on a ghost.
The match commits, then the matcher dies before allocating a server or telling anyone. Now what?
The commit is not the finish line, and the design says so explicitly: the same transaction that claimed the players wrote a match_outbox row meaning "this match still needs a server and notifications". The lease expires, a peer takes the partition at the next epoch, rebuilds its room from the WAITING rows, and sweeps unprocessed outbox entries: it finishes the allocation call and re-sends the pushes. Both repeats are safe because each carries the match id: allocation uses it as an idempotency key, so asking twice returns the same server, and a duplicate push is ignored by the client. K players are never left MATCHED with no game.
Is the 5-minute promise actually guaranteed? Be honest about the bound.
It is enforced by machinery: the deadline is stamped at join and never moves, the matcher indexes requests by deadline as well as join order, and checks the SOONEST deadline every tick, which matters once priority reordering means the front of the queue is not the soonest deadline. When the wall hits, the skill window no longer applies: we still prefer the closest-skill waiters but accept whatever spread we must, and start short-handed or with bots where allowed. The honest bound: 5 minutes plus a small failover slack, lease timeout plus rebuild time, a few seconds, if a matcher dies at the wrong moment. And the synchronous standby means a Postgres failover can never un-commit a match a player was already told about.
What breaks first at 100K join QPS?
Not joins: heartbeats. 100K/s of joins with 30-60s waits means 3-6M concurrent waiters, and at a beat every 10 seconds that is 300-600K heartbeats/sec, three to six times the join rate. The design already keeps them out of Postgres (matcher memory only), so the fix is to carry that through the front door: liveness rides the WebSocket's ping/pong at the gateway, gateways aggregate it into per-room digests, and the API tier sizes for ~100K rps of real requests. The next wall is the single Postgres primary, which tops out in the tens of thousands of small write transactions per second; shard by hash of (game, region), the key we already partition on, so the guarded claim stays single-shard and the CAS machinery survives untouched, with failover blast radius down to 1/64th.
DONE WHEN: you can answer flashcard 1 from memory before opening it, and dives 1, 2, 3, and 5 ended at their Great card with the alternative named and declined.
STEP 5 OF 5

Final design + what is expected at each level

wrap

Final Design

mm-arch-v2
  • Joining snapshots your skill once, stamps a hard deadline, and writes the truth to Postgres.
  • One single-threaded matcher owns each (game, region) room, matches from the oldest matchable player outward with a widening window, retries deeper anchors so no outlier blocks the queue, and claims match + players + outbox in one guarded commit.
  • The soonest-deadline check every tick is what makes the 5-minute promise real. Timeout matches start short-handed or with bots and are recorded as timeouts.
  • After the commit, the matcher gets a server address, publishes the MATCHED event, and gateways deliver it to the right sockets. The outbox sweep finishes anything a crash interrupted.
  • Leases with epochs own partitions; heartbeats evict ghosts before they ruin a match; Postgres runs with a standby because it is the one hard dependency.

What is Expected at Each Level

  • Mid-level candidates are expected to produce a working queue, matcher, and timeout path, and to know the skill API should be asked once at join rather than polled during matching.
  • Senior candidates are expected to nail the exactly-one-match machinery (the guarded claim, the cancel race, the outbox for the post-commit crash), keep truth in the table with memory as a cache, and state the widening window as an actual curve.
  • Staff candidates are expected to open with the scale math that turns this into a correctness problem, to name both alternatives (SKIP LOCKED fleets and batch optimization) and decline them with reasons, to spot head-of-line blocking in their own greedy design, to treat the timeout fraction as the product dial, to drive the exactly-one-match probe instead of waiting for it, and, when the interviewer moves the goalposts to 100K QPS, to redo the scale math, notice the biggest flow (heartbeats) already bypasses the database, and size every tier for it before adding hardware.
DONE WHEN: you can point at the final diagram and name, in one breath, the single writer, the guarded claim, the deadline wall, and the outbox sweep.
APPENDIX A

Moving the candidate pool into Redis

reference, not one of the 5 steps
Read this before the three appendices, or they look like a contradiction
The board above is Postgres-first on purpose, and it is the right answer at the scale Step 1 sized. These three appendices are the Redis-shaped version of the same system, which is what deep dive 5 opens the door to once the numbers move. Two things to hold in your head:
  • The threshold is the whole judgment. Under roughly a few thousand concurrent queuers per region, the plain Postgres loop wins and Redis is complexity you are not being paid for. Say the number out loud so this reads as a call, not a reflex.
  • Epoch and fencing token are the same idea in two stores. Deep dive 3 already gives the Postgres lease an epoch so a stale owner's claims are recognizably stale. Appendix B is that exact mechanism written in Redis. If you can say that sentence, you have shown you understand the mechanism rather than two memorised recipes.
TRIGGER: "the matchmaker rescans the whole waiting table every 500 milliseconds"
TWO SETS, ONE MEMBER SET · Postgres stays the record, Redis is a derived index
OPENING LINE

"The design works, and the first thing that breaks is the matchmaker loop. Every tick it does a range scan over every waiting row, and that scan grows with queue depth while the tick rate stays fixed. I want to keep Postgres as the durable record and move the hot candidate search into Redis."

gm-apx1

A1) What the poll costs today, and what actually changes

  • Price the current loop before replacing it.
    • Each 500ms tick runs an ordered scan to find the oldest waiter, a range scan over skill rating, and a second scan when the first window comes up short.
    • At a queue depth of 50,000, every one of those touches a large index on a table that is simultaneously taking inserts from joins and updates from cancels.
    • The scan grows with depth while the tick rate stays fixed, which is the shape of the problem: the work per tick is unbounded and the time per tick is not.
  • What changes, stated narrowly. Redis holds the live pool. Postgres still records every join, every cancel and every formed match.
    • So nothing about correctness or recovery depends on Redis surviving. That sentence is the one that keeps the dive out of trouble.

A2) Structure: two sorted sets over one member set, plus a hash

  • One sorted set is not enough, because the matchmaker needs two orderings at once.
    • It picks the anchor by longest wait, and it searches candidates by skill rating.
    • A sorted set gives you exactly one score dimension, so you keep two of them over the same member set.
  • A hash alongside holds the per player facts that the two sets cannot carry.
    • Region and mode are what a cancel needs in order to build the two key names it must clean.
    • State is what the client is told it is in, and an expiry on the hash stops abandoned entries leaking.

Every value below comes from the durable join row, written to Postgres before Redis is touched.

-- SOURCE of every value below: the durable join row
SELECT player_id, mmr, enqueued_at
  FROM matchmaking_request WHERE player_id = 'p:9182';
 player_id | mmr  | enqueued_at
 p:9182    | 1450 | 1755530400000

# mm:pool:na:ranked  -  Sorted Set  -  score = MMR
# ordered low to high by rating, which is what a window scan walks
ZADD mm:pool:na:ranked 1450 p:9182
ZRANGE mm:pool:na:ranked 1400 1500 BYSCORE LIMIT 0 50 WITHSCORES
ZREM mm:pool:na:ranked p:9182

# mm:enq:na:ranked  -  Sorted Set  -  score = enqueue time, epoch ms
# ordered oldest to newest, so index 0 is always the anchor
ZADD mm:enq:na:ranked 1755530400000 p:9182
ZRANGE mm:enq:na:ranked 0 0
1) "p:9182"
# mm:player:9182  -  Hash  -  per player facts
# tells cancel which two keys to clean, and tells the client its state
# region, mode and mmr are columns of the same join row.
# 3600 is a chosen policy constant, not a fetched value.
HSET mm:player:9182 region na mode ranked mmr 1450 state QUEUED
(integer) 4          # how many NEW fields it created

HGETALL mm:player:9182
1) "region"   2) "na"
3) "mode"     4) "ranked"
5) "mmr"      6) "1450"
7) "state"    8) "QUEUED"
# a FLAT alternating list: field, value, field, value.
# every value returns as a STRING, including the mmr, so the
# caller parses "1450". The sorted set score is genuinely
# numeric; this field is not.
# fields 2 and 4 are what build the two key names a cancel needs.

EXPIRE mm:player:9182 3600
(integer) 1          # 1 means the timeout was set

A3) The search, worked twice so the widening is not hand waved

  • State the window formula up front. window(w) = 50 + 25 * floor(w / 5s), capped at 400 rating points, where w is how long the anchor has waited. Match size is 4 for this example.
  • Only two of the six steps are Redis calls per axis. The rest is arithmetic, and saying which is which is what stops the trace sounding like magic.
# PASS 1, tick clock 1755530400000

# STEP 1 - who is the anchor, and when did they join?
# ENQ set. 0 0 = POSITIONS, not scores: no BYSCORE, so this
# means "first element by rank" = oldest waiter.
ZRANGE mm:enq:na:ranked 0 0 WITHSCORES
1) "p:9182"
2) "1755530400000"

# STEP 2 - derive the wait (arithmetic, no Redis call)
#   w = 1755530400000 - 1755530400000 = 0 ms

# STEP 3 - derive the window half-width from w
#   window = 50 + 25 * floor(0 / 5000) = 50

# STEP 4 - the anchor's rating. THIS is where 1450 comes from.
# ZSCORE reads the POOL set, because rating lives there.
ZSCORE mm:pool:na:ranked p:9182
"1450"

# STEP 5 - derive the bounds (arithmetic)
#   low = 1450 - 50 = 1400 ; high = 1450 + 50 = 1500

# STEP 6 - read the window
# POOL set. 1400 1500 = SCORES, because BYSCORE is present.
# Different key, different axis, same command name as STEP 1.
# LIMIT takes offset then count: skip 0, return at most 50.
ZRANGE mm:pool:na:ranked 1400 1500 BYSCORE LIMIT 0 50 WITHSCORES
1) "p:7735"  2) "1408"
3) "p:9182"  4) "1450"
5) "p:4471"  6) "1490"       # 3 found, need 4

Three candidates is short of four, so the tick forms nothing and the anchor stays in the pool.

# PASS 2, tick clock 1755530405000. Same six steps, only the clock moved.

ZRANGE mm:enq:na:ranked 0 0 WITHSCORES
1) "p:9182"
2) "1755530400000"          # anchor unchanged, still the oldest

#   w = 1755530405000 - 1755530400000 = 5000 ms
#   window = 50 + 25 * floor(5000 / 5000) = 75

ZSCORE mm:pool:na:ranked p:9182
"1450"                      # read again rather than assumed

#   low = 1450 - 75 = 1375 ; high = 1450 + 75 = 1525

ZRANGE mm:pool:na:ranked 1375 1525 BYSCORE LIMIT 0 50 WITHSCORES
1) "p:7735"  2) "1408"
3) "p:9182"  4) "1450"
5) "p:4471"  6) "1490"
7) "p:6013"  8) "1523"       # 4 found, enough
  • The match forms with p:7735, p:9182, p:4471 and p:6013. Ratings 1408, 1450, 1490 and 1523, a spread of 115 points.
    • p:2210 at 1612 and p:8890 at 1701 stay in the pool, and p:2210 becomes the next anchor on the following tick.
  • Why anchor by wait time rather than sweeping the rating axis.
    • Scanning rating alone starves whoever sits at a sparse part of the rating curve.
    • Anchoring on the longest waiter puts a ceiling on queue time, and the widening window is what pays for that ceiling in match quality.
    • This is the same mechanism as deep dive 2, moved to a different store. Nothing about the fairness argument changes.

A4) The claim: check and remove in one Lua script

  • Reading candidates and removing them are two round trips, and the gap between them is the bug. Another worker or a cancel can take a player in that gap.
  • Redis runs a Lua script as a single atomic unit against the whole keyspace, so putting the check and the removal inside one script closes the gap with no lock.
-- KEYS[1] = mm:pool:na:ranked
-- KEYS[2] = mm:enq:na:ranked
-- ARGV    = the candidate player ids
for i = 1, #ARGV do
  if redis.call('ZSCORE', KEYS[1], ARGV[i]) == false then
    return 0          -- someone is gone, claim nothing
  end
end
redis.call('ZREM', KEYS[1], unpack(ARGV))
redis.call('ZREM', KEYS[2], unpack(ARGV))
return #ARGV        -- all claimed
# register once. Redis returns the SHA1 of the script body,
# which is what EVALSHA takes. It is computed, not invented.
SCRIPT LOAD "<the exact script body above>"
"a4095851f126b1312fa5253c3176d2d41b165a71"

# the 4 ids below are NOT chosen here. They are exactly the
# members STEP 6 of pass 2 returned. The 2 is the key count,
# so KEYS[1] and KEYS[2] follow it.
EVALSHA a4095851f126b1312fa5253c3176d2d41b165a71 2 \
  mm:pool:na:ranked mm:enq:na:ranked \
  p:7735 p:9182 p:4471 p:6013
(integer) 4          # equals the 4 ids passed in, so the group is yours
The property worth naming out loud
Because the script is atomic there is no rollback path to write. A partial claim is not reachable: the script either finds every member present and removes them all, or it finds one missing and removes nothing. That is the whole reason the check sits inside the script instead of in the worker.

A5) Cancel races the claim, and that is fine

  • Cancel is the same shape: one atomic script touching the same two keys, so Redis serializes cancel against claim for free.
  • The design work is not preventing the race. It is deciding what the player is told.
-- cancel script
local n = redis.call('ZREM', KEYS[1], ARGV[1])
redis.call('ZREM', KEYS[2], ARGV[1])
return n
  • Returns 1. Cancel won the race. Flip the Postgres row to CANCELED and confirm to the client.
  • Returns 0. The claim already removed them, so a match exists with this player in it.
    • Return 409 Conflict carrying the match identifier rather than a bare failure, and let the client jump straight to the accept screen.
Do not skip this
Returning a plain success on the zero case is the bug that leaves a player sitting on a canceled screen while a game server holds a slot for them.

A6) Durability: Redis is the working set, not the record

Every state change writes Postgres. Redis is a derived index that exists to make the search cheap.

EventPostgresRedis
joininsert row QUEUEDZADD to both sets, HSET facts
cancelupdate to CANCELEDZREM from both sets
matchinsert match, update players MATCHEDalready removed by the claim
  • If Redis is lost entirely, the pool rebuilds from the durable rows. No player is dropped, they just take one extra tick to reappear.
  • Write ordering matters, and it only goes one way.
    • Insert the Postgres row first, then add to Redis. A crash between them leaves a player queued but invisible, which the rebuild sweep fixes.
    • The reverse ordering leaves a player matchable with no durable record, which nothing fixes.
-- rebuild source of truth
SELECT player_id, mmr, enqueued_at
  FROM matchmaking_request
 WHERE status = 'QUEUED'
   AND region = 'na' AND mode = 'ranked';

 player_id | mmr  | enqueued_at
 p:9182    | 1450 | 1755530400000
 p:2210    | 1612 | 1755530401500
 p:8890    | 1701 | 1755530404000

# replay every returned row into both sets. Each score below is
# a column from the result above, nothing is recomputed.
ZADD mm:pool:na:ranked 1450 p:9182
ZADD mm:enq:na:ranked 1755530400000 p:9182
ZADD mm:pool:na:ranked 1612 p:2210
ZADD mm:enq:na:ranked 1755530401500 p:2210
ZADD mm:pool:na:ranked 1701 p:8890
ZADD mm:enq:na:ranked 1755530404000 p:8890

A7) What you bought and what you owe

Postgres pollRedis pool
anchor readordered index scanO(log N) rank read
window readrange scan on a hot tableO(log N + M)
tick loadhits primary, competes with writesseparate box, no write contention
new failurenoneRedis and Postgres can disagree
new worknonerebuild sweep, drift reconciler
  • What you bought is a search that no longer competes with writes. Both reads go from a scan on a hot table to a logarithmic read on a separate box.
  • What you owe is a reconciler, and it is the honest price. Run a periodic sweep comparing QUEUED rows older than a threshold against sorted set membership, and repair either direction.
    • Without it, a dropped write becomes a player who queues forever, which is the exact failure the Postgres design could not produce.
DONE WHEN: you have said the threshold number, named Postgres as the record, and shown the claim script as the reason no rollback path exists.
APPENDIX B

Running many matchmaker workers

reference, not one of the 5 steps
TRIGGER: "one matchmaker is a single point of failure and a throughput ceiling"
3 LAYERS · partition removes contention, lease moves ownership, fence rejects the stale owner
OPENING LINE

"One matchmaker means a deploy or a crash stops matchmaking globally. The moment I add a second one they both read the same anchor and try to claim overlapping players, so I need to decide whether workers avoid each other by partition or resolve conflicts after the fact."

  • Both mechanisms are needed, for different reasons.
    • Partitioning is the primary strategy because it removes contention instead of resolving it.
    • Optimistic claiming is the safety net that keeps correctness during the seconds when partition ownership is ambiguous, which is exactly when a worker dies or a deploy rolls.
The failure to design against
Two workers both claim player p:9182 into two different matches. The player gets two session addresses, one game server sits with an empty slot, and the rating update path now has two conflicting records.

B1) Mechanism 1, partition: shard by region and mode

  • The partition key is region:mode. Each partition owns its own pair of sorted sets, so two workers on different partitions never touch the same key.
PartitionKeys owned
na:rankedmm:pool:na:ranked mm:enq:na:ranked
na:casualmm:pool:na:casual mm:enq:na:casual
eu:rankedmm:pool:eu:ranked mm:enq:eu:ranked
  • Do not shard on skill rating. This is the part interviewers are actually listening for.
    • A rating band boundary is a wall the widening window can never cross, so a player sitting just under a boundary at a quiet hour waits forever while a perfectly good opponent sits just above it.
    • Region and mode are natural walls, because a cross region match is genuinely undesirable. A rating wall is an artifact of your sharding scheme, not of the product.
    • If you must split a rating axis, use overlapping bands (1400 to 1600 and 1550 to 1750 sharing a seam) and let the atomic claim from A4 settle the case where two workers reach into the overlap.

B2) Mechanism 2, ownership: leases so a partition has one live owner

  • Static configuration is simplest, and it leaves a partition dark when its worker dies. Nobody matches in that region until a human notices.
  • A lease in Redis gives automatic failover with no extra coordination service. One key per partition, taken only if absent, and held open by renewal.
# acquire: NX means set only if the key does not exist
# PX 10000 is a 10 second TTL
SET mm:lease:na:ranked worker-3 NX PX 10000
OK                    # nil means another worker owns it

GET mm:lease:na:ranked
"worker-3"

-- renew every 3s, only if still the owner
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
  return 0        -- lost it, stop working this partition
end
  • Renew at roughly one third of the TTL, so two renewals can fail before ownership moves.
  • Worker death costs at most one TTL of queue delay on that partition, not a global outage. That is the number to say when asked what a crash costs.

B3) The gap leases alone leave open: fencing tokens

  • A lease bounds how long a worker believes it owns a partition. It does not bound how long a stalled worker takes to notice.
    • A long garbage collection pause, a network partition, or a paused container all leave worker-3 waking up after its lease expired and worker-7 took over.
    • worker-3's next claim is a perfectly valid Redis command. Nothing about it looks wrong from Redis's side.
  • The fix: every ownership change increments a counter, and the claim script refuses any token that is not current. This is deep dive 3's Postgres lease epoch, written in Redis.
# worker-3 acquired the lease first, so it INCRs first.
INCR mm:fence:na:ranked
(integer) 41          # worker-3 holds token 41

# later, worker-7 takes over and INCRs the same counter.
# 42 is not chosen, it is 41 + 1 returned by Redis.
INCR mm:fence:na:ranked
(integer) 42          # worker-7 holds token 42

-- claim script, fenced version
-- KEYS[3] = mm:fence:na:ranked, ARGV[1] = caller's token
if redis.call('GET', KEYS[3]) ~= ARGV[1] then
  return -1         -- stale owner, reject before touching the pool
end
for i = 2, #ARGV do
  if redis.call('ZSCORE', KEYS[1], ARGV[i]) == false then
    return 0
  end
end
redis.call('ZREM', KEYS[1], unpack(ARGV, 2))
redis.call('ZREM', KEYS[2], unpack(ARGV, 2))
return #ARGV - 1
gm-apx2
  • t = 0sworker-3

    Holds the lease on na:ranked with fencing token 41. Reads the anchor p:9182 and a candidate window.

  • t = 1sworker-3

    Enters a 9 second garbage collection pause. Renewals stop. It does not know it has stopped.

  • t = 10sredis

    The lease key expires on its own TTL. The partition is now unowned.

  • t = 11sworker-7

    SET NX succeeds, INCR returns token 42. Takes over, forms a match containing p:9182, and the claim returns 4.

  • t = 12sworker-3

    Wakes up mid loop, still believing it owns the partition, and calls claim on its stale candidate list. Without fencing this is where p:9182 gets double booked.

  • t = 12sworker-3

    With fencing, the script compares token 41 against the current 42 and returns -1 before touching the pool. worker-3 drops its candidates, re-reads the lease, finds it lost, and stops working the partition.

B4) Load shape: hot partitions and the starved sub-pool

  • Partitions are not equal. North America ranked can carry the majority of concurrent players while another partition carries a rounding error, and one owner per partition means one worker doing nearly all the work.
  • Split the hot one by hashing the player identifier into sub-pools, each with its own owner.
# derive the shard number (arithmetic, no Redis call)
#   crc32("p:9182") = 649320882
#   649320882 mod 4 = 2   -> sub-pool 2 of 4
# scores are the same two columns from the join row, unchanged
ZADD mm:pool:na:ranked:2 1450 p:9182
ZADD mm:enq:na:ranked:2 1755530400000 p:9182
# each sub-pool leases and fences independently
SET mm:lease:na:ranked:2 worker-5 NX PX 10000
INCR mm:fence:na:ranked:2
The problem splitting creates
Splitting one pool into four quarters the candidate density each worker can see. A rare rating now has a quarter of the neighbors it had, so the players hardest to match get slower, which is the opposite of what you wanted.
  • Fix it with an overflow pool. Any player whose wait crosses a threshold is promoted out of their sub-pool into one undivided pool that a single dedicated worker drains with a wide window.
    • Density where it matters, parallelism everywhere else.
    • The promotion runs as one Lua script for the same reason the claim does, so a player is never in two pools and never in neither.
# READ both scores first, because the ZREM destroys them and
# the overflow pool has to be rebuilt from the real values
ZSCORE mm:pool:na:ranked:2 p:9182
"1450"
ZSCORE mm:enq:na:ranked:2 p:9182
"1755530400000"

ZREM mm:pool:na:ranked:2 p:9182
ZREM mm:enq:na:ranked:2 p:9182

# re-add with the two scores just read, so the original wait
# survives the move and the player keeps their place
ZADD mm:pool:na:ranked:overflow 1450 p:9182
ZADD mm:enq:na:ranked:overflow 1755530400000 p:9182

B5) The three layers, and what each one is actually for

LayerMechanismPreventsDoes not prevent
Partitionregion:mode keys, sub-pools for hot partitionsSteady state contention. Two healthy workers never read the same keys.Anything during ownership handover.
LeaseSET NX PX, renew at one third of TTLA dead worker leaving its partition dark, and two workers deliberately starting on one partition.A stalled worker that wakes up believing it still owns the partition.
FenceINCR on handover, token checked inside the claim scriptThe stalled worker's late claim. It is rejected before any member is removed.Nothing relevant. This is the backstop.
  • Partition is the only layer that removes work rather than resolving it. Two healthy workers on different partitions never read the same keys, so in steady state there is no contention to arbitrate.
  • The lease exists for the handover, not for the steady state. It bounds how long a partition can sit unowned after a worker dies, and it stops two workers deliberately starting on one partition.
  • The fence exists for the one case the lease cannot see. A stalled worker that wakes up still believing it owns the partition, whose next claim Redis has no reason to refuse.
  • Say which layer is correctness and which is optimization. The fenced claim script is the correctness mechanism. Partitioning is a contention optimization on top of it, exactly as the single-writer matcher is in deep dive 1.
DONE WHEN: you have said why rating is the wrong shard key, and named the fence as the thing the lease cannot do.
APPENDIX C

From a formed match to a player connected to a server

reference, not one of the 5 steps
TRIGGER: "the claim succeeded, now what does the player actually connect to"
EVERY BRANCH LANDS THEM · on a server, or back in the pool with their original enqueue time
OPENING LINE

"A formed match is a row, not a game. Between the claim and the player connecting there is a server to allocate, an acceptance step, and a notification that has to survive a dropped connection. Each of those can fail after the players have already left the pool, so each needs a defined way back."

  • The ordering constraint is the whole reason this section exists. Players are already out of the pool when allocation runs.
    • Any failure after this point strands them, so every branch below either lands them on a server or puts them back in the pool with their original wait time intact.
gm-apx3

C1) Capacity: a warm fleet, never a cold boot

  • Booting a game server process takes seconds to tens of seconds. That is not a cost you can pay while a player watches a queue spinner, so servers are started before they are needed and held ready.
  • Fleet per region. A pool of processes already running and idle, registered with an allocator such as Agones on Kubernetes.
  • Buffer sizing. Keep ready capacity above peak concurrent match formation rate multiplied by boot time, with headroom.
    • An undersized buffer means allocation failures during exactly the traffic spike you built it for.
  • Scale on the buffer, not on CPU. The autoscaler watches how many ready servers remain, because CPU utilization on idle servers tells you nothing about whether the next match can be placed.
Cost reality
A warm buffer is idle machines you are paying for. Size it per region on that region's own peak, and let it shrink overnight rather than carrying a global constant.

C2) Allocation, and the branch when it fails

  • The matchmaker asks the allocator for one ready server in the match's region and gets back an address and a session identifier.
    • It writes the match and the session together in a single Postgres transaction, so a match row never exists without the address its players need.
  • When no server is available, there are exactly three moves and one of them is wrong.
    • Retry within region a small number of times with backoff, since the autoscaler may land capacity within a second or two.
    • Then return everyone to the pool with their original enqueue timestamp, not the current time. They keep their place, so a failed allocation does not silently punish the longest waiter.
    • Do not silently place cross region. A player who queued for North America and lands on a European server gets a worse game than a longer queue would have given them. Offer it as a choice on the client after a long wait rather than deciding for them.
-- the claim already removed these players from Redis, so the
-- scores are gone. Re-read them from the durable row.
SELECT player_id, mmr, enqueued_at
  FROM matchmaking_request WHERE player_id = 'p:9182';
 player_id | mmr  | enqueued_at
 p:9182    | 1450 | 1755530400000

# return to the pool using enqueued_at, NOT the current clock.
# That is what preserves their place in line.
ZADD mm:pool:na:ranked 1450 p:9182
ZADD mm:enq:na:ranked 1755530400000 p:9182

C3) The accept window, and what a decline costs

  • Players go idle in queue. Sending four people to a server when one has walked away produces a broken game, so the match holds in a PENDING_ACCEPT state with a fixed window, usually around ten seconds.
  • t = 0sserver

    Match created PENDING_ACCEPT. Server is allocated and held. The accept prompt is pushed to all four players.

  • t = 3sall four

    Accept. The match moves to READY, connection details are released, and the server stops being held.

  • t = 10sp:6013

    The window expires with no response. The match moves to DISSOLVED.

  • t = 10sserver

    The allocated server is released back to the ready fleet rather than left held.

  • t = 10sthe three

    Re-enqueued with their original enqueue timestamps, so they are near the front and are matched again quickly.

  • t = 10sp:6013

    Re-enqueued with a current timestamp and a short cooldown. Repeated non-acceptance is what the penalty is aimed at, not one missed prompt.

Release, or leak
If the dissolve path forgets to release the held server, every declined match permanently shrinks the warm fleet. Give held allocations their own timeout on the allocator side, so the fleet self heals even when the matchmaker crashes mid dissolve.

C4) Notification: delivering the match without losing it

  • Pushing directly from the matchmaker means a player whose connection dropped one second earlier never learns they were matched, while their server sits held until the accept window expires.
  • Write the event in the same transaction as the match, then deliver it separately. This is the transactional outbox, the same mechanism deep dive 3 uses for the post-commit gap.
# STEP 1 - ask the allocator for one ready server in the region.
# The address is RETURNED here. It is not known before this call.
POST /allocate  { "fleet": "na-ranked", "region": "na" }
200 { "session_addr": "10.4.2.19:7777",
      "match_id": "m:5521" }

# STEP 2 - both values below are fields of that response.
# The player list is the same 4 ids the claim returned.
BEGIN;
  INSERT INTO match (id, session_addr, state)
    VALUES ('m:5521', '10.4.2.19:7777', 'PENDING_ACCEPT');
  UPDATE matchmaking_request SET status = 'MATCHED'
    WHERE player_id IN ('p:7735','p:9182','p:4471','p:6013');
  INSERT INTO outbox (topic, payload)
    VALUES ('match.found', '{"match":"m:5521","addr":"10.4.2.19:7777"}');
COMMIT;
  • A relay reads the outbox and publishes to the gateway holding player connections. If publishing fails, the row is still there and gets retried.
  • The gateway pushes over a persistent connection, a WebSocket or Server-Sent Events stream, opened when the player entered the queue rather than at match time.
  • The client also polls the match status endpoint as a fallback, because a push you cannot confirm is not a delivery.
    • On reconnect the client asks for its current state and picks up the pending accept it missed.
  • Delivery is at least once, so the client must tolerate the same match event arriving twice. Key the handler on match identifier and ignore repeats.
DONE WHEN: you can name what happens to the held server on every branch, and say which re-enqueue keeps the original timestamp and which does not.