Build a Game Matchmaking System


distributed-systems scalability performance

System Design Deep Dive

Game Matchmaking System

Balance skill, latency, and patience at once, or the queue breaks first.

⏱ 17 min read 📐 Advanced 🌐 Distributed Systems

A summer camp with six basketball courts and one counselor running pickup games doesn’t seat kids by arrival order alone. The counselor is juggling three things at once: skill (nobody wants a game where one team is all twelve-year-old varsity players and the other is complete beginners), which court is actually close by (a kid isn’t walking fifteen minutes to the far court when three closer ones are open), and how long a kid has already been standing on the sideline. Get the skill match wrong and the game is over in two minutes, one team steamrolling the other. Get the proximity wrong and half the kids give up walking before they reach the court. Ignore the sideline clock and the same unlucky, oddly-skilled kid waits all afternoon while everyone else gets picked.

A game matchmaking service runs the exact same juggling act, except the courts are data centers, the skill ranking is an ELO rating, and the sideline clock is a queue wait time SLO measured in seconds instead of afternoons. At 800,000 concurrent players queueing for 5v5 matches across dozens of regions, the system has to pull compatible tickets out of a pool of tens of thousands of waiting players, evaluate every candidate along skill, latency, and wait time simultaneously, and hand off a fully formed, fair, low-lag match to a dedicated game server, typically within 45 seconds at the 95th percentile.

The naive version of this problem looks deceptively easy: sort the queue by arrival time and match the first ten people who show up. That’s a FIFO queue, and it produces exactly the kind of stomp the counselor is trying to avoid; a brand-new player at 900 ELO and a five-year veteran at 2400 end up on opposite teams by pure coincidence of timing. Sort by skill instead, with a narrow fixed window (say, plus or minus 50 ELO), and the fairness problem is fixed, but a new one appears: in any bracket outside the dense middle of the skill distribution, in an off-peak region, at 3am local time, there simply may not be nine other players within 50 ELO points online right now. That ticket waits forever, and forever is not an SLO. Add latency into the equation and it gets worse still: two players can be a perfect skill match and sit on opposite sides of an ocean, and forcing them into the same match produces an unplayable 180ms-round-trip game even though the ELO numbers looked great on paper.

The forces in real tension here are fairness (the closest possible skill match), playability (the lowest possible latency to the server hosting the match), and patience (a bounded queue wait, no matter how thin a particular skill bracket, region, or hour happens to be), and none of them can be solved in isolation, because tightening any one of them starves the others. We need to solve for organizing hundreds of thousands of in-flight tickets into a structure that’s searchable in milliseconds, a single quality score that lets wildly different tickets (skill delta, latency, party size, minutes waited) be compared on equal footing, and a principled, monotonic way to relax the fairness bar over time so the wait-time SLO never gets breached even in the thinnest bracket, all simultaneously.

Requirements and Constraints

Functional Requirements

  • Solo players and pre-made parties of 2 to 5 players can join a matchmaking queue for a specific game mode and region
  • The system computes and updates every player’s ELO skill rating immediately after a match completes
  • The system matches tickets into two balanced 5-player teams by jointly considering skill rating, latency to candidate game servers, and how long each ticket has already been queued
  • A ticket’s acceptable skill and latency window widens automatically the longer it waits, so no ticket waits indefinitely for a match that will never appear
  • Parties are matched as an atomic, indivisible unit, never split across opposing teams, and are matched preferentially against other parties before being paired against solo-only teams
  • The system selects the game server region and cluster with the lowest worst-case latency across all ten matched players and allocates a dedicated server session
  • Clients receive a live queue status stream (elapsed wait, estimated time remaining) while their ticket is searching
  • A player or party can cancel their ticket at any point before a match is confirmed, cleanly releasing it from the pool

Non-Functional Requirements

  • Peak scale: 800,000 concurrent players, roughly 750 tickets/sec sustained ticket ingress at peak, roughly 75 matches formed/sec at peak
  • Queue wait SLO: p95 under 45 seconds and p99 under 90 seconds, measured per (region, game_mode, skill_bracket)
  • Match fairness: average inter-team skill delta under 60 ELO points for at least 95% of matches formed inside the SLO window
  • Ticket durability: a matchmaker worker crash must not silently drop an in-flight ticket; the ticket resumes searching, with its accumulated wait time preserved, within 2 seconds
  • Rating update latency: ELO changes are computed and durably persisted within 2 seconds of match completion
  • Availability: matchmaking pool availability of 99.9%; a regional outage reroutes affected tickets to the next-nearest healthy region rather than blocking matchmaking entirely
  • Latency freshness: no match is formed using a per-player latency sample older than 30 seconds

Constraints and Assumptions

  • Out of scope: the actual game server simulation and netcode, anti-cheat, voice chat, and the in-game economy. This design covers matchmaking and the trigger to allocate a server, not the game itself
  • We assume clients report round-trip latency probes honestly; detecting probe spoofing or queue-dodging abuse is flagged as a real concern but not designed in depth here
  • We assume a pre-existing dedicated game server fleet and orchestrator (an Agones-style system) that this design allocates sessions from, rather than building the orchestrator itself
  • This design targets a single game mode family (ranked 5v5) at a time; supporting many simultaneous modes is discussed briefly in Scaling and Performance but not designed exhaustively
  • We assume every match has a fixed size of 10 players (5v5); variable team sizes are a straightforward extension, not the primary design target

High-Level Architecture

