?

Boss34 minIntro+60 XP

Multiplayer Game Matchmaking System.

A Real-World Problem Covering Everything You've Learned

6 phases4 code

Boss · W05 · 5 phases · 45 min

Fight it like the real interview: each phase runs against its time budget, you write your answer in a scratchpad first, then reveal the model answer side by side and grade yourself.

or read the model answer below
watch it first · newThe whole lesson in a 21-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Intro · 1 min

The Interview Begins#

You walk into the interview room at a gaming company. The interviewer, a Staff Engineer, smiles and gestures to the whiteboard.

Interviewer

"Thanks for coming in. Today we're going to work through a system design problem together. I'm interested in your thought process, so please think out loud. Feel free to ask questions — this is meant to be collaborative."

They write on the whiteboard:

Design a Multiplayer Game Matchmaking System

We're building a competitive multiplayer game. Players queue up to find matches with others of similar skill. Once matched, they connect to a game server and play.

Key requirements
  • Match players of similar skill (ELO rating)
  • Support multiple game modes (1v1, 5v5, battle royale)
  • Minimize wait time while maintaining fair matches
  • Handle players going offline during queue/matching
  • Update player ratings after each match
  • Support party queue (friends playing together)
Interviewer

"Take a few minutes to think about this, then walk me through your approach. We have about 45 minutes."

ExplainerThe whole capstone: a multiplayer game matchmaking system

Matching 2 million concurrent players on ELO within ±100, with a median wait under 30 seconds, means about 40,000 queued players and 1,300 queue joins a second (4,000 at peak), in just 2.4 MB of queue state. One matchmaker per region is elected in etcd and fences every queue change so nobody is double-booked, ratings are read and written on the primary, match creation is a saga that re-queues players if no server is free, and party membership is an OR-Set. At 10x the 500 ms matching cycle is the bottleneck, solved by splitting each region into ELO bands.

Phase 1 · Stage 1 of 6 · 5 min budget · 2 min read

Phase 1: Requirements Clarification (5 minutes)#

Before diving in, you take a breath and start asking questions. This is crucial — never assume.

Your Questions#

Transcript
You

"Before I start designing, I'd like to clarify a few requirements. First, what's the scale we're targeting? How many concurrent players?"

Interviewer

"At peak, we expect 2 million concurrent players in queue or in matches. We're targeting a global audience."

You

"And for match sizes — you mentioned 1v1, 5v5, and battle royale. What's the battle royale size?"

Interviewer

"Battle royale is 100 players. The 5v5 mode is our most popular, accounting for about 70% of matches."

You

"How quickly do players expect to find a match?"

Interviewer

"For 5v5, median wait time should be under 30 seconds. We'd accept up to 2 minutes for 95th percentile. Players get frustrated if they wait longer."

You

"What about skill matching? How strict should we be?"

Interviewer

"We use ELO ratings from 0-3000. Ideally, players in a match should be within 100 ELO of each other. But if wait time exceeds 60 seconds, we can expand the range. Quality versus speed trade-off."

You

"For party queue — can a high-skill player queue with a low-skill friend?"

Interviewer

"Yes, but we need to handle it carefully. The match should be fair for everyone. We typically use the highest-rated player in the party for matchmaking."

You

"What happens to ELO ratings after a match? Is it updated immediately?"

Interviewer

"Yes, ratings update immediately after the match ends. All 10 players in a 5v5 should see their new rating within seconds. This is important for competitive integrity — we can't have players see stale ratings."

You

"Finally, what about regional servers? Do players only match within their region?"

Interviewer

"We have game servers in 5 regions: NA, EU, APAC, SA, OCE. Players prefer low latency, so we match within region when possible. But during off-peak, we might cross-region match rather than have long waits."

You

"Perfect. Let me summarize the requirements."

Functional Requirements#

Functional requirements
1. Queue management
  • Players can join/leave matchmaking queue
  • Support solo queue and party queue (2-5 players)
  • Multiple game modes: 1v1, 5v5, battle royale (100 players)
  • Players can queue for multiple modes simultaneously
2. Matchmaking
  • Match players within ELO range (±100 preferred, expand over time)
  • Balance teams by average ELO
  • Respect party groupings (keep friends on same team)
  • Consider player region for low latency
3. Game session
  • Allocate game server for matched players
  • Handle player confirmation (ready check)
  • Reconnect players who disconnect mid-match
  • Determine match outcome (win/loss/draw)
4. Rating updates
  • Calculate new ELO for all players after match
  • Update ratings immediately (strong consistency)
  • Handle edge cases (disconnects, abandons)
  • Support rating decay for inactive players

Non-Functional Requirements#

Non-functional requirements
1. Scale
  • 2M concurrent players
  • ~24K matches created per minute (peak; 3× the ~8K/min average)
  • ~5M rating updates per hour (~1,300/sec average, ~4,000/sec peak)
2. Latency
  • Queue join: <100ms p99
  • Match found notification: <50ms p99
  • Rating update visible: <500ms p99
  • Median queue time: <30 seconds (5v5)
3. Consistency
  • Ratings: Strong consistency (no stale reads)
  • Queue state: At-least-once processing
  • Match assignment: Exactly-once (no double-booking)
4. Availability
  • 99.9% uptime for matchmaking
  • Graceful degradation during partial outages
  • No lost players in queue during failures

Phase 2 · Stage 2 of 6 · 5 min budget · 2 min read

Phase 2: Back of the Envelope Estimation (5 minutes)#

You

"Let me work through the numbers to understand the scale."

Traffic Estimation#

