Scale Interview
System DesignMedium

Design Chess.com

Online Chess / Real-Time Game System Design

Overview

An online chess platform lets two players find each other and play a timed game in real time over the internet — like Chess.com or Lichess. A player clicks "Play," waits a moment to be paired with an opponent of similar skill, and then the two trade moves on a shared board while each side's clock counts down. The moment one player runs out of time, they lose.

That experience hides three hard engineering problems.

First, pairing players quickly and fairly. When a player asks for a game, you must find another waiting player with a similar rating — fast. Pair people who are too far apart in skill and the game is no fun; wait too long for a perfect match and the player gives up and leaves. You need a matchmaking pool that finds a close-enough opponent within seconds.

Second, exchanging moves in real time without trusting the client. A move must reach the opponent in well under a second, but you can never let a client tell you "I moved my queen here, and by the way the move is legal." A modified client could make illegal moves, claim impossible board states, or lie about whose turn it is. The server must hold the real board and check every move itself.

Third, keeping time honestly. Each player has a clock that ticks down only on their turn. If the clock lived on the client, a cheater could simply slow it down. The server must be the single source of truth for how much time each side has left, decrement it correctly as moves arrive, and decide fairly who flagged (ran out of time) first — even when the network adds delay.

It is labeled "Medium" because the data volume is small (a chess game is tiny) but the correctness and real-time properties are strict: server-authoritative state, an honest clock, and a connection that survives a player's wifi dropping mid-game.

In this walkthrough you'll scope the platform, size it, model the data, define the client contract (the move channel is a WebSocket protocol, not REST), draw the matchmaking and in-game flows, and go deep on rating-based matchmaking, server-authoritative moves and clocks, and reconnection.

Functional requirements

  • Find a game (matchmaking). A player asks for a game at a time control (e.g. 5 minutes each) and is paired with a waiting opponent of similar rating.
  • Play moves in real time. Each player makes legal moves in turn; every move reaches the opponent within a fraction of a second.
  • Server-authoritative clocks. Each side has a clock that counts down only on their turn; running out of time loses the game (flag-fall).
  • Game lifecycle and result. A game moves through clear states: matchmaking → in progress → finished, ending in checkmate, resignation, draw, or flag-fall (timeout). The result updates both players' ratings.
  • Reconnect mid-game. If a player's connection drops, the game keeps running on the server; the player can rejoin and see the current board, clocks, and any moves they missed.

Out of scope (state it, so you don't burn interview time): the chess engine / AI opponent (we validate human moves but don't build a bot), anti-cheat ML (detecting players secretly using an engine — its own large problem), and spectating/streaming at scale (broadcasting one game to thousands of watchers; we mention it as an extension). Move exchange, matchmaking, the clock, and reconnection are the whole problem.

Non-functional requirements

  • Low-latency move delivery. A move must reach the opponent in well under a second — target < 200 ms p99. This drives the persistent-connection design.
  • Server-authoritative correctness. The server holds the real board and both clocks. It validates every move and never trusts the client's claim about legality, board state, or time. This is the headline property — it is what makes the game cheat-resistant and fair.
  • Fast matchmaking. A player should be paired in seconds, with an opponent close in rating. The quality of the pairing and the speed of finding one trade off against each other.
  • Durability of in-progress games. A live game's state (moves so far, clocks, whose turn) must survive a server crash so players can rejoin, not lose a game that took 20 minutes to play.
  • High availability. Players should keep finding and playing games even when individual servers fail.

Tip

Lead with the property that makes this problem distinctive: server-authoritative state. The board and both clocks live on the server; the client is just a renderer and an input device. Saying this upfront frames every later decision (move validation, clock, reconnection).

Estimations

State assumptions; the goal is to justify the in-memory game state, the persistent-connection layer, and a small durable store — not to be exact.

Rounded assumptions

  • Concurrent live games at peak: ~1M (two players each → ~2M connected players).
  • A game lasts a few minutes; players make a move every few seconds when it's their turn.
  • Moves/sec across the platform: ~1M games × roughly one move every ~5 seconds (summed over both sides) ≈ ~200K moves/sec. Each move is tiny.
  • New games started/sec (matchmaking rate): games are short, so the pairing rate is high — on the order of ~5K–10K new pairings/sec at peak.
  • Finished games stored/day: tens of millions — small records, append-mostly.

