Online chess is two people, one board, two clocks — and a server that has to be both the referee and the timekeeper. Lichess, the open-source chess site, publishes every rated game it hosts: 89.6 million rated standard games in September 2026 alone, about 3 million a day, before counting casual games and variants. The data is tiny — a whole game compresses to a few hundred bytes — but the rules are strict. In a 1+0 bullet game each player has 60 seconds for the whole game, so a club player in Chennai on mobile data, about 150 ms from the server, must not lose to an opponent in Berlin on fibre just because their moves travel further. Every move must be legal, applied exactly once, and timed fairly; a dropped connection must not lose a winning position; and when a world-championship game is live, tens of thousands of people watch the same board. In this problem you design that service: matchmaking, real-time play, spectating, game history and ratings.
Understand the problem
A chess server is a small, stateful, real-time system with a hard fairness contract. Most of the design follows from one question: who is the source of truth for a game in progress, and how fast can everyone else learn what it decided?
Functional requirements
- Users should be able to find an opponent of similar rating for a chosen time control within seconds, or challenge a friend with a link.
- Users should be able to play a real-time game in which the server validates every move and runs both clocks — from 1+0 bullet to 30+20 classical — with resign, draw offers and claims when the opponent disappears.
- Users should be able to watch live games, from a friend’s blitz game to a featured top game with tens of thousands of viewers.
- Users should be able to review finished games as PGN with clock times, and see their rating change after every rated game.
Non-functional requirements
- Scale. About 5 million games a day — comparable to a large site that publishes 3 million rated standard games a day — peaking at about 60k concurrent games, 120k players and 200k spectators.
- Move latency. The server processes a move in under 10 ms; the opponent sees it about one network hop later. Lichess reports its own server latency rarely exceeds 10 ms.
- Clock fairness. A player is charged for thinking time, not network time: higher lag than your opponent should not be a handicap, within bounded limits.
- Correctness. No illegal move accepted, no move applied twice, exactly one result per game, and a rating change applied exactly once per player.
- Availability. 99.95%. A server restart or deploy must not decide a game: interrupted games resume or are aborted, never lost on time.
- Durability. Finished games and rating changes are never lost; a game in progress may lose its last few seconds in a crash, by design.
In the interview
Ask: “Which time controls — is 1+0 bullet in scope?” (it sets the latency budget), “Are games rated, and which rating system?”, “How many spectators can one game have?” and “What happens if the server restarts mid-game?” The last one tells the interviewer you know where the state lives.
Out of scope
- Engine analysis. Post-game Stockfish analysis is a separate batch service fed by finished games.
- Puzzles and lessons. Different content systems with no real-time play.
- Tournaments. Arena and Swiss events reuse the same games, but scheduling and standings are a follow-up.
- Anti-cheat models. Detecting engine use is a follow-up; we keep the hooks — flags, separate pools, move-time data.
The set-up
Plan the approach
Say the shape early: “a pairing service that turns seekers into games, a game tier where each live game is owned by one actor in memory, a socket tier that holds connections, and a database written at the end of each game.” Then go where the problem is won: who owns the state, how the clock stays fair, what happens on reconnect, how pairing scales, how spectators fan out, and how results and ratings are persisted safely.
Core entities
- User.
user_id, and per speed (bullet, blitz, rapid, classical) a Glicko-2 rating, rating deviation (RD) and volatility. - Seek. A player waiting in a pool: user, time control, rating, optional rating range, waves missed.
- Game.
game_id, players and colours, time control, rated or casual, status, the move list, the clock state and an event version. - Move. Ply number (0 for White’s first move), the move in UCI form such as
g1f3, and the mover’s remaining time after it. - Rating change. Per user and game: rating, RD and volatility before and after.
Game g_7Qx2 (3+2 blitz, rated) status: started version: 41
white: u_anika 1712 ± 48 black: u_jonas 1698 ± 52
clock: white 151.3 s, black 163.9 s, running for black since 21:14:05.120
moves: e4 e5 Nf3 Nc6 Bb5 a6 … (16 plies)
API
Finding a game goes through HTTP; everything during the game goes over the WebSocket.
POST /v1/pools/3+2/seek → 202 { "seek_id": "sk_31" } (a gameStart event follows on the socket)
POST /v1/challenges → 201 { "id": "ch_9Xq2", "url": "https://chess.example.com/ch_9Xq2" }
{ "clock": "5+3", "color": "random", "rated": false }
GET /v1/games/g_7Qx2.pgn → the game with [%clk] comments
GET /v1/users/u_anika/ratings → rating, RD and history per speed
Playing. The client sends its move with the ply it believes it is making, plus two timing facts it measured itself: how long the player thought (mt, in centiseconds) and its recent network lag. The server answers every connected client with a versioned event.
{ "t": "move", "game": "g_7Qx2", "ply": 16, "uci": "g1f3", "mt": 234, "lag": 15 }
{ "t": "move", "v": 42, "ply": 16, "uci": "g1f3", "san": "Nf3",
"clock": { "white": 151.3, "black": 163.9 } }
A client that reconnects says which version it has, { "t": "resume", "v": 41 }, and gets the missed events or a full snapshot.
Back-of-envelope estimates
These numbers decide where the state can live. (Method: back-of-envelope estimation.)
Games. 5M games a day ÷ 86,400 s ≈ 58 games started per second. Evenings in Europe overlap with late evenings in India; assume a 3× peak: ≈ 175 games/s.
Concurrent games. With an average game of about 6 minutes (mostly blitz and bullet), Little’s law gives 175 games/s × 360 s = ≈ 63,000 live games at peak, so about 126,000 players.
Moves. At about 80 plies a game: 63,000 × 80 ÷ 360 s = ≈ 14,000 moves/s. Each goes to two players and, say, three spectators on average: about 70,000 messages/s.
Memory. A live game — board, move list, clocks, a short event buffer — fits comfortably in 10 KB: 63,000 × 10 KB = ≈ 630 MB. All live games fit in the memory of one machine; spread them over a few for fault isolation, not capacity.
CPU. Legal-move checking takes microseconds; even at 50 µs per move, 14,000 moves/s is 0.7 of a core. Lichess’s own figure — under 10 ms per move, including everything — is dominated by queueing, not chess.
Connections. 126,000 players + 200,000 spectators ≈ 326,000 WebSockets. At about 50,000 per socket server that is 7 servers; run 12 across zones for headroom.
Storage. A compact game record — 80 plies at about 4 bytes for the move and clock, plus metadata — is about 0.5 KB. 5M × 0.5 KB = 2.5 GB a day, ≈ 0.9 TB a year. That matches Lichess’s published export: 29.2 GB of compressed PGN for 89.6M games is about 330 bytes a game.
Ratings. 175 games/s × 2 players = 350 rating updates/s at peak — trivial.
What the numbers decide: throughput is small and the whole live state fits in memory. The hard parts are ownership (one writer per game), timing (clock fairness at 150 ms of lag) and failure (a crash or disconnect must not decide a game).
Tip
When the live state is this small, keep it in memory with a single owner and treat the database as a log of results. The interesting failure isn’t “the database is slow”, it’s “the owner died mid-game”.
High-level design
Build it one requirement at a time; by the end it plays real games for one region.
Users find an opponent
Quick pairing runs one pool per time control in a pairing service. A player who taps “3+2” joins that pool. Rather than pairing each arrival with whoever happens to be waiting, the service runs a wave: every few seconds, or as soon as enough players are waiting, it pairs everyone in the pool at once by rating. Lichess, for example, runs eleven pools from 1+0 to 30+20, with a 1+0 wave every 12 seconds or as soon as 40 players are waiting.
For each pair, the pairing service creates a game: it picks the owning game server from the game ID (a hash over the current set of game servers, or a lookup in a small directory), and that server creates the game in memory with both clocks stopped. Both players then receive a gameStart event on their socket and open the board.
A challenge link skips the pool: POST /v1/challenges stores a pending challenge for 24 hours, the friend opens the URL and accepts, and the same game-creation step runs.
Users play a real-time game
Three tiers do the work. The socket tier holds the WebSocket connections and knows which games each socket cares about, but no game state. The game servers each own a set of live games, one actor per game: an object with a mailbox that processes one message at a time, so the game’s state needs no locks. Between them, a pub/sub layer carries messages keyed by game ID. Lichess is built this way: its main Scala server keeps live games in memory, and a separate program, lila-ws, holds the WebSockets and talks to it through Redis.
When the actor receives a move, it checks that it is this player’s turn, that the ply number is the next one, and that the move is legal; it charges the mover’s clock (the clock deep dive explains how); it appends the move, bumps the game’s version, and publishes one event that both players and every spectator receive.
The server never streams clock ticks. Each event carries both players’ remaining time; clients count down locally, and the actor sets a timer for the moment the player to move would run out. The actor also ends the game when the rules say so: checkmate, stalemate, insufficient material, and the two automatic draws in the FIDE Laws — the same position five times, or 75 moves by each player without a capture or pawn move. Under Article 6.9, running out of time loses, unless the opponent could not possibly checkmate, which makes it a draw.
Users watch live games
A spectator opens the game page and their socket subscribes to the same game channel as the players. The socket server subscribes to that channel once, however many of its sockets are watching, and fans each event out locally. The game actor publishes once per move whether two people or twenty thousand are watching. Spectators get the same events minus anything private, such as a pending draw offer; how to handle a top game with a huge audience is a deep dive.
Users review games and see their rating change
When a game ends, its actor writes the final record — moves, clock times, result — and both players’ new ratings, then publishes a “game finished” event. Consumers index the game for search, queue it for engine analysis if requested, and update the rating leaderboards. The PGN export is generated from the stored record, with each move’s remaining time as a [%clk] comment — the format Lichess has published since April 2017.
This works: games are fast, ordered and fair in the normal case. The deep dives are about the abnormal cases — a dead owner, a slow network, a dropped connection, a crowded pool, a huge audience and a result that must be recorded exactly once.
Deep dives
Where should a live game’s state live?
Every message about a game — a move, a resign, a draw offer, a flag — must be applied in one order against one board and two clocks. The question is who enforces that order.
Bad: stateless game servers, state in the database. Any server handles any message: read the game, validate, write it back. It is the familiar web pattern, and here it costs three things. Every move pays a database read and a conditional write, a few milliseconds in the same zone, so 14,000 moves a second become 28,000 database operations on the critical path. Two servers handling two messages for the same game — a move and a resign — race, so you need a version column and retries. And a clock needs someone to notice when it runs out, so you add a sweeper that scans every live game a few times a second. When the database hiccups, every game in the world pauses while its clocks keep running.
Good: stateless servers with the game in Redis. Keep each game as a Redis hash and apply moves with a Lua script, which Redis runs atomically. That is sub-millisecond and race-free, but still a network hop per move, the flag sweeper is still external, and one shared store now sits on every move — and loses recent writes when it fails over.
Great: one owner actor per game, in memory. Route every message for g_7Qx2 to the one game server that owns it, where an actor processes messages one at a time. No locks, no read per move, and the flag check is a timer inside the actor. This is Lichess’s design: its game proxy keeps each live game in memory and writes changes to MongoDB lazily — 30 seconds after the last change, or immediately when the game’s status changes, for example when it ends. You pay in two places. Routing: you need a directory or hash that maps games to servers, and a way to move ownership during deploys. Durability: memory dies with the process, which the persistence deep dive deals with.
| Database per move | Redis per move | One in-memory owner per game | |
|---|---|---|---|
| Work per move | Read plus conditional write, 2–5 ms | One atomic script, under 1 ms plus a hop | A local call, microseconds |
| Races between servers | Version column and retries | None: the script is atomic | None: one writer |
| Who notices a flag | An external sweeper | An external sweeper | A timer inside the actor |
| What a crash loses | Nothing | Recent writes, if Redis fails over | Moves since the last flush |
| Extra machinery | Sweeper, retry logic | Sweeper, scripts, a hot shared store | Routing by game ID, ownership handover |
| Good fit | Correspondence chess | Small sites | Real-time play at scale |
Ownership has to move when servers come and go. During a deploy, the old server stops taking new games and tells every actor to flush. Lila does exactly this: it sends a stop message to every game actor, waits for each to acknowledge, and adds 20 seconds to the clock of the player to move and 10 to the other to make up for the pause. The games are then re-created on their new owners from the database while clients reconnect and resume (see the connection deep dive). Unplanned crashes use the same path with older data. Use a consistent hashing ring or a small directory service so that a change of servers moves only the games that must move.
How do we keep the clock fair when networks are slow?
The clock is the hardest part of online chess, because the server and the players never see the same instant. Take a 1+0 bullet game: White in Chennai is about 150 ms round trip from servers in Europe; Black in Berlin is 30 ms away.
Bad: each app runs its own clock and reports “I have 41.2 s left”. No lag problem at all — and trivially cheatable, since a modified client never runs out of time.
Good: the server keeps time by arrival. The server starts White’s clock when it applies Black’s move and stops it when White’s move arrives. It is honest, but it charges the network to the player. White pays about 150 ms a move: over 40 moves that is 40 × 0.15 = 6 seconds, a tenth of a one-minute game. Black pays 40 × 0.03 = 1.2 seconds. The worse connection loses.
Great: server time minus measured lag, paid from a bounded quota. The app measures how long the player actually thought, from the moment the opponent’s move appeared to the moment of the reply, and sends it with the move. The server computes lag as its own elapsed time minus the client’s move time and refunds that lag — but only from a per-player compensation quota, so a client that lies about its lag gains a bounded amount.
Lichess’s open-source clock code (scalachess) shows how to bound it:
- Refund. Each move refunds the smaller of the measured lag and the remaining quota. The quota then grows by a per-move gain, up to a cap.
- Gain. At most min(1 s, 0.15 s + 0.004 × the game’s estimated duration in seconds), where the estimate is initial time + 40 × increment. For 1+0 that is 0.15 + 0.004 × 60 = 0.39 s a move; for 5+3 it hits the 1 s cap. Once the app reports its frame lag, the gain is also capped at that frame lag plus 0.14 s.
- Bank. The quota starts at three gains and is capped at seven — 1.17 s and 2.73 s in 1+0.
- Flag grace. Before declaring that a player has run out of time, the server waits up to min(quota, 2 s) in case their move is still on the wire.
The result is what Lichess promises on its lag page: steady lag is refunded in full, so a higher ping than your opponent is not a handicap; one spike is covered from savings; repeated spikes drain the bank and start to cost the player. A cheating client that invents lag can steal at most what the quota holds. Two finishing touches: the server sends a low estimate of the next refund so the app can show a smooth clock, and premoves — a move queued before the opponent replies and sent the instant their move arrives — spend almost no thinking time, so in bullet the player’s cost per move falls to roughly the network delay, which the quota then refunds.
In the interview
Say the rule in one breath: “The server is the timekeeper. It charges each move its own measured elapsed time minus the lag the client reports, but only up to a per-player quota that refills a little each move — so steady lag is free and lying about lag is capped.”
What happens when a connection drops mid-game?
Mobile networks drop. A player on a train out of Mumbai loses signal in a tunnel for eight seconds; a laptop lid closes; a tab is shut in anger. Three questions follow: how do we notice, how do we resume without applying a move twice, and when does waiting become losing? The clock never pauses: under the FIDE Laws the clock’s reading is conclusive, and online the server’s clock is that clock.
Noticing. The socket server sends WebSocket pings (control frames defined in RFC 6455, which the other side must answer with a pong) or application-level pings every few seconds. When a socket closes or stops answering, the socket server tells the game actor “White is offline”, and the actor tells Black.
Resuming safely.
- Bad: fire-and-forget moves. The app sends a move and the connection dies. Did it land? If the app resends blindly and the server applies it again, the second copy is either rejected as out of turn or, worse, means something else in the new position.
- Good: an idempotency key per move, with a table of applied keys. It works, but adds a lookup and storage for something the game already knows.
- Great: the game’s own sequence numbers. Every move carries its ply, and every event carries the game’s version. The actor accepts a move only if its ply is the next one. A ply that was already played with the same move is a duplicate: acknowledge it again and change nothing. An old ply with a different move is stale; a ply from the future means the client is confused. Both get a fresh snapshot. A reconnecting client sends the last version it saw; the actor keeps its last few dozen events in memory and replays the missing ones, or sends a full snapshot — position, clocks and version — if the client is too far behind. Lila’s socket protocol is built on such versioned events.
When does waiting become losing? Waiting for the clock is too generous — an abandoned 30+20 game would hold the opponent hostage for half an hour — and a fixed 30 seconds is too harsh for a classical game interrupted by a router restart. Lichess scales the grace period, and its rules are a good template:
After the grace period the opponent sees “claim victory” and “call it a draw” buttons; a claimant who could not possibly checkmate gets a draw instead. A game in which a player never makes a first move is aborted, not lost, and players who abort or leave often get temporary play bans. The cost of all this is policy complexity — but every rule is one line in the actor, and each prevents a real way to grief an opponent.
How does matchmaking find a fair opponent fast?
Pairing pulls two ways: fast, and close in rating. At 3 a.m. the 1+0 pool may hold a dozen people; on a weekend evening, hundreds.
Bad: first come, first served. Pair the two longest-waiting players. Instant, and unfair: a 900 meets a 2200, and both have a bad game.
Good: a window per player that widens over time. Each seeker scans the pool for someone within ±50 points, widening by 25 every five seconds. It is fairer, but greedy and order-dependent: the first scanner may take the best partner of someone else and leave two players stranded who would have paired well with each other, and every arrival rescans the pool.
Great: waves with a global weighted matching. At each wave, take everyone in the pool and choose the set of pairs with the lowest total cost. In lila the cost of a pair is the rating gap minus bonuses: 12 points for every wave a player has already missed (up to 460), a bonus when both chose rating ranges that include each other, one for two provisional players, and one for two players with clean records of not abandoning games — with a penalty for pairing a serial leaver with a good citizen. A pair is forbidden above a rating-dependent limit (100 points of cost around 1500, about rating ÷ 15 above that), if either player blocked the other, or if a chosen range excludes the other. Players flagged for cheating or rating manipulation are only paired with each other. Weighted matching is O(n³), but n is one wave of one pool — tens to a few hundred players — so a wave takes milliseconds.
You gain globally good pairings, predictable waits and explicit knobs for fairness. You pay up to one wave of waiting — 12 seconds in 1+0, a minute in 30+20 — and a burst of game creations on every wave, which the hash spreads across game servers. The morning after an Indian grandmaster wins a world title, thousands of new accounts flood the 3+0 and 10+0 pools; waves then fire on head-count rather than the timer, so waits get shorter, and the provisional bonus pairs newcomers with each other while their ratings settle.
How do thousands of people watch the same game?
A world-championship game, or the decisive board of an Olympiad, can draw 100,000 live viewers. Each move then has to reach all of them within a second or so — 100k × about 200 bytes is 20 MB per move — and the two players must not feel it.
Bad: the actor sends to every spectator. Each move becomes 100k sends on the actor’s thread. The players’ next move waits behind them, and their clocks are running.
Good: one channel per game, fan-out at the socket tier. The actor publishes once. Every socket server with at least one watcher subscribes to the game’s channel once and writes to its own sockets: 100k viewers on 10 servers means 10 deliveries from pub/sub and 10k local writes per server. Keep players’ sockets ahead of spectators’, and give each spectator a bounded send buffer — a watcher who falls behind gets a fresh snapshot or is disconnected, never allowed to slow a player.
Great for top games: a broadcast relay with an optional delay. One relay subscribes to the game and turns events into a cacheable stream — server-sent events or chunked HTTP — served through a CDN, so the biggest audiences never touch the socket tier. Elite events often delay public broadcasts so that nobody can relay engine advice back to a player; the relay simply holds events in a queue and releases them, say, 15 minutes later. Spectators need no clock ticks: every event carries both remaining times and the client counts down. You pay in CDN streaming cost, and delayed viewers cannot chat in step with the live game.
How do we persist games and update ratings safely?
Two separate questions: how much of a live game survives a crash, and how a finished game becomes exactly one rating change per player.
Persisting live games.
- Bad: write only when the game ends. A crash loses every game in progress, entirely.
- Good: write every move synchronously. Nothing is lost, but 14,000 writes a second sit on the move path, every move waits a few milliseconds for the database, and a slow database slows every game in the world.
- Great: memory first, flushed lazily — and honest about crashes. Mark the game dirty on each move and flush 30 seconds after the last change, or at once when the status changes, which is what lila does. Before a planned restart, every actor flushes. An unplanned crash loses at most the moves since the last flush, and the game is then resumed from the saved position or aborted; Lichess’s FAQ says that a game lost on time because of a restart is aborted rather than scored. If that is not good enough, also append each move asynchronously to a replicated log keyed by game ID — about a millisecond, off the critical path — so a crash loses only moves still in flight.
Ratings with Glicko-2. Mark Glickman’s system gives each player three numbers: a rating, a rating deviation (RD — how unsure the system is) and a volatility (how erratic the player is). A 95% interval is the rating ± 2 RD. Unrated players start at 1500 with an RD of 350 and a volatility of 0.06 by default; Lichess starts everyone at 1500 with an even wider interval and marks a rating provisional while its RD is above 110. Why not Elo? Elo has one number and a fixed step size, so it moves a newcomer’s rating as slowly as a veteran’s and cannot tell a stable player from one returning after a year. Glicko-2 moves uncertain ratings fast and certain ones slowly, and lets RD grow while a player is inactive. Glickman notes it works best with rating periods of at least 10 to 15 games per player, yet online sites apply it after every game: each game in Lichess’s exports carries Glicko-2 ratings in its Elo tags and a per-game rating difference.
Exactly once. The finish step turns one result into one rating change per player:
- One decision. The actor is the only writer for the game, so there is exactly one result.
- One transaction. Set the game’s status from
startedtofinishedonly if it is stillstarted; insert one rating-history row per player with a unique key on user and game; update both players’ ratings. A retry after a timeout hits the status guard and the unique key and changes nothing. - One announcement. Write a “game finished” event in the same transaction (an outbox) and publish it afterwards, for search, analysis and leaderboards.
One subtlety: a player can be in several games at once — correspondence games, a simultaneous exhibition — so two finishes for the same player can race. Compute each new rating inside the transaction from the stored current value (read it with a lock), not from a copy the actor cached at the start of the game. In a document store without multi-document transactions, as lila uses MongoDB, keep the same guards — a conditional status update on the game, and rating updates keyed by game ID — so each step can be retried safely.
A restart must never decide a game
The deploy that restarts a game server is the most common “failure” you will have. Flush every actor before shutdown, re-create games on their new owners, and if a clock expired while nobody could move, abort the game rather than score it.
The final design
Put the pieces together: connections at the edge, one owner per game in the middle, results and ratings written once at the end.
Follow one game from seek to rating:
- Seek. A player taps “3+2”; the socket tier passes the seek to the pairing service, which adds them to the 3+2 pool.
- Pair and create. On the next wave, a weighted matching pairs players by rating with bonuses for waiting; for each pair, a “create game” message goes through pub/sub to the owner chosen from the game ID, and both players get
gameStart. - Play. Each move goes from the socket tier through pub/sub to the game’s actor, which checks turn, ply and legality, charges the clock its measured time minus the lag it can refund from the player’s quota, and publishes a versioned event to both players and every watching socket server. Reconnecting clients resume from their last version; absent players get a grace period scaled to the time control.
- Broadcast. For top games, a relay subscribes once and streams events, delayed if the event demands it, through CDN edges.
- Persist. The actor flushes progress 30 seconds after the last change and immediately when the game ends, then runs the finish transaction: status guard, both Glicko-2 updates, rating history and an outbox event.
- Announce. The outbox feeds search indexing, optional engine analysis and the rating leaderboards.
Check it against the requirements. Latency: a move is a handful of in-memory operations and two pub/sub hops, well inside 10 ms of server time. Fairness: network lag is refunded from a bounded quota, flags wait for in-flight moves, and abandonment rules scale with the time control. Correctness: one owner per game, ply-checked moves, version-checked events, and a guarded finish transaction. Scale: 63,000 live games fit in about 630 MB across a dozen game servers, and 326,000 sockets on about a dozen socket servers. Availability: restarts flush first and never decide a game; a crash costs at most a few seconds of moves, never a rating.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| State | Game in the database, updated per move | One owner per game in memory; routing by game ID | Ownership handover on deploys, flush policy, the crash-versus-latency trade-off named |
| Real time | WebSockets | Socket tier split from game logic; pub/sub routing | Versioned events, resume and snapshots, back-pressure that protects players from spectators |
| Clocks | Server timestamps | Server-authoritative clocks; notices that lag is charged unfairly | Measured lag refunded from a bounded quota, flag grace, premoves, abuse limits |
| Correctness | Validates legal moves | Ply numbers for duplicates; draw and flag rules | Single finish transaction; exactly-once ratings under concurrent games |
| Matchmaking | Pair by rating | Pools per time control; widening window | Wave-based weighted matching, fairness bonuses, isolation pools for cheaters |
| Spectators | Same socket as players | Fan-out at the socket tier | Relay plus CDN for top games, broadcast delay, priorities |
| Ratings | Elo | Glicko-2 basics: rating and RD | Volatility, provisional ratings, rating periods versus per-game updates |
Follow-up questions
What if we add arena tournaments?
A tournament service runs its own pool: whenever a player finishes a game, it pairs them again with someone near them in the standings. The games themselves are ordinary games carrying a tournament ID, and the “game finished” event updates the standings — a sorted set per tournament, exactly the live leaderboard problem. The hard moment is the start: thousands of players are paired in the same second, so game creation must spread across game servers, and the tournament’s first wave can be staggered by a few seconds.
What if a game server crashes mid-game?
The routing layer notices the dead server through missed heartbeats and reassigns its games; each new owner loads the game from its last flush. Clients reconnect and send their last version. If the stored game is behind what the clients saw — moves were lost — either resume from the stored position and tell both players, or abort without a rating change; aborting is the simpler, more defensible default for rated games. Make crashes rarer than deploys, and make deploys flush first.
What if most players are in India and the servers are in Europe?
Lag compensation keeps the clock fair, but a 150 ms round trip still makes the board feel sluggish. Terminate WebSockets and TLS at regional edges near players — fewer handshakes and smoother jitter — while the game actor stays in one place, because both players must share one clock. Better still, run game servers in several regions and create each game on an owner near both players: a Mumbai–Chennai game lives in an Indian region, and only cross-continent games pay the long hop.
What if we need to detect cheating?
Collect the signals now and model them later. Every move already carries the client’s measured move time, and lila’s move message also carries a “blur” flag for when the player left the page. After the game, an analysis pipeline compares moves with engine choices and timing patterns. Flagged accounts keep playing but are paired only with each other — the isolated pool from the matchmaking deep dive — while humans review the evidence, and their rated results can be refunded to past opponents.
What if we support correspondence chess, with days per move?
Then the live actor is the wrong default: a game may sit untouched for two days. Persist every move synchronously — lila flushes rated correspondence games immediately rather than after 30 seconds — load the actor only while someone is looking, and store each move deadline in the database, where a scheduler finds games whose time has run out. Notifications replace the WebSocket as the main channel.
How to say it in the interview
“I’ll design a Lichess-style service: about 5 million games a day, roughly 60 thousand live at the evening peak, time controls from one-minute bullet to classical, and up to 100 thousand people watching one game.
The core decision is that each live game has exactly one owner: an actor in memory on one game server, chosen by hashing the game ID. It holds the board, both clocks and the move list, and processes one message at a time, so there are no locks and no database read per move. Players connect to a separate socket tier that routes messages to the owner through pub/sub.
Each move carries its ply number. The actor checks turn, ply and legality, appends the move, bumps a version and publishes one event to both players and all watchers. The ply makes resends harmless, and the version lets a reconnecting client catch up from where it left off.
The server is the timekeeper. It charges each move its measured elapsed time minus the network lag the client reports, but only up to a per-player quota that refills a little each move. Steady lag costs nothing, a spike is covered from savings, and a lying client gains very little.
Pairing runs one pool per time control in waves: every few seconds, a weighted matching pairs everyone by rating, with a bonus for each wave someone has waited. Disconnected players get a grace period scaled to the time control, then the opponent can claim the win.
Spectators share the game’s pub/sub channel; top games go through a relay and a CDN, optionally delayed. The actor flushes progress every 30 seconds and writes the result and both Glicko-2 rating updates in one guarded transaction, so a restart never decides a game and ratings change exactly once.”
Concepts used in this design
- Long polling, SSE and WebSockets — the player and spectator channels.
- Stateful vs stateless design — why the game tier is deliberately stateful.
- Publish–subscribe — routing between socket servers and game owners, and spectator fan-out.
- Consistent hashing — mapping games to owners so that a server change moves few games.
- Idempotency — ply numbers make resent moves harmless.
- Message ordering and per-key queues — one mailbox per game gives one order per game.
- Clocks, ordering and conflict resolution — why the server’s clock is the only clock.
- Heartbeats — detecting dropped players and dead game servers.
- Failover — re-homing games after a crash.
- ACID transactions — the finish step that writes a result and two ratings together.
- Transactional outbox — announcing finished games reliably.
- Real-time updates — pushing moves to players and watchers.
- Content delivery network — streaming the biggest games to large audiences.
- Design a live leaderboard — rating leaderboards and tournament standings.
References and further reading
- lichess-org/lila: the Lichess game server (README)github.comA Scala server; WebSockets are handled by a separate server (lila-ws) that talks to it through Redis; MongoDB holds more than 12 billion games.
- lila source: GameProxy.scala (round module)github.comA live game is cached in memory; changes are flushed to the database 30 seconds after the last one, or at once when the game’s status changes.
- lila source: RoundSocket.scala (round module)github.comDisconnect timeouts by speed (30 s base, ×2 blitz, ×4 rapid, ×10 classical, halved when losing or anonymous, 10 s after closing the tab); moves carry lag metrics; versioned events.
- lila source: the pool (quick pairing) modulegithub.comEleven pools from 1+0 to 30+20, pairing waves every 12–60 s or when 20–40 players wait, weighted matching with a bonus of 12 per missed wave.
- scalachess source: Clock, LagTracker and MoveMetricsgithub.comThe server-side clock: lag = server elapsed − client move time, compensated from a per-player quota (start 3×, cap 7× the per-move gain), and a flag grace of up to 2 s.
- Is Lichess lagging? (lichess.org)lichess.orgServer move processing rarely exceeds 10 ms; servers are in France; lag compensation means higher network lag is not a handicap.
- Lichess FAQlichess.orgTime-control classes from initial time + 40 × increment; Glicko-2 starting at 1500 with a wide interval; provisional when RD > 110; games lost on time because of a restart are aborted.
- lichess.org open databasedatabase.lichess.org89.6 million rated standard games in September 2026 (29.2 GB of compressed PGN); %clk comments since April 2017; Elo tags hold Glicko-2 ratings, with a rating difference per game.
- Example of the Glicko-2 system (Mark E. Glickman)glicko.netRating, rating deviation and volatility; defaults 1500, 350 and 0.06; τ between 0.3 and 1.2; a 95% interval of ±2 RD.
- FIDE Laws of Chess (FIDE Handbook E.01)handbook.fide.comArticle 6.9: a flag fall loses unless the opponent cannot possibly checkmate; Article 9.6: fivefold repetition and the 75-move rule end the game in a draw.