WorksheetMatchmaking traffic
  • Concurrent players: 2,000,000
  • Players in matches: ~1,600,000
  • Players in queue (Little's law: ~1,300 matched/sec × 30 sec wait): ~40,000 (~2%)
  • Remainder in menus, lobbies, post-game: ~360,000
Match composition (5v5)
  • Players per match: 10
  • Active matches: 160,000
Match duration (average): 20 minutes
  • Matches ending per minute: 160,000 / 20 = 8,000 matches/min
  • New matches starting: ~8,000 matches/min
Queue operations
  • Join queue: 8,000 matches/min × 10 players / 60 = ~1,300 joins/sec
  • Leave queue (matched): ~1,300/sec
  • Leave queue (cancel): ~100/sec
Peak multiplier: 3x
  • Peak joins: ~4,000/sec

Storage Estimation#

WorksheetStorage estimate
Player data
  • Per player: player_id 16 bytes (UUID), username 32 bytes, elo_rating 4 bytes (int), region 4 bytes, last_match 8 bytes (timestamp) → total ~100 bytes
  • Total players (registered): 50M
  • Player data: 50M × 100B = 5GB
Match history
  • Per match: match_id 16 bytes, game_mode 4 bytes, player_ids (10) 160 bytes, elo_changes (10) 40 bytes, outcome 20 bytes, timestamps 16 bytes → total ~300 bytes
  • Matches per day: 8,000/min × 60 × 24 = 11.5M
  • Daily match data: 11.5M × 300B = 3.5GB
  • Yearly (retained): 1.3TB
Queue state (in-memory)
  • Per queued player: player_id 16 bytes, elo 4 bytes, queue_time 8 bytes, game_modes 8 bytes, party_id 16 bytes, region 4 bytes → total ~60 bytes
  • Queue memory (40K players): 40K × 60B = 2.4MB

Key Metrics Summary#

Estimation summary

Traffic

  • Peak queue joins: ~4,000 /second
  • Matches created: ~130 /second
  • Rating updates: ~1,300 /second (10 per match)

Storage

  • Player profiles: 5 GB
  • Match history (yearly): 1.3 TB
  • Queue state (memory): 2.4 MB

Infrastructure (rough)

  • Queue service: 10 instances (sharded by region)
  • Matchmaker: 15 instances (one leader + 2 standbys per region)
  • Rating service: 10 instances
  • Database: 3-node cluster (primary + 2 replicas)
  • Redis (queue state): 3-node cluster
Quick check2 questions · +5 XP each

Matchmaking estimates

  1. Q1/2 About 1.6 million players are in 5v5 matches at once, and a match lasts 20 minutes on average. Roughly how many matches end, and must start, each minute?

    Answer & explanation

    Answer: C

    1.6M players / 10 per match = 160,000 active matches; spread over 20 minutes that is 8,000 per minute. 80,000 is players per minute (forgetting the 10 players per match), and 160,000 is the number of matches running at once.

  2. Q2/2 The whole queue state is only about 2.4 MB, yet the queue service is sharded by region. What is the sharding for?

    Answer & explanation

    Answer: A

    At 2.4 MB and ~4K joins/sec at peak, neither capacity nor raw throughput forces sharding; regional matching (latency) and fault isolation do. Sharding by itself does not make data durable, and ratings live in PostgreSQL, not the queue.

Your turn · checkpoint 1not answered

Sketch it first: where does each piece of state live?

Before the architecture: you have ~40,000 players waiting, a matching loop that must find ten players of similar ELO every half second, 50 million player ratings that must never be read stale after a match, and match-found messages that must reach players within 50 ms. Where would you keep the queue, where the ratings, and how would the "match found" message reach the players? How many matchmakers may act on one region's queue at once?

Think it through, or jot it down. Then compare.

One strong answer

  • Queue: in memory, as a Redis sorted set by ELO. It's only a few MB, and the hot operation is a range query around a rating.
  • Ratings: in PostgreSQL, written transactionally on the primary, with replicas only for reads that tolerate staleness (leaderboards).
  • Match found: pushed over persistent WebSocket connections through a gateway. Polling can't meet 50 ms for millions of clients.
  • One matchmaker per region acts at a time, elected through a coordination service; standbys wait. Two acting on one queue could double-book players.

Phase 3 · Stage 3 of 6 · 10 min budget · 3 min read

Phase 3: High-Level Design (10 minutes)#

You

"Now let me sketch out the high-level architecture."

System Architecture#

DiagramMatchmaking system architecture
%% title: Matchmaking system architecture
flowchart TD
  P@{ icon: "sd:gamepad", label: "Players<br/>(Clients)" }
  GW@{ icon: "tech:websocket", label: "CDN<br/>WebSocket Gateway" }
  QS["Queue Service"]:::service
  MM["Matchmaker Service"]:::core
  RS["Rating Service"]:::service
  R@{ icon: "tech:redis", label: "Redis Cluster<br/>(Queue State)" }
  PG@{ icon: "tech:postgresql", label: "PostgreSQL<br/>(Ratings/Matches)" }
  K@{ icon: "tech:kafka", label: "Kafka<br/>(Events)" }
  E@{ icon: "tech:etcd", label: "etcd<br/>(Leader Election)" }
  P --> GW
  GW --> QS
  GW --> MM
  GW --> RS
  QS --> R
  MM --> R
  MM --> PG
  RS --> PG
  MM --> K
  MM --> E
  subgraph GS["Game server pools"]
    direction TB
    G1@{ icon: "sd:servers", label: "Game Server<br/>Pool (NA)" }
    G2@{ icon: "sd:servers", label: "Game Server<br/>Pool (EU)" }
    G3@{ icon: "sd:servers", label: "Game Server<br/>Pool (APAC)" }
  end
  class P client
  class GW edge
  class R cache
  class PG,E store
  class K queue
  class G1,G2,G3 service

Component Breakdown#

You

"Let me walk through each component..."

1. WebSocket Gateway#

Purpose: Maintain persistent connections with players

Key responsibilities:

  • WebSocket connection management
  • Authentication and session handling
  • Route messages to appropriate services
  • Push notifications (match found, rating updates)

Technology: Custom service with WebSocket support, horizontally scaled

2. Queue Service#

Purpose: Manage player queue state

Key responsibilities:

  • Add/remove players from queue
  • Track queue time for each player
  • Handle party groupings
  • Publish events when queue state changes

Technology: Custom service + Redis for state

3. Matchmaker Service#

Purpose: Create balanced matches from queued players

Key responsibilities:

  • Run matching algorithm periodically
  • Balance teams by ELO
  • Allocate game servers
  • Coordinate ready checks

Technology: Custom service with leader election (one per region)

4. Rating Service#

Purpose: Manage player ratings with strong consistency

Key responsibilities:

  • Calculate ELO changes after matches
  • Update ratings transactionally
  • Serve rating queries
  • Handle rating decay

Technology: Custom service + PostgreSQL

Data Flow#

You

"Let me trace through a typical matchmaking flow..."

DiagramMatchmaking flow (5v5)
%% title: Matchmaking flow (5v5)
sequenceDiagram
  participant C as Client (Player)
  participant G as Gateway
  participant Q as Queue Service
  participant R as Redis
  participant M as Matchmaker (leader)
  participant PG as PostgreSQL
  participant GS as Game Server
  participant K as Kafka
  participant RS as Rating Service
  Note over C,Q: Step 1: QUEUE JOIN
  C->>G: Player clicks "Find Match"
  G->>Q: queue join
  Q->>R: Add player to sorted set (by ELO)
  Note over R,M: Step 2: MATCHMAKING (runs every 500ms)
  M->>R: Read queue
  Note over M: Finds 10 compatible players<br/>Creates provisional match
  Note over C,M: Step 3: READY CHECK
  M->>G: Ready check
  G->>C: "Match found! Accept?" (10 Players)
  C-->>G: Players confirm (30 second timeout)
  G-->>M: confirmations
  Note over C,M: Step 4: MATCH CONFIRMATION
  alt All 10 accept
    M->>GS: Allocate game server
    M->>PG: Write match to PostgreSQL
    M-)K: Publish MatchCreated event
  else Some decline
    M->>R: Decliners removed from queue
    M->>R: Accepters returned to front of queue
  end
  Note over C,GS: Step 5: GAME SESSION
  C->>GS: Players connect to game server
  Note over GS: Game plays out (20 minutes average)
  Note over GS,RS: Step 6: RATING UPDATE
  GS-)K: Game server reports outcome
  K-)RS: outcome event
  Note over RS: Calculates new ELO
  RS->>PG: Update ratings (transaction)
  RS-)K: Publish RatingUpdated events
  G-->>C: Gateway pushes new ratings to players
Quick check3 questions · +5 XP each

Matchmaking architecture

  1. Q1/3 A ready check goes out to 10 players: 8 accept and 2 decline. What does the flow do?

    Answer & explanation

    Answer: B

    A match only proceeds when everyone accepts. Accepters keep their place by going back to the front, so they are not punished for others declining. Sending everyone to the back would penalize the players who did accept.

  2. Q2/3 Why does the queue live in a Redis sorted set keyed by ELO?

    Answer & explanation

    Answer: D

    The hot loop is "find compatible players near this rating", which is exactly a range query over a sorted set; the queue is small enough to sit in memory. Ratings themselves need strong consistency, which is why they live in PostgreSQL, not in the queue.

  3. Q3/3 The matchmaker must tell players "Match found" within 50 ms at p99. Why does the design route this through a WebSocket gateway?

    Answer & explanation

    Answer: A

    A push over an open connection is the only way to hit a tight notification latency for millions of waiting clients. Polling would either miss the latency target or flood the servers with requests.

Your turn · checkpoint 2not answered

Two matchmakers, one queue

A network blip makes NA's matchmaker leader A lose its etcd lease, and standby B is elected. A doesn't notice and, in the same second as B, reads the same ten players and starts building a match. What stops a double booking? And if A crashes halfway, after removing the players from the queue but before writing the match, how do they get back?

Think it through, or jot it down. Then compare.

