Courses › System Design › Question Breakdowns

Design Online Chess

Rating-based matchmaking, one authoritative server per game, fair clocks, reconnects and spectators.

Design an online chess platform like Lichess or Chess.com: players find an opponent of similar rating, play a real-time game with clocks, and see the result reflected in their rating. It's a question about matchmaking, real-time bidirectional connections, authoritative game state and clocks that are fair over unreliable networks.

Requirements

Functional

  • Players can find a match for a time control (e.g. 3+2 blitz) against someone of similar rating
  • Players make moves in real time; the server validates them and both players see them instantly
  • Each player has a clock; running out of time loses the game
  • Games end by checkmate, resignation, draw agreement, timeout or abandonment, and ratings update
  • Spectators can watch live games; finished games are stored and replayable

Out of scope: tournaments, chat, puzzles, anti-cheat engines (mention as an extension), friend challenges.

Non-functional

  • Low latency: a move reaches the opponent in < 100–200ms
  • Scale: ~10M games/day, ~100k concurrent games, peaks of ~10k matchmaking requests/second
  • Consistency: the server is the single source of truth for each game; no illegal or duplicated moves
  • Fair clocks: players shouldn't lose time to network lag
  • Resilient: a server crash or a player's brief disconnect shouldn't destroy the game

[!TIP] It's a game server, not a CRUD app Each live game is a small piece of hot, fast-changing state touched by exactly two players. Keep it in memory on one server (the game's owner), push moves over WebSockets, and persist asynchronously. That framing answers most of the latency questions.

Core Entities

  • Player — rating per time control (Glicko-2), rating deviation, status
  • Game — players, time control, moves (SAN/UCI list), clocks, status, result
  • Seek — a matchmaking request: player, time control, rating, created at

API

POST /v1/seeks   { "timeControl": "3+2", "rated": true }    → 202 { "seekId": "sk_1" }
DELETE /v1/seeks/{seekId}

WS  /v1/games/{gameId}
  → { "type": "move", "uci": "e2e4", "ply": 1 }
  ← { "type": "move", "uci": "e2e4", "ply": 1, "clocks": { "white": 178400, "black": 180000 } }
  → { "type": "resign" } | { "type": "offerDraw" }
  ← { "type": "end", "result": "1-0", "reason": "timeout" }

The client sends the ply number with each move so duplicates and out-of-order moves are rejected.

High-Level Design

p1 = client "Player A" @0,0
p2 = client "Player B" @0,2
gw = service "WebSocket\nGateway" @1,1
mm = service "Matchmaking" @2,0
pool = cache "Seek Pools\n(Redis, by time control)" @3,0
game = service "Game Server\n(owns live games)" @2,1
pub = cache "Pub/Sub\n(spectators)" @3,1
db = store "Games DB" @2,2
rate = service "Rating Service" @3,2
p1 -> gw
p2 -> gw
gw -> mm : seek
mm -> pool
mm -> game : create game
gw <-> game : moves
game -> pub
game -> db : moves (async)
game -> rate : result
  • Matchmaking puts each seek into a pool keyed by time control and pairs players with close ratings.
  • A paired game is assigned to a game server (by consistent hashing on game ID); both players' connections are routed to it.
  • The game server validates each move, updates clocks, and pushes the move to the opponent and to spectators.
  • Moves are appended to the games DB asynchronously; at the end, the rating service updates both players.

Deep Dives

1. Matchmaking

Keep a sorted set per time control in Redis, scored by rating. A matchmaker loop for each pool:

  • Takes the oldest waiting seek and looks for the closest rating within a window (±50).
  • Widens the window over time (±50 → ±100 → ±200 every few seconds) so nobody waits forever.
  • Pairs them atomically (a Lua script or a single-threaded matcher per pool removes both seeks) so a player can't be matched into two games.

Popular pools (3+0 blitz) can be sharded by rating band; quiet pools need the widening rule most.

2. One authoritative owner per game

Each game lives in memory on exactly one game server: board state, move list, clocks. Both players' WebSocket messages for that game are routed there (gateway looks up gameId → server via consistent hashing or a registry).

  • The server validates every move (legal, correct turn, correct ply) before accepting it.
  • Moves are ordered by the server; the client's copy is only a view.
  • This avoids distributed locks entirely — concurrency is just one game's actor processing one message at a time.

3. Clocks and lag

The server is authoritative, but naive server-side timing charges players for network latency. Lichess-style lag compensation:

  • The server measures each player's round-trip time and credits back estimated network lag (up to a cap) on each move.
  • The client shows a locally ticking clock and resyncs with the server's clock values on every message.
  • Timeouts are enforced by a timer on the game server — not by the losing client reporting it.

4. Disconnects and server failures

  • Player disconnect: keep the game running; the opponent sees "opponent disconnected". If the player doesn't return within e.g. 30–60s, the opponent can claim victory. Reconnecting clients request the full game state and continue.
  • Game server crash: persist every accepted move (append to the DB or a log such as Redis Streams/Kafka) before acknowledging it to players. A replacement server rebuilds the game by replaying moves, and clients reconnect via the gateway.

[!WARNING] Ack after durable write If the server acknowledges a move and then crashes before persisting it, the move vanishes on recovery while both players saw it. Writing to a fast log first costs a millisecond or two and keeps recovery honest. (Lichess batches move writes for efficiency and flushes on important events; that trade-off is worth naming.)

5. Spectators

A top game can have 100k viewers. The game server publishes each move to a pub/sub channel per game; a fan-out tier of WebSocket servers subscribes and pushes to viewers. Viewers can tolerate a short delay (and big broadcasts often add a deliberate delay against cheating).

6. Storage and ratings

  • Live state: in memory + move log.
  • Finished games: a compact document per game (players, PGN/move list, clock times, result) in a document or wide-column store, sharded by game ID; index by player ID for history. Billions of games is fine — each is a few hundred bytes compressed.
  • Ratings: Glicko-2 per time control, updated transactionally per finished game (one row per player per time control). Updates are idempotent by game ID.

What's expected at each level

  • Mid-level: matchmaking by rating, WebSockets for moves, server-side move validation, game storage.
  • Senior: one authoritative owner per game, widening matchmaking windows with atomic pairing, server-enforced clocks, disconnect handling.
  • Staff: lag compensation, durable move logs and crash recovery, spectator fan-out at scale, sharding matchmaking pools, and anti-cheat as a separate async pipeline.