How the numbers affect the design

Signal from the numbers Design decision
~2M players connected at once, server pushes moves A layer of connection servers holding long-lived WebSockets, not request/response web servers.
~200K moves/sec, each tiny, must be validated and timed Keep each live game's authoritative state in memory on the server that owns it; validation and clock math are cheap CPU, not a DB hit per move.
A game is small (a few hundred bytes of moves) No need for a giant store; in-memory state + a modest durable record per game is plenty.
In-progress games must survive a crash Persist game state (and stream moves to a durable log) so a crashed game can be recovered and rejoined.
High pairing rate, must match by closest rating fast An in-memory matchmaking pool indexed by rating, not a database scan over waiting players.
Players' wifi drops constantly A routing registry that knows which server owns each live game, so a reconnecting player resyncs from authoritative state.

Storage

  • A finished game record: two player ids (16 bytes), result (1 byte), time control (a few bytes), timestamps, and the move list. A full game is ~40–80 moves; stored compactly (e.g. as algebraic notation or a few bytes per move) the moves are well under ~1 KB. Round to ~1 KB per game with metadata.
  • Tens of millions of games/day × ~1 KB ≈ ~tens of GB/day — small and append-only. This is comfortably a partitioned relational or wide-column store; storage size is not the challenge here.

Note

Unlike a chat app or ride-sharing service, the hard part is not data volume — a chess game is tiny. The hard part is correctness in real time: an honest clock, validated moves, and surviving disconnects. Don't over-index on storage in this interview; spend your time on the server-authoritative game loop.

Core entities / data model

Entity Key fields
Player player_id (PK), name, rating (Elo, e.g. 1500), games_played
Game game_id (PK), white_id, black_id, time_control (e.g. 300+0 = 5 min, no increment), status (matchmaking/in_progress/finished), result, started_at, ended_at
GameState (live) authoritative board position, move list, to_move (white/black), white_ms_left, black_ms_left, last_move_at — the in-memory truth for an active game
Matchmaking pool entry player_id, rating, time_control, enqueued_at — a waiting player looking for a game

Two very different homes for state:

  • The live GameState is the hot path: the authoritative board, the move history, whose turn it is, and both clocks, held in memory on the connection/game server that owns the game. Every move reads and updates this. It is also streamed to a durable log so it can be recovered (deep dive 3).
  • The durable Game and Player records live in a strongly consistent store (Postgres/MySQL is fine at this scale). The Game row is written when the game starts and finalized when it ends (result + rating change); the Player.rating is updated on game end. This is the accountable record — ratings and game history must not be lost.

Important

The clocks (white_ms_left, black_ms_left) live in the server's GameState, never on the client. The client may display a countdown for smoothness, but the numbers it shows are only a copy of what the server believes — and the server's numbers win. This single decision is what makes clock cheating impossible (deep dive 2).

Client contract

An online chess game is not a normal REST API. Finding a game and reading game history are ordinary request/response calls, but playing is a back-and-forth where the server must push the opponent's move and the ticking clock to you the instant they happen — the client can't know when to ask. So the move exchange is a persistent WebSocket protocol, which we describe alongside the few REST calls. We label the request payload Body: and the response Returns:.

Find a game (enter matchmaking)

POST /api/matchmaking
Body:    { "player_id": "...", "time_control": "300+0" }
200 OK
Returns: { "ticket_id": "...", "status": "searching" }

The player asks to be paired. The server adds them to the matchmaking pool for that time control and returns a ticket. The actual pairing arrives moments later over the WebSocket (a matched event) — this call does not block until an opponent is found.

Cancel matchmaking

DELETE /api/matchmaking/{ticket_id}
200 OK
Returns: { "status": "cancelled" }

Remove the player from the pool if they give up waiting. Cancellation must be safe against the race where a matchmaker is pairing this exact player at the same instant (deep dive 1).

Real-time game channel (persistent connection)

Client opens a WebSocket:  wss://chess.example.com/game
  → authenticates (token), then keeps the connection OPEN

Client → server events:
  { "type": "join",   "game_id": "..." }                       (attach / re-attach to a game)
  { "type": "move",   "game_id": "...", "move": "e2e4", "client_move_id": "<uuid>" }
  { "type": "resign", "game_id": "..." }
  { "type": "draw_offer" / "draw_accept", "game_id": "..." }
  { "type": "ping" }                                           (heartbeat)

