OpenAI Interview: Design an Online Chess Platform
A full solution to the OpenAI chess question: rating-based matchmaking with atomic pairing, stateful game servers routed by game, the move pipeline, a server-owned clock with timeout detection, lag compensation, persistence, reconnects, and failover.
By The Forward Deployed editorial teamReviewed
Part of the OpenAI system design question bank. The question is representative of the round. The analysis and solution are this site's own.
Problem statement
A player picks a time control, such as 3 minutes plus 2 seconds per move, and rated or casual. The system pairs them with an opponent of similar rating within seconds. During the game, moves travel to the opponent within about 150 ms, and each player's clock counts down only on their turn. Tournaments, bots, and anti-cheat analysis are out of scope unless the interviewer adds them.
Clarifying questions
- Scale? For practice: 2 million players a day, 100,000 games in progress at peak.
- Time controls? From 1-minute bullet to 30-minute games, with optional increments per move.
- Latency targets? Pairing within 5 seconds at p95; move delivery within 150 ms at p95.
- Clock accuracy? Within about 100 ms. In bullet chess, that decides games.
- What happens on disconnect? The disconnected player's clock keeps running, the normal chess rule, with an option to claim a win after a timeout.
- Game history? Every finished game is stored and viewable.
What makes online chess hard
Chess logic is easy; fairness over a network is hard.
The clock is the problem. Each player's screen shows a timer, but the network delays every message by tens to hundreds of milliseconds, differently for each player. If the client decides when time runs out, a cheater stops their own clock. If the server decides naively, the player with the slower connection loses time they never used. In a one-minute game, a few hundred milliseconds per move decide the result.
Games are also stateful and long-lived. Every move must apply to the same authoritative board in order, exactly once, even when a player's connection drops and their client resends.
So the driving tension is real-time responsiveness versus authoritative fairness. Moves must feel instant, and the server must be the single judge of the board and the clock.
flowchart LR A([White]):::user <-->|WebSocket| GW[Gateway]:::svc B([Black]):::user <-->|WebSocket| GW GW -->|route by game_id| GS[Game server<br/>board + clocks in memory]:::store MM[Matchmaking]:::svc -->|create game| GS GS --> LOG[(Move log)]:::store classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
Key idea. One owner per game. The server holds the board and the clocks; clients only display them.
Key concepts
Time controls
A time control gives each player a budget, such as 180 seconds, and an optional increment added after each move, such as 2 seconds. A player whose budget reaches zero loses on time, unless the opponent cannot possibly checkmate, which is a draw.
Server-authoritative state
The server's copy of the game is the truth. Clients send intentions ("I move e2 to e4"); the server validates and decides. Clients never tell the server what the clock says.
Single-owner routing
Every message for a game goes to the one server that owns it. That server processes the game's events one at a time, so there are no concurrent updates to the same board.
Idempotent moves
Each move carries its move number. The server applies move N only once, and only if it is the next expected move. A resent move becomes harmless.
Lag compensation
Measuring each client's network delay and giving back a bounded part of it, so a slow connection does not cost the player clock time for moves they made quickly.
Key idea. Server-owned state, one owner per game, move numbers for idempotency, and bounded lag compensation for fairness.
- Requirements
Before reading on. List the requirements, then name the property you would never trade away.
1.1 Functional requirements
- Join a queue by time control and rated or casual; get paired by rating.
- Play moves in real time; validate legality.
- Run both clocks with increments; end games on time, checkmate, stalemate, resignation, draw agreement, or repetition rules.
- Offer and accept draws; resign.
- Reconnect to a game in progress.
- Update ratings after rated games; store and show finished games.
1.2 Non-functional requirements
- Pairing within 5 seconds at p95.
- Move delivery within 150 ms at p95.
- Clock accuracy within about 100 ms.
- No lost moves. A move the server accepted survives any single failure.
- Availability of 99.95% for games in progress.
1.3 The constraint versus the property
Fairness is the property. A game decided by a clock bug or a lost move is a broken product. Latency is the constraint. Everything on the move path must fit within about 150 ms, which rules out slow storage and multi-hop coordination on that path.
- Back-of-the-envelope estimation
| Quantity | Value | Derivation |
|---|---|---|
| Players per day | 2 million | given |
| Games in progress at peak | 100,000 | given |
| Players connected at peak | 200,000 | 2 per game |
| Average time between moves per game | 10 seconds | both players, mixed controls |
| Moves per second at peak | 10,000 | 100,000 / 10 |
| Bytes per move message | about 100 | move, clocks, move number |
| Move bandwidth | about 2 MB/s | 10,000 × 100 × 2 recipients |
| Games per day | about 3 million | 2 M players × 3 games / 2 |
| Stored moves per day | about 240 million | 3 M × 80 half-moves |
The load is many long-lived connections and many small, ordered updates to independent games. Games never interact, so they partition cleanly. One game server handling 2,000 games is about 200 moves per second, easy for one process; about 50 game servers cover the peak with room to spare.
Key idea. 10,000 small moves per second across 100,000 independent games. Independence is what makes the system scale.
- API design
Before reading on. A player's connection drops just after sending a move. On reconnect, the client resends it. What does the server do?
It checks the move number. If the move was already applied, it returns the current game state. If it was not, it applies it. Either way the move happens once.
3.1 Matchmaking
POST /v1/queue {time_control: "3+2", rated: true} -> {ticket_id}
DELETE /v1/queue/:ticket
WebSocket event: match_found {game_id, color, opponent, game_endpoint}3.2 Game protocol over WebSocket
client -> server
{type: "move", game_id, move_no: 23, uci: "e2e4", client_ts}
{type: "resign", game_id}
{type: "draw_offer", game_id, move_no}
{type: "draw_accept", game_id, move_no}
{type: "sync", game_id, last_move_no} // on reconnect
{type: "ping", client_ts}
server -> client
{type: "move", game_id, move_no: 23, uci, white_ms, black_ms, server_ts}
{type: "state", game_id, fen, moves[], white_ms, black_ms, turn, server_ts}
{type: "game_over", game_id, result: "1-0", reason: "time" | "mate" | ...}
{type: "pong", client_ts, server_ts}3.3 History
GET /v1/games/:id -> PGN, clocks per move, result GET /v1/players/:id/games?cursor=
- Data model
In memory on the game server (per game):
game_id, white_id, black_id, time_control, rated,
board (FEN), move_no, turn,
white_ms, black_ms, turn_started_at (server monotonic clock),
draw_offer_by?, status
Durable:
games (game_id, white_id, black_id, time_control, rated, status, result,
reason, started_at, ended_at, server_id)
moves (game_id, move_no, uci, mover_ms_left, server_ts)
primary key (game_id, move_no)
snapshots in Redis: game_id -> {fen, move_no, white_ms, black_ms, turn_started_at}
ratings (player_id, time_class, rating, deviation, games_played, updated_at)The moves table's primary key on (game_id, move_no) also enforces that each move number is stored once.
- High-level design
5.1 One server, clients own the clock
Clients count down their own time and report when they flag. It is simple, and trivially cheatable: a modified client never reports running out of time.
5.2 Fix 1: the server owns the clock and the board
The server validates every move and computes clocks from its own timestamps. Clients display what the server says.
5.3 Fix 2: many game servers, routed by game
Spread games across game servers. Matchmaking picks a server when it creates the game. A gateway routes every message for a game ID to that server, so each game has exactly one owner.
flowchart LR P1([Player]):::user --> GW[Gateway]:::svc P2([Player]):::user --> GW GW -->|game 17| G1[Game server 1]:::new GW -->|game 42| G2[Game server 2]:::new REG[(game_id -> server)]:::new -.-> GW classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;
5.4 Fix 3: matchmaking by rating
A matchmaking service holds queues per time control, sorted by rating, and pairs players atomically.
5.5 Fix 4: durable moves and snapshots
Each accepted move is appended to the durable move log before it is broadcast, and a small snapshot goes to Redis. A failed game server's games can be restored on another.
5.6 The composed design
sequenceDiagram autonumber actor W as White participant G as Gateway participant S as Game server participant L as Move log participant R as Redis snapshot actor B as Black W->>G: move 23 e2e4 G->>S: route by game_id S->>S: is move 23 next? White's turn? legal? S->>S: clock: white_ms -= now - turn_started, += increment S->>L: append (game, 23, e2e4, white_ms) S->>R: snapshot S-->>W: move 23 + both clocks S-->>B: move 23 + both clocks S->>S: turn_started = now, schedule flag check for Black
Key idea. Authoritative server, single-owner routing, atomic matchmaking, and a durable log written before broadcast.
- Deep dives
6.1 The clock
Before reading on. Write the server's clock logic for a move. Then say how the server notices a player ran out of time when no message arrives.
On each accepted move by player P:
now = monotonic_clock() elapsed = now - turn_started_at P.remaining_ms -= elapsed if P.remaining_ms <= 0: game over, P loses on time (unless insufficient material) P.remaining_ms += increment_ms turn_started_at = now turn = opponent schedule_flag_check(game_id, at = now + opponent.remaining_ms)
Use a monotonic clock, never wall time. A wall clock can jump when the server syncs its time.
A player who stops moving sends no message, so the server must wake itself. Schedule a check at the moment the opponent's time would run out, in an in-memory timer wheel on the game server. Each move cancels the previous check and schedules a new one. When a check fires and no move arrived, the player loses on time. A timer wheel handles hundreds of thousands of timers cheaply.
Never write the clock to the database every second. Its value only matters at moves and at the flag check, so it is stored with each move and in the snapshot.
The client displays a local countdown from the last server values, and corrects to the server's numbers on every message. The display is cosmetic; the server's decision is final.
What separates answers: the clock
WeakTrusts the client, or ticks the database
Lets clients report their time, or writes the clock every second.
GoodServer-owned clock with a scheduled flag check
Computes elapsed time on each move from a monotonic clock and schedules a timeout check for the player to move.
StrongFair under real networks
Adds bounded lag compensation, explains why the display is cosmetic, handles increments and insufficient-material draws, and survives server failover without charging players for downtime.
6.2 Lag compensation
Before reading on. Black has a 200 ms connection and White has a 20 ms one. Without compensation, who loses time, and how much?
The server starts Black's clock when it sends White's move. Black sees the move about 100 ms later (half the round trip), thinks, and moves; that move takes another 100 ms to arrive. Black is charged about 200 ms per move of pure network time. Over 40 moves, that is 8 seconds, a lot in a 3-minute game.
Measure each client's round-trip time with regular pings. When a move arrives, credit back a portion of the measured delay: for example, the lesser of the estimated one-way delay pair and a cap such as 100 ms per move. The cap is essential. Without it, a player could fake a slow connection to buy thinking time. Some sites also guarantee a minimum think time per move for players on very slow links. Say the policy explicitly; it is a fairness decision, not only a technical one.
6.3 Matchmaking
Before reading on. Two matchmaking workers look at the same queue and both try to pair player X. How do you stop X from being in two games?
Keep one Redis sorted set per queue (time control, rated or casual), scored by rating. A matching step for player X looks for the closest opponent within a rating window. Removing both players and creating the match must be one atomic step, such as a Lua script that checks both players are still in the set, removes them, and returns the pair. Only one worker's script can succeed for X.
The window widens with wait time: start at ±50 rating points, widen by 50 every 3 seconds, up to a cap. That keeps pairing within 5 seconds for most players, and still finds a match for players with rare ratings.
For very popular time controls, shard the queue by rating band so one Redis key does not become a hot spot, with overlap at band edges.
6.4 Moves, ordering, and idempotency
The game server processes one game's messages one at a time. For each move it checks, in order: the game is active, the move number is the next one, it is the sender's turn, and the move is legal. Then it updates the clock, appends the move to the durable log, updates the snapshot, and broadcasts. Appending before broadcasting means a player never sees a move that could vanish in a crash.
A resent move with an old move number returns the current state. A move with a number ahead of the expected one means the client is confused; the server replies with full state and the client resyncs.
Draw offers and resignations go through the same ordered stream. A draw offer records the move number it was made at, and expires when the offering player's opponent makes a move.
6.5 Reconnects
On disconnect, the game continues and the disconnected player's clock keeps running, as in over-the-board chess. On reconnect, the client sends sync with the last move number it has. The server replies with the missed moves and current clocks, or the full state if the gap is large. If the player does not return for a set time, such as 60 seconds or their remaining clock time, the opponent may claim the win.
6.6 Failover
Before reading on. A game server with 2,000 games crashes. What happens to those games and their clocks?
The gateway detects the failure through missed health checks within a few seconds. A coordinator reassigns the crashed server's games to healthy servers, updating the routing map. Each new owner loads the game's snapshot from Redis, checks it against the move log (the log wins if they disagree), and resumes.
Clocks pause during the gap. The new owner sets turn_started_at to now when it resumes, so neither player is charged for the outage. Clients reconnect, sync, and see a short "reconnecting" notice. No accepted move is lost, because moves are durable before they are broadcast.
What separates answers: failures
WeakGames die with their server
Keeps state only in memory, so a crash ends 2,000 games.
GoodDurable moves and snapshots
Writes each move before broadcast and restores games on another server.
StrongFair recovery
Also pauses clocks during failover, uses the move log as the source of truth over snapshots, and resyncs clients by move number.
6.7 After the game
Write the final result, then emit an event to a rating service, which updates both ratings asynchronously with a standard rating system. Finished games are immutable, read often for a short time and rarely after that. Cache recent games; move old ones to cheaper storage. Clock times per move go into the stored game for review.
6.8 The game server loop
Before reading on. How does one game server process thousands of games without locks?
Give each game a single-threaded owner. Two common shapes:
- An actor per game. Each game has a mailbox. Messages for the game, such as moves, draw offers, resignations, and timer firings, go into its mailbox, and one worker processes them one at a time. Games run in parallel; one game's events never run concurrently.
- Partitioned event loops. The server runs one event loop per CPU core. Games are assigned to loops by hash of the game ID. Each loop handles all events for its games in order.
Either way, the state for a game is touched by one thread, so no locks are needed. The timer wheel posts "flag check" events into the same mailbox, so a move and a timeout for the same game can never race: whichever arrives first in the mailbox wins, and the second sees the game already changed.
flowchart LR GW[Gateway]:::svc -->|move game 42| MB42[[Mailbox: game 42]]:::store TW[Timer wheel]:::svc -->|flag check game 42| MB42 MB42 --> A42[Game 42 actor:<br/>one event at a time]:::svc GW -->|move game 43| MB43[[Mailbox: game 43]]:::store --> A43[Game 43 actor]:::svc classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
6.9 A move and a timeout race
Before reading on. Black has 50 ms left. Black's move arrives at the server at almost the same moment the flag check fires. Who wins?
The server's clock decides, applied in mailbox order. If the flag check is processed first, Black has lost on time and the late move is rejected with "game over." If the move is processed first, the server computes elapsed time from its own receive timestamp; if the remaining time is still positive, the move stands and the flag check, when processed, finds the turn already changed and does nothing.
Use the receive timestamp recorded when the gateway took the message off the socket, not the time the actor processed it. Queueing inside the server should not cost a player time. With lag compensation, credit the capped allowance before deciding. State this rule clearly, because in bullet chess it decides real games.
What separates answers: concurrency
WeakLocks around a shared board
Uses locks per game and cannot explain a move racing a timeout.
GoodOne owner per game
Processes each game's events in order on one thread or actor.
StrongTimers in the same order
Posts timeouts into the game's own event order, uses the receive timestamp for clock math, and explains exactly how a move and a flag race resolve.
- Variants
7.1 Tournaments
Many games start at the same moment. Pre-assign game servers, create games in a batch, and stagger notifications so gateways are not flooded.
7.2 Spectators
Popular games may have thousands of viewers. Fan out moves through a pub/sub channel per game to a separate spectator tier, with a small delay to limit live assistance to the players.
7.3 Anti-cheat
Compare players' moves with engine choices over many games, asynchronously. Never slow the move path for it.
7.4 At ten times the players
With a million games in progress, run game servers in several regions and place each game in the region nearest the midpoint of its two players' latency. Matchmaking prefers opponents in the same region for fast time controls. The move log becomes a partitioned store keyed by game ID, and rating updates move to a queue processed by region.
- The transferable pattern
Online chess is single-owner real-time state with an authoritative server clock. The same shape powers multiplayer games, collaborative editors with a server of record, and live auctions: route every event for an entity to one owner, order and deduplicate by sequence number, persist before broadcast, and never let clients decide time.
Review: the 30-second answer
- Atomic matchmaking by rating. Sorted sets per queue, pair with one atomic script, widen the window over time.
- One owner per game. Gateway routes by game ID; events processed in order.
- Server-owned clock. Monotonic time, elapsed per move, increment, scheduled flag check, no per-second writes.
- Move numbers. Idempotent moves and resync on reconnect.
- Durable before broadcast; pause clocks on failover. Bounded lag compensation for fairness.
Quiz
+Why must the server, not the client, own the clock?
A client can be modified to stop its own clock. Only the server's measurement of elapsed time between moves is trustworthy.
+How does the server detect a timeout when the player sends nothing?
It schedules a check for the moment the player's remaining time would reach zero. If the check fires before a move arrives, the player loses on time.
+Why not write the clock to the database every second?
The clock's value only matters at moves and at the timeout check. Storing it with each move and in the snapshot is enough; per-second writes for 100,000 games would be 200,000 useless writes per second.
+Why must lag compensation have a cap?
Without a cap, a player could fake a slow connection and gain extra thinking time on every move.
+What prevents a player from being paired into two games?
Pairing removes both players from the queue in one atomic operation that first checks they are still there. Only one matching attempt can succeed for a given player.
+How do you avoid locks inside a game server?
Give each game one owner that processes its events in order, such as an actor with a mailbox. All events for that game, including timer firings, pass through that single sequence.
+Which timestamp decides a move that races the flag?
The time the server received the move from the socket, not the time it was processed. Internal queueing should not cost the player clock time.
Sources and further reading
- FIDE Laws of Chess define time controls, flag fall, and insufficient material.
- Hashed and Hierarchical Timing Wheels describes efficient timers for many scheduled events.
- The Glicko rating system describes a rating method with rating deviation, common on chess sites.
- The same connection-routing ideas appear in the one-to-one chat solution.
