Design a Matchmaking Service
Multiplayer Game Matchmaking System Design
Overview
A matchmaking service takes a stream of players who each pressed "Play" and, within seconds, groups them into balanced multiplayer sessions. A player joins the queue; the system finds others of similar skill, splits them into two teams of a fixed size (commonly 16), provisions a fresh dedicated game server, and hands everyone the server's address to connect. The click is simple; keeping matches fair and fast while the player pool swells at peak and thins out at 3am is not.
It is "Hard" because the core is a live tension between two goals that pull in opposite directions: matches should be fair (players of similar skill, balanced teams, playable network latency) and fast (nobody waits more than a few seconds). Tighten fairness and wait times grow; loosen it and matches become lopsided. On top of that you must find N compatible players out of a huge pool without scanning everyone, place each player into exactly one match (never two), and drop them onto a freshly provisioned server without a slow cold start.
In this walkthrough you'll scope the system, size it, model the ticket and the match state machine, design the API, draw the enqueue and matching/allocation paths, and go deep on skill-based matching and the fairness-vs-wait-time tradeoff, the matching pipeline at scale, and game-server allocation with an exactly-once commit.
Out of scope (state it): the in-game netcode and physics (what happens after players connect to the server), the anti-cheat system, voice chat, and the long-term progression/ranked-ladder UI. We keep only the placement of players into fair sessions and the allocation of a server for each.
Functional requirements
- Join the queue. A player (or a pre-made party) joins the queue for a game mode from a region, carrying a skill rating.
- Form a balanced match. Group compatible players into a match of fixed size (e.g. 32 total = two teams of 16), skill-similar and split into two teams of roughly equal strength.
- Allocate a game server. For each formed match, hand out a freshly provisioned dedicated server and give every player its address to connect to.
- Keep wait times low even as skill differs. If no perfect match exists, widen the acceptable skill range over time so nobody waits forever.
- Leave the queue / handle failure. A player can cancel while queued; if a matched player fails to connect or declines, recover the others (backfill or requeue) rather than stranding them.
- Place each player exactly once. A queued player is committed into one match only — never two matches at once.
Out of scope (state it): the ranking algorithm that updates skill after a match (we consume a rating, we don't recompute the ladder here), cosmetic lobbies, and tournament brackets. Naming these keeps interview time on matching and allocation.
Non-functional requirements
- Low wait time. A player should be matched within a few seconds under normal load — target a p50 of a few seconds and a bounded worst case (the widening window guarantees it never grows unbounded).
- Fairness / match quality. Matched players should be close in skill and teams close in total strength, subject to the wait-time budget. Fairness and wait time trade against each other — this is the headline.
- Latency as a near-hard constraint. Never match players who would get unplayable ping. Bucket by region/data-center first; skill can flex, geography barely can — relax only to adjacent DCs with acceptable ping, never further.
- Exactly-once placement. A player is committed into exactly one match. Two matches claiming the same player is a correctness bug, like double-booking a seat.
- Elasticity under surge and shrink. The queue rate swings enormously between prime time and 3am. Matching must scale out at peak and degrade gracefully (wait a bit longer, widen the band) when a pool thins.
- High availability. A crash in one pool's matcher or one server allocation must not stall the whole service.
Tip
State the fairness-vs-wait-time tradeoff and the region-as-near-hard-constraint rule (relax only to adjacent DCs with acceptable ping) in the first minute. They frame every later decision — which skill band to accept, how to partition the queue, and what you relax under load.
Estimations
State assumptions; the goal is to justify per-pool matchers, a warm server pool, and the widening skill band — not to be exact.
Rounded assumptions
- Concurrent players online at peak: ~2M. Of those, some fraction are in queue at any instant; the rest are already in a match.
- Join rate: if the average session is ~20 min and 2M players cycle through matches, roughly ~2M / 20 min ≈ ~1.7K players/sec enter the queue at peak, in round numbers ~1–2K joins/sec.
- Match size: 32 players/match (two teams of 16). So ~1.7K / 32 ≈ ~50 matches formed/sec at peak, each needing a server.
- Servers: ~50 server allocations/sec at peak — but these come almost entirely from the warm pool (recycled hardware), not cold boots. Newly booted servers are only the autoscaler's delta above the recycle rate — roughly ~0/sec in steady state, rising only when demand climbs. With ~20-min matches, ~50 × 1200s ≈ ~60K servers in use concurrently at peak.
- Pools: partition by (region × game-mode × coarse skill bucket). A handful of regions × a few modes × ~10 skill buckets ⇒ hundreds to low-thousands of pools, each holding a small slice of the queue.
- Off-peak: at 3am a single (region × mode × skill) pool may hold only a handful of players — the shrink case that forces widening.
How the numbers affect the design
| Signal from the numbers | Design decision |
|---|---|
| ~1–2K joins/sec, but each match needs N compatible players | Don't scan the global queue — partition into small pools and match locally. |
| Hundreds–thousands of pools, each small | A per-pool matcher runs a cheap local search; pools scale horizontally. |
| ~50 matches/sec each needing a fresh server | Keep a warm pool of ready servers so allocation is instant, not a cold start. |
| Off-peak pools hold a handful of players | Widen skill/region bands over wait time so thin pools still match. |
| A player must join exactly one match | Commit the match by claiming each ticket exactly once (compare-and-set). |
| ~60K servers concurrently | Autoscale the server fleet and the warm buffer ahead of demand. |
Storage. Matchmaking state is small and short-lived. A queue ticket — player/party ids (~32 bytes), skill rating (~8 bytes), region + mode (~16 bytes), enqueue time (8 bytes), current search band (~8 bytes), state (1 byte) ≈ ~100 bytes. How many tickets sit queued at once? By Little's Law, queued ≈ join rate × wait: ~1.7K joins/sec × a few-second wait ≈ a few thousand tickets queued at any instant — well under a MB. This fits in memory easily. The heavy resource is not storage; it's game servers (CPU/RAM per match) and the matching compute that keeps wait times low.
Caution
Do not size this system for storage or raw QPS — both are modest. The difficulty is forming compatible groups fast without a global scan, keeping matches fair as pools surge and shrink, and committing each player into exactly one match. Design for pool-local matching, the widening band, and exactly-once placement first.
Core entities / data model
Three things matter: the ticket (a player or party waiting to be matched), the match (a group being formed and run, with a state machine), and the game server (a provisioned host handed to a match).
| Entity | Key fields |
|---|---|
Ticket |
ticket_id (PK), player_ids[] (1 for solo, N for a party), skill, region, game_mode, enqueued_at, search_band, state (QUEUED/MATCHED/CANCELLED), match_id (nullable — set when claimed into a match, null while QUEUED) |
Pool |
pool_key = (region, game_mode, skill_bucket) — the small partition a ticket lands in; owned by one matcher |
Match |
match_id (PK), ticket_ids[], team_a[], team_b[], server_id, state, created_at |
GameServer |
server_id (PK), address, region, status (WARM/ALLOCATED/IN_USE/DRAINING), match_id |
A ticket represents a unit that must stay together: a solo player is a ticket of one; a 4-friend party is a single ticket of four that must be placed on the same team and balanced against the rest (deep dive 1). A ticket carries its own search_band — the currently acceptable skill range — which starts tight and widens the longer it waits.
The match is the heart of the lifecycle. It moves through legal transitions only:
stateDiagram-v2
[*] --> FORMING
FORMING --> ALLOCATING: "N compatible tickets claimed"
ALLOCATING --> READY: "warm server assigned + address sent"
READY --> IN_PROGRESS: "enough players connected"
READY --> DISSOLVED: "player failed to connect / declined"
IN_PROGRESS --> ENDED: "match finished"
FORMING --> DISSOLVED: "timed out before filling"
DISSOLVED --> [*]: "servers reclaimed, others requeued"
ENDED --> [*]: "server drained + returned to pool"
Two rules make this safe. Each ticket is claimed exactly once as a match moves FORMING → ALLOCATING — the claim is a compare-and-set from QUEUED to MATCHED, so a ticket can never be pulled into two matches (deep dive 2). And the match's state is durable: a crash mid-allocation leaves the match in ALLOCATING, and recovery re-drives it forward or dissolves it — never silently loses the players (deep dive 3).
Important
The Ticket state is the source of truth for whether a player is still available to match; the Match state is the source of truth for whether a session is being built or run. Committing a match means atomically flipping every one of its tickets QUEUED → MATCHED — if any flip fails (someone else grabbed that ticket, or the player cancelled), the whole match attempt backs off and retries with other tickets.
API design
Matchmaking is mostly a queue + push contract, not a set of CRUD endpoints: a client joins, then waits for an asynchronous "you're matched, here's your server" message. We label the request payload Body: and the response Returns:.
Join the queue
POST /api/matchmaking/tickets
Body: { "player_ids": ["p_1"], "game_mode": "conquest_32", "region": "eu-west", "skill": 1420 }
201 Created
Returns: { "ticket_id": "t_88", "state": "QUEUED", "estimated_wait_seconds": 6 }
Create a ticket and place it in the right pool. player_ids is a list so a party queues as one ticket. skill is the player's (or party's aggregate) rating. The server picks the pool from (region, game_mode, skill_bucket) and returns an ETA computed from the pool's recent match rate. The actual match result arrives later over the push channel, so this call returns immediately rather than blocking until a match forms.
Leave the queue
DELETE /api/matchmaking/tickets/{ticket_id}
200 OK
Returns: { "ticket_id": "t_88", "state": "CANCELLED" }
409 Conflict (if already MATCHED — too late to cancel)
Cancel a still-queued ticket. Implemented as a compare-and-set from QUEUED to CANCELLED: if a matcher has already claimed the ticket into a match (MATCHED), the cancel loses cleanly and returns 409 — the player is already being placed, so the client should expect a match message instead.
Get ticket / match status
GET /api/matchmaking/tickets/{ticket_id}
200 OK
Returns: { "ticket_id": "t_88", "state": "MATCHED", "match_id": "m_12", "wait_seconds": 5 }
Read the current state — a fallback for when the push channel reconnects. A MATCHED ticket includes its match_id so the client can look up (or await) the server assignment.
Match result (push channel)
Channel: WebSocket /ws/matchmaking/{ticket_id}
Server → client messages:
{ "type": "searching", "estimated_wait_seconds": 8, "search_band": 100 }
{ "type": "matched", "match_id": "m_12", "team": "A" }
{ "type": "server", "address": "gs-eu-42.example.com:7777", "token": "..." }
{ "type": "dissolved", "reason": "player_failed_to_connect", "requeued": true }
Rather than have the client poll, the server pushes the search progress, the match result, and finally the server address to connect to. The token authorizes this player to join that specific server. If the match dissolves (someone failed to connect), the client is told and — if it was requeued — receives fresh searching messages. A WebSocket is the natural fit because the interesting events are server-initiated and arrive seconds after the join.
High-level architecture
Two paths matter and they have very different shapes: an enqueue path that durably records a ticket into the right pool, and a matching/allocation path where per-pool matchers form matches and hand them servers. We'll walk each, then combine them.
1. Enqueue path (join the queue)
Joining is a small write: pick the pool from region × mode × skill bucket, and add the ticket to that pool's queue.
flowchart LR
Client["Player / party client"] -->|"join: mode, region, skill"| MM["Matchmaking Service"]
MM -->|"pool_key = region × mode × skill_bucket"| Router["Pool Router"]
Router -->|"add ticket (state=QUEUED)"| Pool["Pool queue<br/>(one small partition)"]
MM -->|"ticket_id + ETA"| Client
Client -.->|"open push channel"| Push["Push Service (WebSocket)"]
- A player or party sends a join with its game mode, region, and skill. The Matchmaking Service validates it and computes the pool key =
(region, game_mode, skill_bucket)— region and mode are exact; skill is bucketed coarsely (e.g. 100-rating bands). - The Pool Router adds the ticket to that specific pool's queue in state
QUEUED, indexed byenqueued_atso the oldest tickets are matched first (fairness on wait time). - The service returns the
ticket_idand an ETA, and the client opens a push channel to await the result. The ticket is now waiting in exactly one small pool — matching is local to that pool, never a global scan.
Note
Bucketing skill coarsely at enqueue time is a starting point, not the final band. A ticket lives in one skill bucket for locality, but its own search_band widens over time and is allowed to spill into adjacent buckets when it has waited too long (deep dive 1). A band-widened ticket is then considered by the matcher of each overlapping bucket — pools are single-owner, but a boundary ticket is visible to more than one, and the exactly-once CAS claim (deep dive 2) makes that cross-pool contention safe. The bucket keeps matching cheap; the band keeps it fair-then-fast.
2. Matching / allocation path (form a match, give it a server)
Each pool has a matcher that runs on a short interval, forms matches from waiting tickets, claims each ticket exactly once, and allocates a warm server.
flowchart LR
Matcher["Per-pool Matcher<br/>(runs every ~1s)"] -->|"1. read waiting tickets, oldest first"| Pool["Pool queue"]
Matcher -->|"2. form a match within each ticket's band + balance teams"| Matcher
Matcher -->|"3. CAS each ticket QUEUED → MATCHED (claim once)"| Pool
Matcher -->|"4. create Match (FORMING → ALLOCATING)"| MatchStore["Match Store (durable)"]
Matcher -->|"5. request a server"| Alloc["Server Allocator"]
Alloc -->|"take a WARM server, mark ALLOCATED"| Fleet["Warm server pool"]
Alloc -->|"server address"| Matcher
Matcher -->|"6. push server address + token"| Push["Push Service"]
Push -->|"connect here"| Client["Matched clients"]
- The per-pool Matcher wakes on a short interval (~1s) and reads the pool's waiting tickets, oldest first. Matching is a batch/interval operation, not per-request — it looks at everyone waiting and forms as many matches as it can this tick.
- It forms a candidate match: pick tickets whose skill fits within each ticket's current
search_band, fill up to N players, and balance the two teams so their total strength is roughly equal (deep dive 1). - It claims every ticket in the candidate match with a compare-and-set
QUEUED → MATCHED. If any claim fails (a ticket was cancelled or grabbed by another attempt), the match backs off and retries with other tickets. This is the exactly-once placement guarantee (deep dive 2). - On a full claim, it writes a durable Match row (
FORMING → ALLOCATING) so a crash from here on is recoverable. - It asks the Server Allocator for a server. The allocator takes one from the warm pool (already booted and ready), marks it
ALLOCATED, and returns its address — no cold start (deep dive 3). - The Matcher pushes the server address and a join token to every matched client and moves the match to
READY. Players connect; once enough have joined, the match goesIN_PROGRESS.
3. Combined architecture
Putting it together — the enqueue path files tickets into small pools; a fleet of per-pool matchers forms and balances matches, claims tickets exactly once, and pulls servers from a warm pool that an autoscaler keeps stocked; a monitor recovers stuck matches.
flowchart LR
Client["Player / party client"] --> GW["API Gateway"]
GW -->|"join / cancel / status"| MM["Matchmaking Service"]
MM -->|"pool_key routing"| Pools["Pool queues<br/>(region × mode × skill)"]
Matchers["Per-pool Matchers<br/>(one owner per pool)"] -->|"read + claim tickets (CAS)"| Pools
Matchers -->|"create/advance match (durable)"| MatchStore["Match Store"]
Matchers -->|"request server"| Alloc["Server Allocator"]
Alloc -->|"take WARM → ALLOCATED"| Fleet["Game-server fleet"]
Autoscaler["Fleet Autoscaler"] -.->|"keep K warm ahead of demand"| Fleet
Matchers -->|"push match + server address"| Push["Push Service (WebSocket)"]
Push --> Client
Monitor["Match Monitor"] -.->|"recover stuck ALLOCATING / dissolve + requeue"| MatchStore
The shape to notice: small pools on the left make matching a cheap local search instead of a global scan; one matcher owns each pool so two matchers never fight over the same tickets; the durable Match Store and the warm fleet on the right hold the important, comparatively rare state (a match is formed ~50 times/sec, not thousands). The Autoscaler keeps warm servers ahead of demand, and the Match Monitor backstops crashes by re-driving or dissolving stuck matches.
Deep dives
1. Skill-based matching & the fairness-vs-wait-time tradeoff (the headline)
The defining tension: match players of similar skill (fair) but fast (low wait). A perfect skill match may not exist right now, and waiting for one makes players quit. The whole art is deciding how much fairness to give up as someone waits.
Skill rating. Every player carries a numeric rating. The classic system is Elo — a single number that rises when you beat stronger opponents and falls when you lose. Glicko adds a rating deviation (how confident we are in the number), so a brand-new player is matched more loosely until their rating settles. TrueSkill goes further and models skill as a distribution (a mean μ and an uncertainty σ), which handles team games and new players naturally. For matchmaking, the key output is the same: a number (or a distribution) you can compare, plus an idea of how uncertain it is.
The core mechanic: widen the acceptable skill band as wait time grows. Start tight — only match players within, say, ±50 rating. If nobody suitable is found this tick, relax the band a little each second, so a player who has waited longer accepts a wider skill range. This guarantees a bounded wait: the band eventually grows wide enough that someone matches, so nobody waits forever, while players who match quickly still get a tight, fair game.
Player queued at skill 1500. Acceptable band widens over wait time:
t=0s band ±50 → look for opponents 1450–1550 (tightest, fairest)
t=2s band ±100 → 1400–1600
t=4s band ±175 → 1325–1675
t=8s band ±300 → 1200–1800 (wide: accept a looser match over waiting)
t=12s band ±450 → 1050–1950 (last resort before "no match")
Two players match when their bands OVERLAP and both accept.
A player who finds a partner at t=2s never sees the wider bands.
Team balancing. Once you have N players, split them into two teams whose total (or average) skill is as equal as possible — a 1600+1400 team should face a 1550+1450 team, not a 1900+1100 team. A simple greedy assignment (sort by skill, snake-draft into the two teams) gets close; TrueSkill can score a proposed split directly. The objective balances on the skill estimate (the mean μ), but you can optionally penalize high-uncertainty (high-σ) players too, since their true strength is less predictable and stacking several of them makes a team's real strength a coin-flip. The match-quality check is on both the skill spread of the group and the balance between teams.
Party / premade handling. A party of 4 friends is one ticket that must land on the same team. That constrains balancing: you can't split them to even the teams, so you balance around them — pick the other players and the opposing team so total strength still matches. A party's rating is usually its members' aggregate (average, or a party-size-adjusted value, since coordinated groups play above their raw average). Apply that premade bonus in the balance objective too, not just in pool routing — a coordinated premade plays above its raw average, so treat it as slightly stronger when equalizing the two teams, or the party's side ends up favored. Parties are why balancing is a small optimization, not a simple sort.
Region / latency is a hard constraint. Skill can flex; geography mostly cannot. A perfectly skill-matched game is worthless if half the players have 250ms ping. So bucket by region / data-center first — region is part of the pool key, not something you relax freely. You may widen to an adjacent region as a last resort (a nearby data center with acceptable ping), but you never match a player onto a server that gives them unplayable latency.
Warning
Never let the skill band widen without limit silently. Widen it deliberately and cap it, and treat region/ping as a near-hard floor. A "fair on skill" match that gives players 300ms ping is a worse experience than a slightly looser skill match on a nearby server.
Here is the matching strategy as a ladder, worst to best:
Strict exact-skill matching only (avoid — players wait forever in thin pools)
Only ever match players within a tiny fixed skill band (say ±25), no matter how long they wait. If nobody that close is queued, keep waiting.
Worked example — a 1500-rated player at 3am, when the pool holds five people rated 1200–1400:
band fixed at ±25 → acceptable range 1475–1525
nobody in the pool is in that range → no match
t=30s ... t=2min ... still ±25 → still no match → player quits
- Pro: every match that does form is maximally fair on skill.
- Con: in a thin or lopsided pool, players wait indefinitely or never match at all. Off-peak, this empties the game — the worst time to make people wait.
- Verdict: avoid. Fairness with no wait-time bound isn't fair, it's abandonment.
Fixed wide band (works, with caveats — fast but often lopsided)
Pick one generous band (say ±300) and match anyone within it immediately. Wait times stay tiny.
Worked example — the same 1500 player:
band fixed at ±300 → acceptable range 1200–1800
matches instantly with a 1250 player who happened to queue
→ very fast, but a 250-point skill gap → a lopsided, unfun game
- Pro: near-zero wait; simple (one constant, no timers).
- Con: matches are often lopsided even when a much closer match was seconds away, because it grabs the first "good enough" player instead of starting tight. Skilled players get easy stomps; weaker players get crushed. And a fixed band only tunes fairness, not fill-ability: in a genuinely thin pool (3am) even a wide-but-fixed band still can't fill a match, because — unlike the expanding band — it has no mechanism to relax further, to fall back to region, or to form a smaller match. It's stuck at its one width.
- Verdict: works, but wastes fairness the pool could have provided. A fixed wide band trades away quality you didn't need to spend at peak.
Expanding skill band over wait time (recommended — fair when possible, fast when needed)
Start each ticket with a tight band and widen it as it waits. Early on you only accept close matches; the longer someone waits, the more skill difference you tolerate — until the band is wide enough that a match is guaranteed.
Worked example — 1500 player, pool has a 1470 player and a 1250 player:
t=0s band ±50 → 1470 player is in range (|1500−1470|=30) → MATCH immediately, tight & fair
(if no 1470 existed:)
t=8s band ±300 → 1250 player now in range → match, looser but bounded wait
- Pro: best of both — tight, fair matches at peak when the pool is rich; a bounded, guaranteed wait off-peak when it's thin. Each player pays only as much fairness as their wait actually required.
- Con: more machinery (per-ticket band, timers, a widening schedule to tune); you must cap the band and the region relaxation so it never produces an unplayable match.
- Verdict: recommended — the standard design. Bucket by region, start the skill band tight, widen it over wait time with a cap, and balance teams around any parties.
2. The matching pipeline at scale — pools, tickets, and finding a match without scanning everyone
At ~1–2K joins/sec you cannot answer "find me 31 players compatible with this one" by scanning a global queue every time — that's O(queue size) per match and it collapses under load. The fix is to make matching local to a small pool.
Partition the queue into pools. A pool key is (region, game_mode, skill_bucket). A ticket only ever matches within its pool — plus, once its band widens across a bucket edge, it becomes visible to the matcher of each adjacent skill bucket too. So a boundary ticket is considered by more than one matcher: pools are single-owner (one matcher per pool), but a band-widened ticket straddles pools, and whichever matcher claims it first wins the CAS. Because region and mode are exact and skill is bucketed, every ticket in a pool is already a plausible match for every other — the matcher isn't filtering a huge heterogeneous queue, it's assembling groups from a small, pre-filtered set. Hundreds–thousands of pools each hold a small slice, and pools scale horizontally: a hot region gets its own matchers, a quiet one barely ticks over.
The ticket model. Each waiting player/party is a ticket with attributes (skill, region, mode) and an expanding search range (search_band, from deep dive 1). The matcher's job each tick is: over the tickets in this pool, find a subset of N whose bands mutually overlap and whose teams can be balanced, preferring the oldest tickets so wait time stays fair.
Matching is a batch/interval operation, not per-request. The matcher runs every ~1 second per pool, looks at everyone currently waiting, and forms as many matches as it can this tick. This is far more efficient than trying to match on every join (which would repeatedly re-scan) and it naturally lets a just-widened ticket get picked up on the next tick. The interval trades latency (shorter = matches form sooner) against load (shorter = more matcher runs).
Surge and shrink dynamics. At peak a pool is rich, so tight bands match instantly. Off-peak a pool thins to a handful — then the widening band (skill) and, as a last resort, adjacent-region relaxation are what keep matches forming. The system doesn't need a separate "off-peak mode": the same widening mechanism that handles an unlucky skill gap at peak handles a thin pool at 3am. If even a wide band can't fill a match, the fallback depends on the mode: for modes that allow it, form a smaller-than-ideal match or bot-fill the gaps; for fixed-size modes (32 players, 16v16), you can't shrink the match, so hold the tickets, widen further, and relax region — and report a longer ETA. Either way it never fabricates human players.
Single-matcher-per-pool ownership, and the exactly-once claim. Give one matcher ownership of each pool (via a lease / partition assignment) so two matchers don't try to form matches from the same tickets simultaneously — this removes most contention up front. But ownership alone isn't a correctness guarantee (a lease can expire under a slow matcher, a ticket can also be cancelled mid-match). So the match-commit must be atomic per ticket: claiming a ticket into a match is a compare-and-set QUEUED → MATCHED, and a ticket is claimed exactly once. This is the same "reserve exactly once" pattern as grabbing a driver in ride-sharing or a seat in ticket-booking: the winner flips the state; a racer's CAS finds the ticket no longer QUEUED and loses cleanly.
Two match attempts both include ticket t_88 (a rare race despite single-owner pools,
e.g. a band-widened boundary ticket seen by two adjacent buckets, or a lease handoff):
Attempt X: CAS t_88 QUEUED → MATCHED(m_1) → 1 row changed ✓ (X owns t_88)
Attempt Y: CAS t_88 QUEUED → MATCHED(m_2) → 0 rows (already MATCHED) ✗
Y's match is short a player → Y releases its other claimed tickets
(CAS each back QUEUED, predicated on state=MATCHED AND match_id=m_2 — only the
ones Y itself owns) and retries next tick.
→ t_88 is placed in exactly one match; no double-placement.
The commit is all-or-nothing across the match's tickets: claim them one by one, and if any claim fails, release the ones you already claimed and abandon this candidate. Two details make this safe rather than fragile:
- Claim in a fixed global order (e.g. by ascending
ticket_id). If two attempts both need tickets{A, B}but claim in opposite orders, each grabs one, fails the other, releases, and retries — forever (a livelock). Claiming in one agreed order means competing attempts can't each hold what the other needs: one attempt getsAfirst and wins both; the loser fails onA, releases, and backs off with jitter before retrying. - Release is predicated on the match id. Rolling a ticket back is a CAS
(state=MATCHED AND match_id=mine) → QUEUED, not a bareMATCHED → QUEUED. Thematch_idpredicate is the correctness of the rollback: without it, an attempt could roll back a ticket that a different match legitimately claimed and now owns. You only ever release tickets you yourself claimed.
A cancel racing a claim is the same story — cancel is a CAS QUEUED → CANCELLED, so whichever CAS commits first wins and the other loses cleanly (a too-late cancel returns 409; a claim that lost skips that ticket).
Caution
Do not rely on single-owner pools alone to prevent double-placement. A lease can expire under a slow matcher and two owners can briefly overlap; a player can cancel mid-form. The per-ticket compare-and-set claim (QUEUED → MATCHED, claimed once) is the real guarantee — ownership only reduces contention.
3. Game-server allocation & the end-to-end commit
A formed match is useless until its players are on a server together. The requirement is a freshly provisioned dedicated game server per match — but booting one on demand takes tens of seconds, which no player will wait for. The answer is a warm pool.
The warm pool. Keep K servers already booted and idle (WARM), ahead of demand. When a match needs one, the Server Allocator takes a warm server, marks it ALLOCATED, and returns its address — an instant hand-out, no cold start. (It flips to IN_USE later, when players actually connect — step 5 of the handshake below.) A background Autoscaler watches the warm buffer and the match-formation rate and boots more servers before the buffer runs dry (and drains excess off-peak to save cost). Sizing the buffer is the tradeoff: too small and a surge stalls waiting for boots; too large and you pay for idle machines.
The match → allocate → connect handshake.
1. Matcher claims N tickets (all CAS QUEUED→MATCHED) [deep dive 2]
2. Match row written: FORMING → ALLOCATING (durable)
3. Allocator takes a WARM server → ALLOCATED, stamps server.match_id = m (commit), returns addr
4. Match: ALLOCATING → READY; push {server addr + token} to all players
5. Players connect with their token; server confirms them
6. Enough connected → Match: READY → IN_PROGRESS
When a player fails to connect or declines. A matched player may not show up (crashed client, closed the game, declined a "match found" prompt). Two recoveries:
- Backfill: pull one more compatible ticket from the pool to replace the missing player, so the match still starts full. Best when the pool is rich (peak). Backfill is best-effort with its own bounded timeout — if no compatible ticket is available, or the backfill player also fails to connect before the timeout, it falls through to dissolve-and-requeue rather than blocking indefinitely.
- Dissolve and requeue: if too many drop or backfill can't fill in time, dissolve the match, reclaim its server (back to
WARMor drain it), and requeue the remaining players (CAS their ticketsMATCHED → QUEUED) so they get a fresh match rather than a broken one. Preserve each requeued ticket's originalenqueued_atand currentsearch_bandso a dissolved player keeps their accrued wait-time priority — they resume from where they were, not restarting cold at the tight band. The dropout may be penalized.
The lifecycle state machine (recap). FORMING → ALLOCATING → READY → IN_PROGRESS → ENDED, with DISSOLVED reachable from FORMING (timed out) or READY (connect failure). When a match ENDED, its server is drained (players disconnected, state reset) and returned to the warm pool or shut down per autoscaling.
Idempotency / exactly-once allocation. A matched ticket set allocates exactly one server, even across crashes. Two guards make this safe:
- The match is durable with a state. A crash after step 2 leaves it in
ALLOCATING; the Match Monitor finds stuckALLOCATINGmatches and re-drives them — but it must not allocate a second server for the same match. - Allocation is idempotent on
match_id. The single durable commit point is stamping the chosen server's row withserver.match_id = <match>(under a uniquematch_idconstraint) — that write, not the in-memory hand-out, is what "allocated" means. On retry, recovery reads back which server carries thismatch_id— a point lookup on the uniquematch_idindex, not a fleet scan: if one does, reuse it; if none does, the earlier attempt crashed before the commit, so no server was ever bound — safely take a fresh warm server and stamp it. Either way the match ends with exactly one server, and any warm server that was pulled but never stamped is reclaimed by a sweep ofALLOCATEDservers with nomatch_id— but only those that have beenALLOCATEDpast a grace/timeout (older than the allocation deadline), not any un-stamped server instantly, so the sweep can't reclaim a server in the brief legitimate window between theWARM→ALLOCATEDflip and theserver.match_idstamp.
crash during allocation, then recovery:
Matcher: match m_12 → ALLOCATING
Allocator: take server gs-42 (WARM→ALLOCATED) ... CRASH before stamping match_id
Monitor: sees m_12 stuck ALLOCATING → re-drives allocation
Allocator: read back "which server has match_id = m_12?" → none (crash was pre-commit),
so take a fresh warm server gs-77 and stamp gs-77.match_id = m_12.
Sweep: gs-42 is ALLOCATED with no match_id → reclaimed back to WARM.
(Had the crash landed AFTER the stamp, recovery would read gs-42 back and reuse it.)
→ match m_12 ends up with exactly one server; no leak, no double-allocation.
Warning
Do not allocate servers by cold-booting on demand — players won't wait 30+ seconds. Keep a warm pool ahead of demand and autoscale it. And make allocation idempotent on match_id so a crash mid-allocation can't hand the same match two servers or strand players with none.
Caution
Handle the connect step failing. A "match found" isn't done until players actually connect. Without a backfill-or-dissolve path, one no-show strands the other 31 players in a half-empty server — dissolve and requeue them (CAS their tickets back to QUEUED) rather than starting a broken match.
Tradeoffs & bottlenecks
- Fairness vs wait time (the central trade). A tighter skill band means fairer matches but longer waits; a wider band means faster matches but lopsided games. The widening band buys both — tight when the pool allows, wide only when waiting would otherwise be too long.
- Region as a near-hard constraint. You partition by region for playable ping and only relax to adjacent regions as a last resort. Skill flexes freely; geography barely does — a fair-on-skill match on a far server is a bad match.
- Pool granularity. More pools (finer skill buckets, more modes) make each match tighter and matching cheaper per pool, but thin each pool out — hurting off-peak. Coarser pools fill faster but need more in-pool balancing. Tune bucket width to the population.
- Interval matching vs instant. Running the matcher every ~1s per pool is efficient and lets bands widen between ticks, but adds up to a second of latency versus matching on every join. The interval trades latency against matcher load.
- Warm-pool size vs cost. A big warm buffer absorbs surges but burns money on idle servers; a small one is cheap but stalls under a spike. The autoscaler sizes it from the formation rate.
- Single-owner pools vs contention. One matcher per pool removes most contention, but ownership can briefly overlap on a lease handoff — so the exactly-once ticket claim (CAS) is still mandatory as the correctness guarantee, not just an optimization.
- Party constraints vs balance. Parties must stay together, which limits how evenly you can split teams; you balance around them, occasionally accepting a slightly less even match to honor the group.
Extensions if asked
If the interviewer wants to push beyond the core, add only the extension that changes the design discussion. (These are standalone topics; there's no dedicated guide for them yet.)
- Backfill into live matches. When a player quits mid-match, pull a fresh queued player of matching skill into the running server — matchmaking that targets in-progress sessions, not just new ones.
- Role / class matchmaking. Some modes need composition (e.g. exactly one healer per team), turning balancing into a constraint-satisfaction problem on top of skill.
- Rating updates after a match. Feeding the match result back into Elo/Glicko/TrueSkill to update ratings — a separate write path that this system consumes but doesn't own.
- Cross-play and input-based pools. Splitting or merging pools by platform or input device (controller vs mouse) for fairness, changing the pool key.
- Queue-time prediction and dynamic modes. Estimating wait per pool and steering players toward modes/regions that match faster, or temporarily disabling starved modes.
- Skill uncertainty / placement matches. Using Glicko deviation or TrueSkill σ to widen bands for new players until their rating is confident, then tightening.
What interviewers look for & common mistakes
What interviewers usually reward:
- Naming the fairness-vs-wait-time tradeoff and solving it with a skill band that starts tight and widens over wait time, with a cap — not a fixed band.
- Region/latency as a near-hard constraint — bucket by region first, relax skill before geography, and only ever to an adjacent DC with acceptable ping.
- Pool partitioning (region × mode × skill bucket) so matching is a small local search, not a global scan, with a per-pool matcher running on an interval.
- A ticket model with an expanding search range, and matching as a batch operation.
- Exactly-once placement — each ticket claimed once via compare-and-set (
QUEUED → MATCHED), the "reserve exactly once" pattern, with single-owner pools as an optimization on top. - A warm server pool with autoscaling so allocation is instant, and idempotent allocation so a crash can't double-allocate or strand players.
- A durable match state machine (
FORMING → ALLOCATING → READY → IN_PROGRESS → ENDED,DISSOLVEDon failure) with backfill-or-requeue when a player fails to connect.
Before you finish, do a quick mistake check:
- Did you widen the skill band over wait time (with a cap), rather than a fixed band that either strands or lopsides players?
- Did you treat region/ping as a near-hard constraint (relax only to an adjacent DC with acceptable ping), relaxing skill before geography?
- Did you partition the queue into pools so matching is local, not a global scan?
- Did you run matching as a per-pool batch/interval operation, not per-request?
- Did you make placement exactly-once via a compare-and-set ticket claim, not just single-owner pools?
- Did you balance teams (and keep parties together), not just gather N similar-skill players?
- Did you allocate from a warm pool (no cold start) and make allocation idempotent on
match_id? - Did you handle a player failing to connect with backfill or dissolve-and-requeue, and model the match as a durable state machine?
Practice this live
Run this exact question with our AI voice interviewer and get feedback.
Start the interview →