Server → client events (pushed, no request):
  { "type": "matched",   "game_id": "...", "color": "white", "opponent": {...}, "time_control": "300+0" }
  { "type": "move",      "game_id": "...", "move": "e2e4", "by": "white",
                         "white_ms_left": 297400, "black_ms_left": 300000, "to_move": "black" }
  { "type": "rejected",  "client_move_id": "<uuid>", "reason": "illegal_move" }
  { "type": "state",     "game_id": "...", "fen": "...", "moves": [...],
                         "white_ms_left": ..., "black_ms_left": ..., "to_move": "..." }   (full resync)
  { "type": "game_over", "game_id": "...", "result": "white_wins", "reason": "flag" }

The client opens one WebSocket and keeps it open. The defining difference from REST: the server pushes the move, matched, and game_over events whenever they happen, with no matching request — that is how real-time play works. Two details matter:

  • Every server move event carries the authoritative clocks (white_ms_left, black_ms_left) and whose turn it is. The client renders those numbers; it does not compute the source of truth itself.
  • A client move carries a client_move_id (an idempotency key). On a recognized duplicate (the client resending after a dropped ack), the server replays the prior confirmation — the same already-charged clocks — rather than re-validating the move or charging the clock again. If the move is illegal or out of turn, the server replies rejected and does not change the board.

Get game (history / resync fallback)

GET /api/games/{game_id}
200 OK
Returns: { "game_id": "...", "moves": [...], "result": "...", "white": {...}, "black": {...} }

A pull endpoint for a finished game's record, or a fallback to read current state if the WebSocket is reconnecting. The live resync normally comes over the channel as a state event (deep dive 3).

High-level architecture

Two flows matter and they have different shapes: a matchmaking flow (pair two waiting players by rating) and an in-game move flow (validate, clock, broadcast). We'll walk each, then combine them. The shared cast of components:

  • Connection servers (gateway layer). Each holds many long-lived WebSockets and shuttles events to and from clients. A live game is owned by one connection/game server, which holds that game's authoritative in-memory GameState.
  • Matchmaker. Worker(s) that watch the matchmaking pool and pair compatible players. The pool is an in-memory structure indexed by rating, per time control.
  • Game registry (routing). A fast lookup (e.g. Redis) mapping game_id → the server that owns the live game. This is how a reconnecting player, or a message about a game, reaches the right server.
  • Durable stores. The Game/Player store (results, ratings, history) and a move/state log for crash recovery.

1. Matchmaking flow

A player asks for a game and is parked in a rating-indexed pool; a matchmaker pairs two close-rated players and starts a game.

flowchart LR
    P1["Player (rating 1500)"] -->|"POST /matchmaking (300+0)"| MM[Matchmaker]
    MM -->|"add to pool, bucket by rating"| Pool[("Matchmaking Pool (in-memory, by rating)")]
    MM -->|"find closest-rated waiting player"| Pool
    MM -->|"atomically claim BOTH players"| Pool
    MM -->|"create game = in_progress"| GameDB[("Game store (durable)")]
    MM -->|"register owner server"| Reg[("Game registry (game_id to server)")]
    MM -->|"push 'matched' to both"| GW[Connection Servers]
    GW -->|"WebSocket: matched + color"| P1
  1. A player asks for a game at a time control; the matchmaker adds them to the pool, placed by rating (e.g. into a rating bucket).
  2. The matchmaker looks for the closest-rated waiting player within an acceptable rating window (deep dive 1).
  3. When it finds a pair, it must atomically remove both players from the pool so no other matchmaker can grab either one — this is the critical race (deep dive 1).
  4. It creates a Game row in in_progress, assigns colors, initializes both clocks from the time control, and records which connection server will own the live game in the game registry.
  5. It pushes a matched event to both players over their WebSockets, telling each their color and the game id. Both clients now join the game.

2. In-game move flow

A player sends a move; the server validates it against the authoritative board, updates the clock, persists, and broadcasts to the opponent.