The system has six major components: the Matchmaking Gateway (accepts tickets from solo players and parties, streams queue status back to clients), the Latency Probe Service (measures and maintains a per-player RTT estimate to candidate game server clusters), the Matchmaking Pool (an in-memory, region-and-skill-sharded structure holding every in-flight ticket), the Match Scoring and Assembly Engine (the background workers that search the pool, expand brackets over time, score candidates, and draft balanced teams), the Game Server Allocator (picks the best region for a formed match and requests a session from the dedicated server fleet), and the Post-Match Rating Service (ingests match results and updates ELO ratings).

Game matchmaking system architecture overview

A ticket’s journey starts at the Gateway, which validates the request (solo or party), computes an effective ELO rating for it, and writes it into the Matchmaking Pool shard for its region and skill bucket. In parallel, the Latency Probe Service keeps a rolling RTT estimate for every player against a handful of nearby server clusters, refreshed roughly every 10 seconds, so the Assembly Engine always has a recent latency picture to work with. The Assembly Engine continuously scans pool shards, expanding each ticket’s acceptable skill and latency window the longer it has waited, scoring every candidate set it finds against a single quality function, and drafting compatible tickets (parties kept intact) into two 5-player teams once a candidate set clears the quality bar. Once a team pair is formed, the Allocator runs a short ready-check, picks the server cluster that minimizes the worst latency any single player would see, and hands the match off to a warm dedicated server. When the match ends, the Post-Match Rating Service applies the ELO update for all ten players and closes out the audit trail used to measure the queue wait SLO.

Key Insight

The entire design hinges on one decision: a ticket's search constraints only ever widen, never tighten, for as long as it waits. That single monotonic property is what turns "match quality" from a fuzzy goal into a bounded guarantee. It means every ticket is mathematically guaranteed to eventually accept almost any candidate, which is precisely what keeps the queue wait SLO from being breached by an unusually thin bracket, an odd hour, or a small region, without anyone having to hand-tune a special case for it.

The Matchmaking Pool and Ticket Queue

This component’s job is to hold every in-flight ticket in a structure a background worker can filter by region, skill, and elapsed wait time in milliseconds, without scanning the whole pool on every search tick.

A smart engineer’s first instinct is a single global queue, either FIFO or sorted once by ELO at insert time. Both break down fast. FIFO ignores skill entirely, which we already covered. A single sorted structure across every region and every skill level creates contention on one hot data structure and forces every search to skip over players an ocean away or leagues out of range just to find the handful that matter. What actually works is the same idea a library uses for a card catalog: don’t search the whole library, walk straight to the drawer for the right subject and call number range.

Matchmaking pool internals showing region and skill bracket sharded ticket search

We shard the pool by (region, game_mode, coarse_elo_bucket), with each bucket spanning 200 ELO points, backed by a Redis sorted set per shard. The sorted set’s score is the ticket’s elo_rating, so a bracket search is a single ZRANGEBYSCORE call, O(log N + M) where M is the number of tickets actually in range, never a scan of the full pool.

// Matchmaking Gateway -> Pool RPC contract
// Demonstrates: ticket submission shape for solo players and parties
syntax = "proto3";

package matchmaking.v1;

message TicketRequest {
  string requester_id = 1;        // player_id or party_id
  repeated string player_ids = 2; // 1 for solo, 2-5 for a party
  string region = 3;              // preferred home region, e.g. "na-east"
  string game_mode = 4;           // e.g. "ranked_5v5"
  int32 party_elo = 5;            // pre-computed effective party rating
}

message TicketResponse {
  string ticket_id = 1;
  int64 queued_at_ms = 2;
  int32 initial_search_radius = 3;
}

service MatchmakingGateway {
  rpc SubmitTicket(TicketRequest) returns (TicketResponse);
  rpc CancelTicket(CancelRequest) returns (CancelResponse);
}

message CancelRequest {
  string ticket_id = 1;
}

message CancelResponse {
  bool cancelled = 1;
}
# Matchmaking Pool: region and bracket sharded ticket queue backed by Redis
# Demonstrates: ticket insert, bracket-window search, and multi-bucket fan-out
import redis

BRACKET_WIDTH = 200  # ELO points per coarse bucket, used only for shard routing

class TicketPoolShard:
    # One Redis sorted set per (region, mode, elo bucket). Score is elo_rating,
    # so ZRANGEBYSCORE gives every ticket within a skill window in O(log N + M).
    def __init__(self, client: redis.Redis, region: str, mode: str, elo_bucket: int):
        self.client = client
        self.key = f"pool:{region}:{mode}:{elo_bucket}"
        self.meta_prefix = f"ticket_meta:{region}:{mode}:{elo_bucket}"

    def insert(self, ticket_id: str, elo: int, queued_at_ms: int, party_size: int) -> None:
        self.client.zadd(self.key, {ticket_id: elo})
        self.client.hset(f"{self.meta_prefix}:{ticket_id}", mapping={
            "queued_at_ms": queued_at_ms,
            "party_size": party_size,
            "elo": elo,
        })

    def search_window(self, elo: int, radius: int) -> list[str]:
        # Every ticket whose rating falls inside [elo - radius, elo + radius]
        return self.client.zrangebyscore(self.key, elo - radius, elo + radius)

    def remove(self, ticket_id: str) -> None:
        self.client.zrem(self.key, ticket_id)
        self.client.delete(f"{self.meta_prefix}:{ticket_id}")

def adjacent_buckets(elo: int, radius: int, bracket_width: int = BRACKET_WIDTH) -> list[int]:
    # A wide search radius can span more than one coarse bucket; return every
    # bucket index the window touches so the caller can fan out across shards
    low_bucket = (elo - radius) // bracket_width
    high_bucket = (elo + radius) // bracket_width
    return list(range(low_bucket, high_bucket + 1))

Premium Content

Unlock the full article along with everything else in the archive — all in one place.

In-depth analysis Expert insights Full archive access
Unlock Full Article