One strong answer

  • Fencing: each election yields a higher token, and queue removals run in a Redis Lua script that checks the token and removes players atomically. Once B's removal has raised the highest token seen, any removal A sends with its older token is rejected.
  • Why atomic: a check followed by a separate removal would leave a window for A to slip through.
  • Half-done match: match creation is a saga. The removal records a pending_match_id; once A's lease expires, the new leader finds players with no confirmed match and re-queues them.

Phase 4 · Stage 4 of 6 · 20 min budget · 20 min read

Phase 4: Deep Dives (20 minutes)#

Interviewer

"Great high-level design. Let's dive deeper into a few areas. How do you ensure only one matchmaker is running per region?"

Deep Dive 1: Leader Election for Matchmaker (Week 5, Day 5)#

You

"This is critical — if two matchmakers run simultaneously, players could be double-booked into different matches. I'd use leader election with fencing tokens."

The Problem#

Double-booking scenario

Without proper leader election:

  • Matchmaker A (thinks it's leader):
    • Reads queue: [Player1, Player2, ... Player10]
    • Creates Match-1 with these players
  • Matchmaker B (also thinks it's leader):
    • Reads queue: [Player1, Player2, ... Player10]
    • Creates Match-2 with same players!

Result: Players assigned to two matches → confusion, broken game state.

The Solution#

You

"I'd use etcd for leader election with fencing tokens. The matchmaker must include its fencing token when modifying queue state."

DiagramMatchmaker leader election · Region NA
%% title: Matchmaker leader election · Region NA
flowchart TD
  A["Matchmaker A<br/>(LEADER)<br/>token=42"]:::hot
  B@{ icon: "sd:server", label: "Matchmaker B<br/>(standby)" }
  C@{ icon: "sd:server", label: "Matchmaker C<br/>(standby)" }
  E@{ icon: "tech:etcd", label: "etcd<br/>/matchmaker/na/leader" }
  A --> E
  B --> E
  C --> E
  subgraph NA["Region: NA"]
    direction TB
    A
    B
    C
    E
  end
  class E store
  class B,C service

Implementation#

pythonLeaderLease · 171 lines
# Matchmaker Leader Election with Fencing Tokens

import etcd3
from dataclasses import dataclass
from typing import Optional
import asyncio
import time


@dataclass
class LeaderLease:
    """Leadership lease with fencing token."""
    token: int
    expires_at: float
    
    def is_valid(self) -> bool:
        return time.time() < self.expires_at


class MatchmakerLeaderElection:
    """
    Leader election for matchmaker service.
    
    Ensures only ONE matchmaker runs per region.
    Uses fencing tokens to prevent zombie leaders.
    
    Week 5, Day 5: Leader Election concepts applied
    """
    
    def __init__(
        self,
        etcd_host: str,
        region: str,
        node_id: str,
        lease_ttl: int = 10
    ):
        self.client = etcd3.client(host=etcd_host)
        self.region = region
        self.node_id = node_id
        self.lease_ttl = lease_ttl
        self.election_key = f"/matchmaker/{region}/leader"
        
        self.lease: Optional[LeaderLease] = None
        self.etcd_lease = None
        self.is_leader = False
    
    async def campaign(self) -> Optional[int]:
        """
        Campaign for leadership.
        
        Returns fencing token if elected, None otherwise.
        """
        # Create lease
        self.etcd_lease = self.client.lease(self.lease_ttl)
        
        # Try to become leader (atomic compare-and-set)
        success, responses = self.client.transaction(
            compare=[
                self.client.transactions.version(self.election_key) == 0
            ],
            success=[
                self.client.transactions.put(
                    self.election_key,
                    self.node_id.encode(),
                    lease=self.etcd_lease
                )
            ],
            failure=[]
        )
        
        if success:
            # We're the leader - get fencing token from key version
            value, metadata = self.client.get(self.election_key)
            fencing_token = metadata.mod_revision  # Monotonically increasing
            
            self.lease = LeaderLease(
                token=fencing_token,
                expires_at=time.time() + self.lease_ttl
            )
            self.is_leader = True
            
            # Start keepalive
            asyncio.create_task(self._keepalive_loop())
            
            return fencing_token
        
        return None
    
    async def _keepalive_loop(self):
        """Keep lease alive while we're leader."""
        while self.is_leader:
            try:
                self.etcd_lease.refresh()
                self.lease.expires_at = time.time() + self.lease_ttl
                await asyncio.sleep(self.lease_ttl / 3)
            except Exception as e:
                self.is_leader = False
                break
    
    def get_fencing_token(self) -> Optional[int]:
        """Get current fencing token."""
        if self.lease and self.lease.is_valid():
            return self.lease.token
        return None
    
    async def resign(self):
        """Resign from leadership."""
        self.is_leader = False
        if self.etcd_lease:
            self.etcd_lease.revoke()


class FencedQueueOperations:
    """
    Queue operations protected by fencing tokens.
    
    Redis operations include fencing token validation.
    Prevents zombie leaders from corrupting state.
    """
    
    def __init__(self, redis_client):
        self.redis = redis_client
        # Track highest token seen per region. The {hash tag} keeps this key and
        # the queue key in the same Redis Cluster slot, as a multi-key script requires.
        self.highest_token_key = "matchmaker:highest_token:{{{region}}}"
    
    async def remove_players_from_queue(
        self,
        region: str,
        player_ids: list[str],
        fencing_token: int
    ) -> bool:
        """
        Remove players from queue atomically with fencing.
        
        Only succeeds if fencing_token >= highest seen.
        """
        # Lua script for atomic fencing check + removal
        script = """
        local highest_key = KEYS[1]
        local queue_key = KEYS[2]
        local token = tonumber(ARGV[1])
        local player_count = tonumber(ARGV[2])
        
        -- Check fencing token
        local highest = tonumber(redis.call('GET', highest_key) or '0')
        if token < highest then
            return 0  -- Stale token, reject
        end
        
        -- Update highest token
        redis.call('SET', highest_key, token)
        
        -- Remove players from queue
        for i = 3, 2 + player_count do
            redis.call('ZREM', queue_key, ARGV[i])
        end
        
        return 1  -- Success
        """
        
        # redis-py signature: eval(script, numkeys, *keys_and_args)
        result = await self.redis.eval(
            script,
            2,  # numkeys: the first two arguments after this are KEYS
            self.highest_token_key.format(region=region),
            f"queue:{{{region}}}",
            fencing_token, len(player_ids), *player_ids
        )
        
        return result == 1

Edge Cases#

Transcript
Interviewer

"What happens if the leader crashes mid-match-creation?"

You

"The match creation is a saga. If the leader crashes, the saga either completes via compensation or a new leader takes over and cleans up."

Leader crash recovery
Scenario
  • Leader starts creating match
  • Leader crashes after removing from queue but before writing match
Recovery
  1. etcd lease expires (10 seconds)
  2. New leader elected with higher fencing token
  3. New leader checks for "orphaned" queue removals
  4. Players without match assignment are re-queued
Implementation
  • Match creation is a saga with compensation
  • Queue removal records pending_match_id
  • If match not confirmed within timeout, players re-queued
ExplainerMatchmaking queue and a fenced leader

Players wait in queue:{na}, a Redis sorted set scored by ELO. Every 500 ms the region's leader finds 10 players within ±100 ELO and removes them with a Lua script that accepts its fencing token only if it is at least the highest seen. Leader A (token 42) stalls, its 10 s etcd lease expires and B is elected with a higher token (43), which it uses to remove the next players; when A wakes and tries again with 42, the script rejects it, so no player is double-booked.

Deep Dive 2: Rating Updates with Strong Consistency (Week 5, Day 1)#

You

"Rating updates need strong consistency. Players must see their correct rating immediately after a match."

The Problem#

Stale rating problem

Without strong consistency:

  • T=0 —
    Match ends, Player A won. Server 1 updates rating: 1500 → 1520
  • T=1 —
    Player A checks profile (reads from replica). Replica hasn't received update yet. Player A sees: 1500 (STALE!)
  • T=2 —
    Player A queues for next match. Queue uses stale rating: 1500. Matchmaker creates unfair match!

This violates competitive integrity.

The Solution#

You

"I'd use read-your-writes consistency for the player who just finished a match, and linearizable reads for matchmaking."

Consistency strategies: rating consistency model
Operation Consistency Implementation
Rating update Linearizable Write to primary
Player views own rating Read-your-writes Read from primary if recent update
Matchmaker reads rating (snapshot at queue join) Linearizable Always read from primary
Leaderboard Eventual Read from replica (cached)

Implementation#

pythonRatingUpdate · 211 lines
# Rating Service with Strong Consistency

from dataclasses import dataclass
from typing import Optional, Dict, Tuple
from datetime import datetime, timedelta, timezone
import asyncpg
import redis.asyncio as redis  # async client: its commands are awaited


@dataclass
class RatingUpdate:
    """Result of a rating update."""
    player_id: str
    old_rating: int
    new_rating: int
    match_id: str
    updated_at: datetime


class RatingService:
    """
    Rating service with consistency guarantees.
    
    Week 5, Day 1: Consistency Models applied
    - Linearizable writes (always to primary)
    - Read-your-writes for recent updates
    - Causal consistency via version tracking
    """
    
    def __init__(
        self,
        primary_db: asyncpg.Pool,
        replica_db: asyncpg.Pool,
        redis_client: redis.Redis
    ):
        self.primary = primary_db
        self.replica = replica_db
        self.redis = redis_client
        
        # Track recent updates for read-your-writes
        self.recent_update_ttl = timedelta(seconds=30)
    
    async def update_ratings_for_match(
        self,
        match_id: str,
        winner_ids: list[str],
        loser_ids: list[str],
        is_draw: bool = False
    ) -> list[RatingUpdate]:
        """
        Update ratings for all players in a match.
        
        Uses transaction for atomicity.
        Linearizable consistency (writes to primary).
        """
        updates = []
        
        async with self.primary.acquire() as conn:
            async with conn.transaction():
                # Get current ratings
                all_player_ids = winner_ids + loser_ids
                ratings = await conn.fetch(
                    """
                    SELECT player_id, elo_rating 
                    FROM players 
                    WHERE player_id = ANY($1)
                    ORDER BY player_id  -- same lock order in every transaction avoids deadlocks
                    FOR UPDATE  -- Lock rows for consistent read-modify-write
                    """,
                    all_player_ids
                )
                
                rating_map = {r['player_id']: r['elo_rating'] for r in ratings}
                
                # Calculate new ratings (ELO formula)
                new_ratings = self._calculate_elo_changes(
                    winners={pid: rating_map[pid] for pid in winner_ids},
                    losers={pid: rating_map[pid] for pid in loser_ids},
                    is_draw=is_draw
                )
                
                # Update all ratings atomically
                for player_id, new_rating in new_ratings.items():
                    old_rating = rating_map[player_id]
                    
                    await conn.execute(
                        """
                        UPDATE players 
                        SET elo_rating = $1, 
                            last_match_at = NOW(),
                            rating_version = rating_version + 1
                        WHERE player_id = $2
                        """,
                        new_rating, player_id
                    )
                    
                    # Record update for read-your-writes
                    await self._record_recent_update(player_id)
                    
                    updates.append(RatingUpdate(
                        player_id=player_id,
                        old_rating=old_rating,
                        new_rating=new_rating,
                        match_id=match_id,
                        updated_at=datetime.now(timezone.utc)
                    ))
        
        return updates
    
    async def get_rating(
        self,
        player_id: str,
        require_fresh: bool = False
    ) -> Tuple[int, int]:
        """
        Get player's rating.
        
        Returns (rating, version).
        
        If require_fresh=True or player has recent update,
        reads from primary (linearizable).
        Otherwise reads from replica (eventually consistent).
        """
        # Check for recent update (read-your-writes)
        has_recent_update = await self._has_recent_update(player_id)
        
        if require_fresh or has_recent_update:
            # Read from primary for consistency
            result = await self.primary.fetchrow(
                "SELECT elo_rating, rating_version FROM players WHERE player_id = $1",
                player_id
            )
        else:
            # Read from replica (faster, eventual consistency)
            result = await self.replica.fetchrow(
                "SELECT elo_rating, rating_version FROM players WHERE player_id = $1",
                player_id
            )
        
        if result:
            return result['elo_rating'], result['rating_version']
        
        raise PlayerNotFoundError(player_id)
    
    async def get_ratings_for_matchmaking(
        self,
        player_ids: list[str]
    ) -> Dict[str, int]:
        """
        Get ratings for matchmaking.
        
        ALWAYS reads from primary for linearizable consistency.
        Matchmaker needs accurate ratings for fair matches.
        """
        results = await self.primary.fetch(
            "SELECT player_id, elo_rating FROM players WHERE player_id = ANY($1)",
            player_ids
        )
        
        return {r['player_id']: r['elo_rating'] for r in results}
    
    async def _record_recent_update(self, player_id: str):
        """Record that player has a recent rating update."""
        key = f"rating:recent_update:{player_id}"
        await self.redis.setex(
            key,
            int(self.recent_update_ttl.total_seconds()),
            "1"
        )
    
    async def _has_recent_update(self, player_id: str) -> bool:
        """Check if player has a recent rating update."""
        key = f"rating:recent_update:{player_id}"
        return bool(await self.redis.exists(key))
    
    def _calculate_elo_changes(
        self,
        winners: Dict[str, int],
        losers: Dict[str, int],
        is_draw: bool,
        k_factor: int = 32
    ) -> Dict[str, int]:
        """Calculate new ELO ratings using standard formula."""
        # Average ratings
        winner_avg = sum(winners.values()) / len(winners)
        loser_avg = sum(losers.values()) / len(losers)
        
        # Expected scores
        exp_winner = 1 / (1 + 10 ** ((loser_avg - winner_avg) / 400))
        exp_loser = 1 - exp_winner
        
        # Actual scores
        if is_draw:
            actual_winner = 0.5
            actual_loser = 0.5
        else:
            actual_winner = 1.0
            actual_loser = 0.0
        
        # Calculate changes
        new_ratings = {}
        
        for pid, rating in winners.items():
            change = k_factor * (actual_winner - exp_winner)
            new_ratings[pid] = max(0, min(3000, round(rating + change)))
        
        for pid, rating in losers.items():
            change = k_factor * (actual_loser - exp_loser)
            new_ratings[pid] = max(0, min(3000, round(rating + change)))
        
        return new_ratings
Quick check3 questions · +5 XP each

Matchmaker leadership and rating consistency

  1. Q1/3 Why do the fencing-token check and the ZREM calls run inside one Redis Lua script?

    Answer & explanation

    Answer: A

    A check followed by a separate write leaves a window where a zombie leader passes the check and then removes players the new leader is matching. Speed is a side benefit; atomicity is the point, and a stale token is rejected rather than retried.

  2. Q2/3 The matchmaker leader crashes after removing 10 players from the queue but before writing the match. How are those players recovered?

    Answer & explanation

    Answer: C

    Queue removal is the first step of a saga, so it leaves a breadcrumb the next leader can compensate from. etcd only holds the leadership key, not the queue, and the old leader may never come back.

  3. Q3/3 Player A finished a match 5 seconds ago and opens their profile. Where does get_rating read from?

    Answer & explanation

    Answer: B

    Read-your-writes is implemented by flagging players with a recent update and routing their reads to the primary while the flag lasts. A replica might still show the old rating, which is exactly the stale-rating problem this deep dive avoids.

Deep Dive 3: Match Creation Saga (Week 5, Day 2)#

You

"Match creation is a multi-step process that can fail at any point. I'd use the saga pattern with compensation."

The Problem#

Match creation steps (can fail at any point)
  1. Remove players from queue
  2. Send ready check to all players
  3. Wait for confirmations (timeout possible)
  4. Allocate game server
  5. Write match to database
  6. Notify players of server address
If Step 4 fails (no servers available)
  • Players already removed from queue
  • They've confirmed they're ready
  • But no match happens!

Need compensation to put players back in queue.

The Solution#

DiagramMatch creation saga
%% title: Match creation saga
flowchart TD
  S1["Step 1: Remove from queue"]:::core
  S2["Step 2: Send ready checks"]:::core
  S3["Step 3: Collect confirmations"]:::core
  D{"DECISION POINT:<br/>All confirmed?"}
  S4["Step 4: Allocate game server<br/>PIVOT POINT"]:::hot
  S5["Step 5: Write match record<br/>Forward recovery: retry until success"]:::core
  S6["Step 6: Notify players<br/>Forward recovery: retry until success"]:::core
  C1["Compensation:<br/>Re-add to queue (front)"]:::bad
  C2["Compensation:<br/>Cancel ready check UI"]:::bad
  C3["Compensation:<br/>Return declined players to queue"]:::bad
  FC["Full compensation<br/>(re-queue all)"]:::bad
  S1 --> S2 --> S3 --> D
  D -->|"Continue"| S4
  D -->|"Partial compensate"| C3
  S4 -->|"If fail"| FC
  S4 -->|"If success: Forward recovery only"| S5
  S5 --> S6
  S1 -.-> C1
  S2 -.-> C2

Implementation#

pythonMatchStatus · 272 lines
# Match Creation Saga

from dataclasses import dataclass, field
from typing import Optional, List, Dict
from enum import Enum
from datetime import datetime, timedelta, timezone
import asyncio


class MatchStatus(Enum):
    PENDING_CONFIRMATIONS = "pending_confirmations"
    ALLOCATING_SERVER = "allocating_server"
    CONFIRMED = "confirmed"
    CANCELLED = "cancelled"
    FAILED = "failed"


@dataclass
class MatchCandidate:
    """A potential match being formed."""
    match_id: str
    region: str
    game_mode: str
    team1_player_ids: List[str]
    team2_player_ids: List[str]
    status: MatchStatus
    created_at: datetime
    fencing_token: int
    
    # Saga state
    removed_from_queue: bool = False
    ready_checks_sent: bool = False
    confirmations: Dict[str, bool] = field(default_factory=dict)
    server_allocated: Optional[str] = None
    match_written: bool = False


class MatchCreationSaga:
    """
    Saga for creating a match.
    
    Week 5, Day 2: Saga Pattern with compensation
    
    Handles partial failures and ensures players
    are never left in limbo.
    """
    
    def __init__(
        self,
        queue_service,
        gateway_service,
        server_allocator,
        match_repository,
        fencing_token: int
    ):
        self.queue = queue_service
        self.gateway = gateway_service
        self.servers = server_allocator
        self.matches = match_repository
        self.fencing_token = fencing_token
    
    async def execute(self, candidate: MatchCandidate) -> MatchCandidate:
        """
        Execute the match creation saga.
        
        Returns the final state of the match candidate.
        """
        all_players = candidate.team1_player_ids + candidate.team2_player_ids
        
        try:
            # Step 1: Remove players from queue
            await self._step_remove_from_queue(candidate, all_players)
            
            # Step 2: Send ready checks
            await self._step_send_ready_checks(candidate, all_players)
            
            # Step 3: Wait for confirmations
            confirmed = await self._step_collect_confirmations(
                candidate, 
                all_players,
                timeout=timedelta(seconds=30)
            )
            
            if not confirmed:
                # Partial compensation - some players declined
                await self._compensate_unconfirmed(candidate)
                candidate.status = MatchStatus.CANCELLED
                return candidate
            
            # Step 4: Allocate game server (PIVOT POINT)
            try:
                server = await self._step_allocate_server(candidate)
            except NoServersAvailableError:
                # Full compensation - re-queue everyone
                await self._compensate_full(candidate, all_players)
                candidate.status = MatchStatus.FAILED
                return candidate
            
            # PIVOT PASSED - Forward recovery only from here
            candidate.server_allocated = server.address
            
            # Step 5: Write match to database (retry until success)
            await self._step_write_match(candidate)
            
            # Step 6: Notify players (retry until success)
            await self._step_notify_players(candidate, all_players)
            
            candidate.status = MatchStatus.CONFIRMED
            return candidate
            
        except Exception as e:
            if candidate.server_allocated:
                # Past the pivot: never compensate; a recovery job retries forward
                raise
            # Unexpected error before the pivot - full compensation
            await self._compensate_full(candidate, all_players)
            candidate.status = MatchStatus.FAILED
            raise
    
    async def _step_remove_from_queue(
        self,
        candidate: MatchCandidate,
        player_ids: List[str]
    ):
        """Step 1: Remove players from queue."""
        success = await self.queue.remove_players(
            region=candidate.region,
            player_ids=player_ids,
            fencing_token=self.fencing_token,
            pending_match_id=candidate.match_id
        )
        
        if not success:
            raise FencingError("Stale fencing token")
        
        candidate.removed_from_queue = True
    
    async def _step_send_ready_checks(
        self,
        candidate: MatchCandidate,
        player_ids: List[str]
    ):
        """Step 2: Send ready checks to all players."""
        await self.gateway.send_ready_checks(
            player_ids=player_ids,
            match_id=candidate.match_id,
            game_mode=candidate.game_mode
        )
        candidate.ready_checks_sent = True
    
    async def _step_collect_confirmations(
        self,
        candidate: MatchCandidate,
        player_ids: List[str],
        timeout: timedelta
    ) -> bool:
        """Step 3: Collect player confirmations."""
        deadline = datetime.now(timezone.utc) + timeout
        
        while datetime.now(timezone.utc) < deadline:
            # Get current confirmations
            confirmations = await self.gateway.get_confirmations(
                candidate.match_id
            )
            candidate.confirmations = confirmations
            
            # Check if all confirmed
            confirmed_count = sum(1 for c in confirmations.values() if c)
            declined_count = sum(1 for c in confirmations.values() if c is False)
            
            if confirmed_count == len(player_ids):
                return True
            
            if declined_count > 0:
                # Someone explicitly declined
                return False
            
            await asyncio.sleep(0.5)
        
        # Timeout - treat as decline
        return False
    
    async def _step_allocate_server(
        self,
        candidate: MatchCandidate
    ):
        """Step 4: Allocate game server (PIVOT)."""
        server = await self.servers.allocate(
            region=candidate.region,
            game_mode=candidate.game_mode,
            match_id=candidate.match_id
        )
        return server
    
    async def _step_write_match(self, candidate: MatchCandidate):
        """Step 5: Write match to database (forward recovery)."""
        max_retries = 10
        
        for attempt in range(max_retries):
            try:
                await self.matches.create(
                    match_id=candidate.match_id,
                    region=candidate.region,
                    game_mode=candidate.game_mode,
                    team1=candidate.team1_player_ids,
                    team2=candidate.team2_player_ids,
                    server_address=candidate.server_allocated
                )
                candidate.match_written = True
                return
            except Exception as e:
                if attempt == max_retries - 1:
                    raise
                await asyncio.sleep(2 ** attempt)  # Exponential backoff
    
    async def _step_notify_players(
        self,
        candidate: MatchCandidate,
        player_ids: List[str]
    ):
        """Step 6: Notify players of match (forward recovery)."""
        # Retry until all notified
        for _ in range(10):
            try:
                await self.gateway.notify_match_ready(
                    player_ids=player_ids,
                    match_id=candidate.match_id,
                    server_address=candidate.server_allocated
                )
                return
            except Exception:
                await asyncio.sleep(1)
    
    async def _compensate_unconfirmed(self, candidate: MatchCandidate):
        """Compensate for unconfirmed players."""
        # Re-queue players who confirmed
        confirmed_players = [
            pid for pid, confirmed in candidate.confirmations.items()
            if confirmed
        ]
        
        if confirmed_players:
            await self.queue.add_players_priority(
                region=candidate.region,
                player_ids=confirmed_players,
                priority=True  # Put at front of queue
            )
        
        # Notify confirmed players match was cancelled
        await self.gateway.notify_match_cancelled(
            player_ids=confirmed_players,
            reason="Other players did not confirm"
        )
    
    async def _compensate_full(
        self,
        candidate: MatchCandidate,
        player_ids: List[str]
    ):
        """Full compensation - re-queue all players."""
        if candidate.removed_from_queue:
            await self.queue.add_players_priority(
                region=candidate.region,
                player_ids=player_ids,
                priority=True
            )
        
        if candidate.ready_checks_sent:
            await self.gateway.notify_match_cancelled(
                player_ids=player_ids,
                reason="Match creation failed"
            )

Deep Dive 4: Party Queue with Conflict Resolution (Week 5, Day 4)#

You

"Party queue adds complexity — players might update their party membership concurrently. I'd use CRDTs for party state."

The Problem#

Party conflict scenario

Alice creates party, invites Bob and Carol: Party: {Alice (leader), Bob, Carol}

Concurrent operations
  • Alice kicks Carol (on her device)
  • Carol leaves party (on her device)
  • Bob invites Dave (on his device)

All three operations happen before any sync.

Questions
  • What's the final party state?
  • What if Alice's kick and Carol's leave have different effects?
  • Is Dave in the party or not?

The Solution#

You

"I'd model party membership as an OR-Set CRDT. Each add/remove is tagged, and add wins over concurrent remove."

Party state as CRDT
Party modeled as OR-Set
  • Add member = add to set with unique tag
  • Remove member = tombstone existing tags
  • Concurrent add+remove = add wins (member stays)
Example resolution

Initial: {Alice, Bob, Carol}

  1. Operation 1: Alice kicks Carol → Tombstone Carol's tag
  2. Operation 2: Carol leaves → Tombstone Carol's tag (same effect)
  3. Operation 3: Bob invites Dave → Add Dave with new tag

Final state after merge: {Alice, Bob, Dave}

  • Carol is out (both operations removed her)
  • Dave is in (no conflicting remove)

Implementation#

pythonPartyMember · 255 lines
# Party Queue with CRDT

from dataclasses import dataclass, field
from typing import Dict, Set, Optional
import json
import uuid
import time


@dataclass
class PartyMember:
    """Member in a party with metadata."""
    player_id: str
    joined_at: float
    is_leader: bool


class PartyORSet:
    """
    Party membership using OR-Set CRDT.
    
    Week 5, Day 4: Conflict Resolution with CRDTs
    
    Handles concurrent joins/leaves/kicks without conflicts.
    Add-wins semantics ensures players aren't accidentally removed.
    """
    
    def __init__(self, party_id: str, creator_id: str):
        self.party_id = party_id
        self.leader_id = creator_id
        
        # OR-Set: player_id -> {tag -> (tombstoned, joined_at, is_leader)}
        self.members: Dict[str, Dict[str, tuple]] = {}
        
        # Add creator as first member
        self._add_member(creator_id, is_leader=True)
    
    def _generate_tag(self) -> str:
        """Generate unique tag for this operation."""
        return f"{uuid.uuid4()}:{time.time()}"
    
    def _add_member(self, player_id: str, is_leader: bool = False):
        """Add a member with new unique tag."""
        tag = self._generate_tag()
        
        if player_id not in self.members:
            self.members[player_id] = {}
        
        self.members[player_id][tag] = (False, time.time(), is_leader)
    
    def add_member(self, player_id: str) -> bool:
        """Add a player to the party."""
        if self.get_member_count() >= 5:
            return False  # Party full (local check only: concurrent joins on
                          # different servers can exceed 5 after a merge, since
                          # CRDTs cannot enforce global invariants)
        
        self._add_member(player_id)
        return True
    
    def remove_member(self, player_id: str):
        """Remove a player (tombstone all their tags)."""
        if player_id in self.members:
            for tag in self.members[player_id]:
                tombstoned, joined_at, is_leader = self.members[player_id][tag]
                self.members[player_id][tag] = (True, joined_at, is_leader)
    
    def is_member(self, player_id: str) -> bool:
        """Check if player is currently a member."""
        if player_id not in self.members:
            return False
        
        # Member if any non-tombstoned tag exists
        return any(
            not tombstoned
            for tombstoned, _, _ in self.members[player_id].values()
        )
    
    def get_members(self) -> list[str]:
        """Get all current members."""
        return [
            pid for pid in self.members
            if self.is_member(pid)
        ]
    
    def get_member_count(self) -> int:
        """Get count of current members."""
        return len(self.get_members())
    
    def merge(self, other: 'PartyORSet'):
        """
        Merge with another party state.
        
        OR-Set merge:
        - Union of all tags
        - Tombstoned if tombstoned in either
        - Add wins: new tag from concurrent add survives
        """
        all_players = set(self.members.keys()) | set(other.members.keys())
        
        for player_id in all_players:
            self_tags = self.members.get(player_id, {})
            other_tags = other.members.get(player_id, {})
            
            merged_tags = {}
            all_tag_ids = set(self_tags.keys()) | set(other_tags.keys())
            
            for tag in all_tag_ids:
                if tag in self_tags and tag in other_tags:
                    # Both have this tag - tombstoned if either tombstoned
                    self_tomb, self_time, self_leader = self_tags[tag]
                    other_tomb, other_time, other_leader = other_tags[tag]
                    merged_tags[tag] = (
                        self_tomb or other_tomb,
                        max(self_time, other_time),
                        self_leader or other_leader
                    )
                elif tag in self_tags:
                    merged_tags[tag] = self_tags[tag]
                else:
                    merged_tags[tag] = other_tags[tag]
            
            if merged_tags:
                self.members[player_id] = merged_tags
    
    def to_dict(self) -> dict:
        """Serialize for storage/transmission."""
        return {
            "party_id": self.party_id,
            "leader_id": self.leader_id,
            "members": {
                pid: {
                    tag: {"tombstoned": t, "joined_at": j, "is_leader": l}
                    for tag, (t, j, l) in tags.items()
                }
                for pid, tags in self.members.items()
            }
        }
    
    @classmethod
    def from_dict(cls, data: dict) -> 'PartyORSet':
        """Deserialize from storage."""
        party = cls.__new__(cls)
        party.party_id = data["party_id"]
        party.leader_id = data["leader_id"]
        party.members = {
            pid: {
                tag: (info["tombstoned"], info["joined_at"], info["is_leader"])
                for tag, info in tags.items()
            }
            for pid, tags in data["members"].items()
        }
        return party


class PartyService:
    """
    Party management service with CRDT-based conflict resolution.
    """
    
    def __init__(self, redis_client):
        self.redis = redis_client
    
    async def create_party(self, creator_id: str) -> str:
        """Create a new party."""
        party_id = str(uuid.uuid4())
        party = PartyORSet(party_id, creator_id)
        
        await self._save_party(party)
        await self._set_player_party(creator_id, party_id)
        
        return party_id
    
    async def join_party(self, party_id: str, player_id: str) -> bool:
        """Join an existing party."""
        party = await self._load_party(party_id)
        if not party:
            return False
        
        if party.add_member(player_id):
            await self._save_party(party)
            await self._set_player_party(player_id, party_id)
            return True
        
        return False
    
    async def leave_party(self, party_id: str, player_id: str):
        """Leave a party."""
        party = await self._load_party(party_id)
        if not party:
            return
        
        party.remove_member(player_id)
        await self._save_party(party)
        await self._clear_player_party(player_id)
        
        # Check if party is now empty
        if party.get_member_count() == 0:
            await self._delete_party(party_id)
    
    async def merge_party_updates(
        self,
        party_id: str,
        remote_state: dict
    ):
        """
        Merge remote party state (from another server).
        
        Used for cross-region party sync.
        """
        local_party = await self._load_party(party_id)
        remote_party = PartyORSet.from_dict(remote_state)
        
        if local_party:
            local_party.merge(remote_party)
            await self._save_party(local_party)
        else:
            await self._save_party(remote_party)
    
    async def get_party_for_matchmaking(
        self,
        party_id: str
    ) -> Optional[list[str]]:
        """Get party members for matchmaking."""
        party = await self._load_party(party_id)
        if not party:
            return None
        
        return party.get_members()
    
    async def _save_party(self, party: PartyORSet):
        """Save party to Redis."""
        await self.redis.set(
            f"party:{party.party_id}",
            json.dumps(party.to_dict())
        )
    
    async def _load_party(self, party_id: str) -> Optional[PartyORSet]:
        """Load party from Redis."""
        data = await self.redis.get(f"party:{party_id}")
        if data:
            return PartyORSet.from_dict(json.loads(data))
        return None
    
    async def _delete_party(self, party_id: str):
        """Delete party from Redis."""
        await self.redis.delete(f"party:{party_id}")
    
    async def _set_player_party(self, player_id: str, party_id: str):
        """Set player's current party."""
        await self.redis.set(f"player_party:{player_id}", party_id)
    
    async def _clear_player_party(self, player_id: str):
        """Clear player's current party."""
        await self.redis.delete(f"player_party:{player_id}")
Quick check3 questions · +5 XP each

Match saga and party CRDT

  1. Q1/3 All 10 players confirmed, but server allocation fails with "no servers available". What does the match-creation saga do?

    Answer & explanation

    Answer: D

    Allocation is the pivot, and it failed, so the saga has not crossed it yet; it undoes the queue removal and ready-check UI. Forward recovery only applies once the pivot succeeds, and players who did nothing wrong should not lose their place.

  2. Q2/3 A server was allocated, then writing the match record fails with a transient database error. What does the design call for?

    Answer & explanation

    Answer: B

    After the pivot the saga is committed to completing, so failures are handled by retrying forward. Compensating now would throw away a match that already has a server waiting for its players.

  3. Q3/3 Alice kicks Carol from the party while, on another server, Carol is concurrently re-invited and re-joins. Party membership is an OR-Set. After the states merge, is Carol in the party?

    Answer & explanation

    Answer: A

    An OR-Set remove only tombstones the tags it has seen; a concurrent add carries a fresh tag that survives. OR-Sets have no notion of leader priority or timestamps; that would be LWW or custom merge logic.

Your turn · checkpoint 3not answered

Ten times the players

Scale to 10× the load at the same 30-second median wait. Using Little's law as in Phase 2, roughly how many players sit in the queue? With one matchmaker leader per region running a 500 ms cycle, what breaks first, and how do you add parallelism without risking double booking?

Think it through, or jot it down. Then compare.

One strong answer

  • Queue size: 10 × ~1,300 matched a second ≈ 13,000 a second × 30 s ≈ ~400,000 queued players (vs ~40,000 today).
  • First bottleneck: the leader's 500 ms cycle can't scan that many players per region in time. More instances don't help, because only the leader may act on the queue.
  • Fix: split each region's queue into disjoint ELO bands, each matched separately. No player is in two bands, so parallelism doesn't bring back double booking.

Phase 5 · Stage 5 of 6 · 5 min budget · 2 min read

Phase 5: Scaling and Edge Cases (5 minutes)#

Interviewer

"How would this system scale to 10x the current load?"

Scaling Strategy#

You

"There are several scaling vectors to consider..."

Horizontal Scaling#

Current → 10X scale
Component Current Scaled
Queue Service 10 instances (2 per region) 30 instances (6 per region)
Matchmaker 5 leaders + 10 standbys 5 leaders + 10 standbys (hot failover)
Rating Service 10 instances 50 instances (stateless, scales horizontally)
Database 3-node cluster Sharded by region (5 clusters × 3 nodes)
Redis 3-node cluster 5 clusters (1 per region)

Matchmaker note: Cannot horizontally scale leaders (one per region). Instead, optimize matching algorithm.

Bottleneck Analysis#

Bottleneck analysis
Component Current Limit Scaling Solution
Queue joins ~4K/sec (peak) Shard by region + game mode
Matchmaker cycle 500ms/region Optimize algorithm, parallel
Rating updates ~4K/sec peak, bounded by the 10K/sec database write limit Batch updates, async writes
WebSocket conns 500K/server Add gateway instances
Database writes 10K/sec Shard by player_id hash

First bottleneck: Matchmaker cycle time. At 10x scale, 500ms not enough to process ~400K queued players. Solution: Parallelize within region by ELO bands.

Edge Cases#

Transcript
Interviewer

"What are some edge cases we should handle?"

You

"Let me walk through several..."

Edge Case 1: Player Disconnects During Ready Check#

Player disconnects during ready check

Scenario: Player confirms ready, then disconnects before match starts

Handling
  • 10-second grace period for reconnection
  • If not reconnected: treat as decline
  • Re-queue other confirmed players (priority)
  • Player who disconnected gets short queue ban (1 minute)

Edge Case 2: Rating Update During Queue#

Rating update during queue

Scenario: Player queued at 1500 ELO, plays another mode, rating updates to 1550 while still in first queue

Handling
  • Queue stores snapshot of rating at join time (read from the primary, so fresh at that moment)
  • Matchmaker uses queue-time rating for matching (this is the "linearizable read" from Deep Dive 2)
  • After match, new rating is used for next queue
  • Prevents gaming the system

Edge Case 3: Cross-Region Party#

Cross-region party

Scenario: Alice (NA) parties with Bob (EU)

Handling
  • Party stored in both regions (CRDT sync)
  • When queuing, use lowest latency region for party
  • Show warning about potential latency for some members
  • Respect party leader's preference

Failure Scenarios#

Failure Detection Impact Recovery
Matchmaker crashes etcd lease expires Queue builds up (~10 sec lease TTL + election) New leader elected, continues
Redis cluster fails Health checks Players can't queue Fallback to local memory, replay from Kafka
Database primary fails Connection errors Rating updates delayed Promote replica, replay pending updates
Game server crashes Heartbeat timeout Match lost Players returned to queue, no ELO change
Kafka unavailable Connection errors Events not published Local queue, retry when available
Quick check2 questions · +5 XP each

Scaling and failure handling

  1. Q1/2 At 10x load, the matchmaker cycle becomes the first bottleneck. Why not simply run several active matchmakers per region?

    Answer & explanation

    Answer: C

    One leader per queue is what prevents double-booking. Splitting the queue into disjoint ELO bands keeps that guarantee while adding parallelism. Standbys help failover, but only one instance may act on a given set of players at a time.

  2. Q2/2 A game server crashes in the middle of a match. What should happen to the players and their ratings?

    Answer & explanation

    Answer: B

    The outcome is unknown, so no rating change is applied; players are returned to matchmaking. Penalizing both teams for an infrastructure failure would damage the competitive integrity the design protects.

Phase 6 · Stage 6 of 6 · 2 min

Phase 6: Monitoring and Operations#

Interviewer

"How would you monitor this system in production?"

Key Metrics#

You

"I would track metrics at multiple levels..."

Business Metrics#

Metric Target Alert
Median queue time by region <30s >60s
Match quality (ELO difference) <100 >200
Player satisfaction (match completion rate) >95% —

System Metrics#

Matchmaking dashboard

Queue health

  • Queue depth (all regions): ~40K players
  • Median wait time: 28 seconds
  • P95 wait time: 85 seconds

Matchmaker health

  • Matches created/min: 8,200
  • Cycle time (p99): 450ms
  • Failed matches/min: 12

Rating service

  • Updates/sec: 1,350
  • Update latency (p99): 45ms
  • Primary DB lag: 2ms

Leader election

  • Current leaders by region: NA ok · EU ok · APAC ok · SA ok · OCE ok
  • Last failover: 2 hours ago
  • Failover count (24h): 1

Alerting Strategy#

Alerting strategy

CRITICAL (PagerDuty)

  • No matchmaker leader for region > 30 seconds
  • Queue depth growing > 10% per minute for 5 minutes
  • Rating update latency p99 > 5 seconds
  • Database replication lag > 10 seconds

WARNING (Slack)

  • Median queue time > 45 seconds
  • Match ELO spread > 150 average
  • Game server allocation failures > 1%
  • Kafka consumer lag > 1000 messages

INFO (Dashboard only)

  • Leader election occurred
  • New region peak queue depth
  • Daily match count records

Runbook Snippet#

Runbook: Matchmaker Leader Not Elected
Symptoms
  • Alert: "No matchmaker leader for NA > 30s"
  • Queue depth increasing
  • No new matches being created
Diagnosis
  1. Check etcd cluster health: $ etcdctl endpoint health
  2. Check matchmaker pods: $ kubectl get pods -l app=matchmaker
  3. Check etcd leader election key: $ etcdctl get /matchmaker/na/leader
Resolution
  1. If etcd unhealthy:
    • Check etcd pod logs
    • Restart etcd pods if necessary
  2. If matchmaker pods crashed:
    • Check for OOM or panic in logs
    • Scale up replicas
  3. If election stuck:
    • Delete stale lease: etcdctl lease revoke <lease-id>
    • Matchmakers will re-elect
  4. Escalate to: Platform Team
Quick check3 questions · +5 XP each

Operating matchmaking

  1. Q1/3 "No matchmaker leader for a region > 30 seconds" is critical. What are players in that region experiencing?

    Answer & explanation

    Answer: B

    The leader is the one matchmaker allowed to form matches for its region, so without a leader nothing gets matched. Games already running are unaffected.

  2. Q2/3 An election is stuck because the crashed leader's lease is still registered. What does the runbook do?

    Answer & explanation

    Answer: B

    Leadership is held by a lease in etcd. Revoking the dead holder's lease frees the key for a clean re-election, with the fencing guarantees intact. Starting a leader by hand would risk two leaders.

  3. Q3/3 Why alert on queue depth growing more than 10% a minute for 5 minutes, rather than on a fixed depth?

    Answer & explanation

    Answer: B

    A big queue at peak can be normal. A queue that keeps growing is the real signal: arrivals are outpacing matches, whatever the absolute number.

Wrap-up · 2 min

Interview Conclusion#

Transcript
Interviewer

"Excellent work. You've demonstrated strong understanding of distributed systems, particularly around leader election and consistency models. The saga pattern for match creation was well thought out. Any questions for me?"

You

"Thank you! I'm curious how you handle the matchmaking algorithm itself — do you use any ML to predict match quality? And how do you balance queue time versus match fairness in practice?"

Interviewer

"Great questions. We actually use a learned model to predict match outcomes and optimize for close games. The queue time trade-off is a constant tuning exercise — we have knobs for each region based on player population."

Summary: Week 5 Concepts Applied#

Week 5: Consistency and Coordination#

Day Concept Application in This Design
Day 1 Consistency Models Linearizable reads for matchmaking ratings, read-your-writes for player's own rating after match
Day 2 Saga Pattern Match creation as saga with compensation for failed/declined matches
Day 3 Workflow Orchestration Could use Temporal for complex match lifecycle (an alternative; not used in this design)
Day 4 Conflict Resolution Party membership as OR-Set CRDT for concurrent join/leave/kick
Day 5 Leader Election Single matchmaker leader per region with fencing tokens

Code Patterns Demonstrated#

Code patterns demonstrated
  • 1. Leader election with fencing
    • MatchmakerLeaderElection class
    • FencedQueueOperations for safe state modification
    • etcd-based election with lease TTL
  • 2. Consistency guarantees
    • RatingService with read-your-writes
    • Linearizable reads for matchmaking
    • Eventual consistency for leaderboards
  • 3. Saga pattern
    • MatchCreationSaga with compensation
    • Pivot point at server allocation
    • Forward recovery after pivot
  • 4. CRDT for conflict resolution
    • PartyORSet for membership
    • Add-wins semantics
    • Merge operation for cross-region sync
  • 5. Fencing token validation
    • Lua script in Redis for atomic check
    • Prevents zombie leader writes
    • Monotonically increasing tokens

Self-Assessment Checklist#

After studying this capstone, you should be able to:

After studying this capstone, you should be able to0/10 ready
  • Explain why leader election is necessary for matchmaking
  • Implement fencing tokens to prevent zombie leader corruption
  • Design consistency models based on operation requirements
  • Use sagas for multi-step operations with compensation
  • Apply CRDTs for concurrent state modification
  • Choose between choreography and orchestration for workflows
  • Identify pivot points in saga design
  • Handle partial failures gracefully
  • Design monitoring for distributed coordination
  • Debug leader election and consistency issues

Key Interview Takeaways#

Matchmaking system checklist
  • Leader election for matchmaker (one per region)
  • Fencing tokens prevent zombie leader writes
  • Saga pattern for match creation with compensation
  • Strong consistency for ratings (linearizable)
  • CRDTs for party state (conflict-free merging)
  • Read-your-writes for player viewing own rating
  • Graceful degradation during failures
  • Horizontal scaling where possible
  • Clear monitoring and alerting strategy
Key trade-offs
  • Queue time vs match quality (configurable ELO range)
  • Strong vs eventual consistency (per-operation choice)
  • Single leader vs parallel processing (leader per region)

This capstone problem integrates all concepts from Week 5 of the System Design Mastery Series. Use this as a template for approaching similar interview problems involving distributed coordination.

this lesson in shorts · 1

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Boss · Multiplayer Game Matchmaking System
Finish BossStages clear as you read them (0/6 so far). Skipping ahead doesn't count: marking complete unlocks after ~6 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

AmazonBlog post · ~9 min

Leader Election in Distributed Systems

Marc Brooker · 2019
What
How Amazon uses leader election and where it goes wrong.
Key idea
Leases are the most widely used election mechanism at Amazon; they are built on measured durations rather than synchronised wall clocks, and leadership is usually sharded per item of data.
Why here
The capstone's matchmaker leader: shard queues so each has one leader, and make its actions safe if leadership flips.
taught here: Leader election for the matchmaker
MicrosoftPaper · ~30 min

TrueSkill(TM): A Bayesian Skill Rating System

Ralf Herbrich, Tom Minka, Thore Graepel · 2007
What
The paper behind Xbox Live's skill rating and matchmaking.
Key idea
Each player's skill is a mean plus an uncertainty that shrinks with games played, updated by Bayesian inference after every match, including team games.
Why here
What the capstone's rating update actually computes, and why it must be applied exactly once per match.
taught here: Rating updates
AmazonDocs · ~5 min

How Amazon GameLift Servers FlexMatch works

What
AWS's documentation of its managed matchmaking service.
Key idea
Games send matchmaking requests to a matchmaker whose rule set defines team structure and player attributes such as skill and latency, then approves or rejects potential matches.
Why here
A production version of the capstone's matchmaking loop: requests in, rule-based evaluation, matches out to game servers.
taught here: Matchmaking tickets and rule sets

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…