flowchart LR
    Mover["Mover's client"] -->|"move e2e4 (WebSocket)"| Owner["Game server (owns GameState)"]
    Owner -->|"1. is it your turn? is move legal?"| Owner
    Owner -->|"2. stop mover's clock, charge elapsed time"| Owner
    Owner -->|"3. apply move, switch turn"| Owner
    Owner -->|"4. append move to durable log"| Log[("Move / state log")]
    Owner -->|"5a. push 'move' + both clocks"| Opp["Opponent's client"]
    Owner -->|"5b. push 'move' confirmation + clocks"| Mover
  1. The mover sends move e2e4 over the WebSocket to the game server that owns this game's GameState.
  2. The server checks whose turn it is (reject if it's not the mover's turn) and whether the move is legal from the current authoritative position (reject illegal moves — never trust the client).
  3. It computes how much time the mover used since their clock started, subtracts it from the mover's clock, and checks for flag-fall (deep dive 2).
  4. It applies the move to the authoritative board, switches to_move to the opponent, and starts the opponent's clock.
  5. It durably appends an entry to the game's log — the move and the time charged for it (or, equivalently, both post-move clock values and whose turn it now is) — so the game survives a crash, and waits for that append to be acknowledged durable, then pushes the move plus both authoritative clock values to the opponent — and a confirmation to the mover. Logging the move alone would not be enough: the clocks must be reconstructable from the log too (deep dive 3), so each entry carries the time information, not just e2e4.

This ordering is a hard rule, and it is write-ahead logging: the move must be persisted to the log before it is broadcast or acked, never after. If the server broadcast first and crashed before the durable append, the recovered game would be missing a move both players already saw — and a clock charge both saw — so the observed state would diverge from the authoritative state. Broadcasting only after the append is acknowledged durable closes that gap.

Note this durable append sits in the move hot path — it is the one unavoidable persistence step per move, so it's not free the way in-memory validation and clock math are. Keeping it inside the sub-200 ms p99 budget is why the log is an append-only sequential log (fast sequential writes, no random-access index update) with group commit — many concurrent games' appends are batched into one fsync — rather than a per-move round trip to the primary Game store. That's the deliberate tradeoff: a small, bounded durability cost on every move buys crash-survivable games.

Caution

Never let the client tell you the move is legal, or what the board looks like, or how much time is left. A modified client will lie. The server re-derives legality from its own board and owns both clocks. "Server-authoritative" is the single most important phrase in this interview.

3. Combined architecture

Putting the two flows together:

flowchart LR
    P["Player client"] -->|"find game"| MM[Matchmaker]
    MM <-->|"bucket / claim by rating"| Pool[("Matchmaking Pool (in-memory)")]
    MM -->|"create game + ratings"| GameDB[("Game / Player store (durable)")]
    MM -->|"register owner"| Reg[("Game registry (game_id to server)")]
    MM -->|"matched"| GW[Connection / Game Servers]
    P <-->|"WebSocket: join + moves"| GW
    GW -->|"validate + clock (authoritative GameState in memory)"| GW
    GW -->|"append moves"| Log[("Move / state log (durable)")]
    GW -->|"on finish: result + rating update"| GameDB
    Reg -->|"reconnect: which server owns this game?"| GW

The shape to notice: the matchmaker + pool turn waiting players into games and hand each game to an owning game server, which holds that game's authoritative state in memory and runs the move/clock loop. The game registry is the glue that lets a dropped player find their game's owner again. The durable store holds the comparatively rare but important writes — game created, moves logged, result and rating finalized.

Note

The matchmaking pool is in-memory and disposable: if it's lost, waiting players simply re-queue. The live GameState is more precious — a game in progress represents real player time — so it's streamed to a durable log and can be recovered onto another server (deep dive 3).

Deep dives

1. Matchmaking by rating

The job: when a player asks for a game, pair them with a waiting opponent of similar rating, quickly. Elo ratings typically run from a few hundred to ~2800+ (top players higher), and a good match is two players within a small rating gap (e.g. ±50). But if you insist on a perfect match, a 1500-rated player might wait forever for another exact 1500. So you start strict and widen the acceptable rating window as the player waits.

The widening window. Each waiting player has an acceptable rating range that grows over time: maybe ±50 at first, ±100 after 5 seconds, ±200 after 15 seconds, and so on. A pairing is valid when each player falls inside the other's current window. This trades match quality for wait time gracefully — a fresh player only matches a near-equal, but a player who's been waiting a while accepts a wider gap rather than waiting forever.

Structuring the pool. How you store the waiting players decides how fast "find someone near rating R" is. Start from the obvious approach and see where it breaks, then fix it:

Scan the whole waiting list each time (avoid)

Keep waiting players in a plain list and, for each new player, loop over everyone to find the closest acceptable opponent.

 1000 waiting players → for each new arrival, compare against all 1000
 At ~10K pairings/sec this is ~10M comparisons/sec of wasted work.
  • Pro: trivial to implement; fine for a tiny hobby site with a handful of players online.
  • Con: O(n) per attempt. At a high pairing rate with a large pool this becomes a CPU bottleneck and adds latency to every match.
  • Verdict: avoid at scale — it works only when the pool is tiny. Index by rating instead.

The fix is to index the pool by rating so you never touch a player outside the target band. Keep the waiting players in rating order, and a match becomes a short range read instead of a full sweep:

Rating buckets + ordered scan within the band (recommended)

Keep, per time control, a structure that lets you read waiting players in rating order — e.g. a sorted set keyed by rating (Redis sorted sets do exactly this), or rating buckets (1450–1499, 1500–1549, …) each holding a queue. To match a player at rating R with a current window of ±W, read only the waiting players in [R−W, R+W] and pick the closest, oldest-waiting one.

 Pool (5+0), sorted by rating:
   ... 1420(d) 1480(a) 1495(b) 1510(c) 1560(e) ...
 New player at 1500, window ±50 → scan [1450, 1550]:
   candidates: 1480(a), 1495(b), 1510(c)
   pick closest → 1495(b)  (gap 5)  → pair (1500, b)
  • Pro: finding the closest opponent is a short range read, not a full scan — O(log n) to locate the band plus a small constant to pick within it. Scales to a large pool.
  • Con: you must keep the structure ordered as players enter and leave, and widen each player's window over time (a periodic re-scan or a priority queue keyed by "next widen time").
  • Verdict: recommended — a rating-ordered index is the standard way to make "closest opponent within a window" fast.

The double-pairing race (the critical correctness bug). With multiple matchmaker workers (you need more than one for throughput), two workers can both look at the pool, both see player b, and both try to pair b — once with a and once with c. Now b is in two games. This is a classic race condition.

The fix is to make claiming a player an atomic remove from the pool: a matchmaker may only pair two players if it can atomically remove both from the pool, and whoever removes a player first owns them. Anyone who tries to remove an already-removed player loses and must pick a different candidate.

Atomic claim — remove both players from the pool as one indivisible step (recommended)

Treat "take these two players out of the pool" as a single atomic operation that fails if either player is already gone. With Redis this is a small script (run atomically) that checks both players are still present and removes them together; with a database it's a conditional delete in one transaction (DELETE ... WHERE both still waiting, succeeds for exactly one caller). The DB variant must then assert it removed exactly 2 rows (affected-rows = 2) — if it removed only 1, one player was already taken, so it rolls back rather than silently proceeding with half a pair. The matchmaker only creates the game if the atomic claim succeeded.

 Workers M1 and M2 both eye player b:
   M1: atomically remove {a, b} from pool  → success (a and b were present)  → create game(a,b)
   M2: atomically remove {b, c} from pool  → FAILS (b already gone)          → b is taken,
                                                                                retry with c + someone else
 Result: b is in exactly one game; c goes back to matching.
  • Pro: correctness guaranteed by a single indivisible step — exactly one worker can claim a given player. No locks held across the slow game-creation work.
  • Con: the loser does a little wasted work and must retry; under heavy contention for a thin rating band this retry can repeat, but that's rare because pairings spread across ratings.
  • Verdict: recommended — atomic claim is the clean fix for the double-pairing race. State the race explicitly and name the atomic removal as the guard.

Warning

The double-pairing race is the bug interviewers probe here. If your matchmaker "finds a pair then creates the game" without an atomic claim, two workers can put the same player in two games. Always pair by atomically removing both players from the pool, and only then create the game.

2. Server-authoritative real-time moves and clock

This is the heart of the system. The server holds the authoritative GameState — the board, the move history, whose turn it is, and both clocks — for each live game, in memory on the owning server. Clients send moves; the server is the referee and the timekeeper.

Move validation: never trust the client. When a move arrives, the server:

  1. Checks the move came from the player whose turn it is (reject out-of-turn moves).
  2. Re-derives whether the move is legal from its own board position — the piece can move that way, it doesn't leave the mover's king in check, castling/en-passant rules hold, and so on. It does not ask the client whether the move was legal.
  3. Applies the move to the authoritative board only if legal; otherwise replies rejected and leaves the board unchanged.

Because the server re-derives legality from its own state, a hacked client that sends "queen to h8 (illegal)" simply gets a rejected and changes nothing. The client is a renderer; the server is the rules engine.

Caution

A client-reported board or "this move is legal, trust me" is the cheating door. Validate every move against server-side state. The only thing the client supplies is the intended move (e.g. e2e4); legality, turn order, and resulting position are the server's to compute.

Clock management: the server is the timekeeper. Each player has a remaining-time counter in milliseconds. The rule is simple and must be enforced server-side:

  • A clock runs only on that player's turn. When the server applies a move and switches the turn, it stops the mover's clock and starts the opponent's.
  • The server measures elapsed time using its own monotonic clock at the moment the move is received, not any timestamp the client sends. It subtracts the elapsed time from the mover's remaining time.
  • The side-to-move's running clock is not decremented by a ticking loop; it is computed on demand whenever someone reads it, as stored_ms_left − (now_monotonic − turn_start_monotonic). The server stores only the remaining time at the start of the turn plus a monotonic anchor for when the turn began, and derives the live value from those two numbers.
 White to move. Server recorded white's clock started at server-time T0
 with white_ms_left = 120000 (2:00).
 White's move arrives; server stamps receipt at T1 (its own clock).
   elapsed = T1 − T0
   white_ms_left -= elapsed           (e.g. 120000 − 3200 = 116800)
   if white_ms_left <= 0  → flag-fall: white loses on time
   else apply move, switch turn, start black's clock at receipt time.

Why this must be server-side: if the client owned the clock, a cheater would simply make their clock tick slower (or never expire). With the server measuring elapsed time from its own clock on each received move, the player's only way to "save time" is to actually move faster.

Warning

Do not let the client report how much time it used. A client that says "I only used 0.1s" on every move would have an infinite clock. The server times each move by when it receives it, on the server's own clock. The client's countdown is display-only.

Network latency and fairness. Network delay sits between the mover and the server, so "when the move was received" is slightly after "when the player physically moved." Two fair-handling choices, in increasing sophistication:

  • Simple and standard: charge time at server receipt. The mover's clock is charged for the round trip up to receipt. This very slightly penalizes a laggy player, but it's simple, symmetric in spirit, and impossible to game (the client can't claim an earlier move time). Most platforms do exactly this.
  • Lag compensation (optional): the server can credit back a small, bounded amount of measured network delay (e.g. estimated from heartbeats), so a player on a slow connection isn't unfairly drained. The credit is capped and computed server-side from observed round-trip times — never from a client-supplied timestamp — so it can't be abused. Mention this as a refinement; the simple version is a fine default.

The flag-fall race. Flag-fall (running out of time) has a subtle race: a player might be about to move at the exact moment their clock hits zero, while the opponent's client simultaneously claims a win on time. Who wins? The server resolves it, because the server owns both clocks and processes events in a single order per game:

  • The server's own timer is the authoritative path. When a player's remaining time reaches zero with no move received, the server itself fires a flag-fall event into that game's ordered event stream — it does not wait for anyone to ask. This is the primary mechanism that ends a game on time.
  • The server checks the mover's remaining time when it receives their move. If remaining − elapsed <= 0, the move is too late — the player flagged, and the game ends as a loss on time even though a move arrived.
  • An opponent's "claim on time" is only a non-authoritative hint or poll — a prompt to evaluate the clock now — not a trusted input competing with the server's own view. It is checked against the same authoritative clock and the same ordered event stream; it can never make the server end a game that its own clock says is still running.
  • A common refinement (used by Lichess and others): when a player flags (runs out of time), the game is a draw rather than a loss if the opponent — the side that still has time on the clock — has insufficient material to deliver checkmate (e.g. a lone king). In other words, the flagging player is only saved from losing when the side that would otherwise win by time couldn't have mated anyway. That's a chess rule the authoritative server applies; the point for the interview is simply that the server, with its single ordered view of the clock, decides — not either client.

Important

Per-game event serialization is what makes the clock and flag-fall correct. One server owns a game's GameState and processes that game's moves and timeouts one at a time, in order. There is no concurrent mutation of a single game's board or clocks, so "did the move beat the flag?" has one authoritative answer.

Draws. The server is also the referee for draws. It validates draw consent — a draw_accept is honored only if it matches a still-open offer from the opponent (not a stale offer that's been withdrawn, nor the player accepting their own offer) — and it can detect rule-based draws (threefold repetition, the 50-move rule, insufficient material) directly from its authoritative board, declaring the draw without either player asking.

3. Connection state and reconnection

Players' connections drop constantly — phone sleeps, wifi blips, laptop lid closes. The game must not end just because a socket died; the clock keeps running server-side (you don't get free time by disconnecting), and the player must be able to rejoin and see the true current state.

Which server owns a live game. Each live game is owned by exactly one game server, which holds its authoritative in-memory GameState. The game registry maps game_id → owning server. This is essentially sticky session routing at the granularity of a game: all of a game's traffic — both players' moves, reconnects, timeouts — must reach the one server that owns its state.

When a player's WebSocket drops:

  1. The game server notices the socket is dead (a heartbeat ping stops arriving). It marks that player as disconnected but keeps the game alive — the GameState stays in memory, and crucially, if it's the disconnected player's turn, their clock keeps counting down. Disconnecting must never pause your own clock, or players would rage-quit their wifi to escape a lost position.
  2. Meanwhile the opponent keeps playing/waiting against the authoritative state. (Platforms often show "opponent disconnected, will time out in N seconds" — that timeout is just the normal clock running out, or a separate abandonment timer.)

When the player reconnects:

  1. The client reopens a WebSocket and sends join game_id. It may land on a different connection server than before.
  2. That server consults the game registry to find which server owns the live game and routes the join there (or the registry tells the client/owner directly). The owning server still holds the authoritative GameState.
  3. The owning server sends a full state resync: the current board position (e.g. as FEN), the move list, whose turn it is, and both authoritative clock values as of now. If it was the disconnected player's turn, their current remaining time is computed on read with the same stored_ms_left − (now_monotonic − turn_start_monotonic) formula — so the resync reflects the time that elapsed while they were gone, not a frozen pre-drop value. The client rebuilds its view from this; it does not trust any local state it had before the drop.
 Mid-game, black's wifi drops on move 20. Server keeps GameState:
   black is to move, black_ms_left ticking down from 95000.
 5 seconds later black reconnects → join game_id
   registry: game_id → server-7 (still owns it)
   server-7 → push state: { fen: "...", moves: [...20 moves...],
                            to_move: "black", white_ms_left: 110000,
                            black_ms_left: 90000 }   ← 5s was charged to black
 Black resumes from the true position with the true (reduced) clock.

Store-and-forward of missed moves. During a brief disconnect the opponent may have moved. Two compatible mechanisms cover the gap:

  • Full resync on rejoin (above) is always correct — it replaces the client's view wholesale, so any moves missed during the drop are included. This is the backstop and is enough on its own.
  • Store-and-forward for brief blips: the owning server can buffer the few events a momentarily-disconnected player missed and replay them on reconnect, or the client can request "everything after move N" (it knows its last move number). For chess the state is tiny, so most implementations just send the full state and skip incremental replay — but naming store-and-forward shows you'd handle a longer outage without re-sending nothing.

Surviving a server crash (not just a client drop). If the owning server crashes, its in-memory GameState is gone. This is why each move is durably appended to a durable move/state log as it's applied (the in-game flow, step 5). On crash, another server can be assigned the game, replay the move log to reconstruct the board, and recover the clocks (the log records the per-move time charges for completed turns, so each side's stored remaining time is rebuildable). The in-flight turn is the exception: the log holds no charge for it, so the recovered server resets the running clock's start anchor to recovery time — the pre-crash elapsed of the in-progress turn is forgiven (a small grace the player gets for the server's failure, not theirs). The registry is updated to point at the new owner, and players reconnect and resync as above. A game in progress is real player effort — it shouldn't vanish because one machine died.

Warning

Don't pause a disconnected player's clock or let a reconnecting client push its own stale board into the game. The clock runs server-side regardless of connection state, and reconnection is a server → client resync from authoritative state, never the client telling the server where the game stood.

Tradeoffs & bottlenecks

  • In-memory game state vs durability. Holding each game's authoritative state in memory makes move validation and clock math fast (no query to the Game store per move), but a crash loses in-memory state — which is why every move is also appended to a durable log so a game can be replayed onto another server. That durable append does sit in the move hot path, so it isn't truly free; it's kept within the latency budget by using an append-only sequential log with group commit rather than a random-access DB write. You accept a small, bounded write cost per move to make games crash-survivable.
  • Match quality vs wait time. A tight rating window gives a fairer game but longer waits; a widening window trades quality for speed as the player waits. There's no single right setting — it's a product tuning knob per rating band and time control.
  • Atomic claim contention. Atomically removing both players from the pool guarantees no double-pairing, but a thin rating band with many matchmakers retrying for the same few players does wasted work. In practice pairings spread across ratings, so contention is low.
  • Charge-at-receipt vs lag compensation. Charging the mover's clock at server receipt is simple and un-gameable but slightly penalizes laggy players; bounded lag compensation is fairer but more complex and must be computed server-side to stay cheat-proof.
  • Sticky game ownership. Pinning a game to one server keeps its state in one place (simple, correct serialization), but that server is a hotspot for that game and a single point of failure — mitigated by the durable log and registry-driven recovery.
  • Connection layer scale. Holding ~2M live WebSockets means stateful connection servers that must drain and hand off cleanly, and a registry that stays fresh as games move servers. This is the price of sub-second push.

Extensions if asked

If the interviewer wants to push beyond the core matchmaking/move/clock/reconnect loop, add only the extension that changes the design discussion. (These are standalone topics; there's no dedicated guide for each yet.)

  • Spectating / broadcasting at scale. One popular game watched by thousands of viewers is a fan-out problem: don't have each viewer send moves, broadcast the game's moves to a viewer-only pub/sub channel (read-only, slightly delayed), separate from the two players' authoritative channel.
  • Rating update math (Elo/Glicko). On game end, update both players' ratings from the result and the rating gap (Elo, or the more modern Glicko which also tracks rating uncertainty). This is a write-light step at game finalization, kept out of the move hot path.
  • Anti-cheat (engine detection). Detecting a player secretly consulting a chess engine is a separate ML/analytics problem over move quality and timing — large enough to be its own system, deliberately out of scope here.
  • Reconnection grace and abandonment. Beyond the clock, an abandonment timer can award the win if a disconnected player never returns within a window, separate from flag-fall.
  • Tournaments and pairing systems. Swiss/arena pairing over many rounds is a different matchmaking model than the open ladder pool, with its own scheduling.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Server-authoritative state — the board and both clocks live on the server, which validates every move and never trusts the client's claim about legality, position, or time.
  • An honest, server-side clock — charged at server receipt on the server's own clock, running only on the mover's turn, with the flag-fall race resolved by one server's ordered view.
  • Rating-based matchmaking with a widening window, a rating-indexed pool (not a full scan), and an atomic claim that prevents two matchmakers pairing the same player twice.
  • A persistent connection (WebSocket) for pushing moves and clocks, not polling or plain REST.
  • Reconnection that resyncs from authoritative state — clock keeps running on disconnect, the game registry routes the rejoin to the owning server, and the client rebuilds from a server-pushed full state.
  • Crash survival — moves appended to a durable log so an in-progress game can be replayed onto a new server.

Before you finish, do a quick mistake check:

  • Did you make the server validate every move and own the board, rather than trusting the client?
  • Did you put both clocks on the server, charged by server receipt time, with display-only clocks on the client?
  • Did you resolve the flag-fall race with a single server's ordered processing of a game's events?
  • Did you index the matchmaking pool by rating and widen the window over time, not scan all waiting players?
  • Did you prevent the double-pairing race with an atomic removal of both players from the pool?
  • Did you keep a disconnected player's clock running and resync on reconnect from authoritative state?
  • Did you persist moves to a durable log so a server crash doesn't lose an in-progress game?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →