Design an online chess game
A live game of chess between two people in the browser, from one-minute bullet to long classical games, with spectators watching. The hard part is not drawing 64 squares: it is a server that owns the rules and the clocks, moves that feel instant while the network is slow, fair clocks for a player with 300 ms of lag, premoves, and a socket that drops in the middle of a bullet game.
The question
Design the game page of an online chess site like lichess.org or Chess.com. Two players are matched, the board appears, and they play with a chess clock: anything from bullet (one minute each, no increment) to classical (30 minutes or more). Other people can open the same game and watch it live.
Walk me through how a move gets from one player's mouse to the other player's board, how the clocks stay fair when one player has a slow connection, what happens when a socket drops in the middle of a bullet game, and what the board component does to feel fast.
Real-life examples: Lichess.org, Chess.com Live Chess, Lichess TV and broadcasts, Chess.com Play a Friend
Requirements, and what makes online chess hard
An online chess game is two people playing chess through a server, each on their own device, with a chess clock. The server matches them, checks every move, runs the clocks, and sends each move to the opponent and to anyone watching. Lichess and Chess.com host millions of games a day, many of them bullet and blitz, with one to five minutes per player.
Speed changes everything here. In a classical game a player thinks for minutes and nobody notices 200 ms of network delay. In bullet (lichess counts a game as bullet when its estimated length is under 3 minutes) a player makes about 40 moves in 60 seconds, so each move gets about 1.5 seconds of thinking. A 200 ms delay is then more than 10 percent of a move, so pin down the time controls first: the fast ones drive the design.
| Question | Answer we assume | What it changes |
|---|---|---|
| Which time controls? | Bullet (1+0, 2+1) up to classical (30+20). Written as minutes + increment in seconds | Clocks must be fair to within tens of milliseconds |
| Who plays? | Two humans, matched by the server or by a challenge link | No engine opponent in scope; one socket per player per game |
| Can people watch? | Yes: anyone with the link, from 0 to about 50,000 viewers for a top game | A read-only spectator stream that fans out cheaply |
| Which devices? | Desktop browsers and phones (touch), on Wi-Fi and 4G | Drag with mouse and touch, click-click moves, small payloads |
| What if a player loses connection? | The game goes on and the clock keeps running; they can rejoin | Resume by game id; a claim-win option for the opponent |
| Premoves? | Yes: they decide many bullet games | A queued move that the client plays the moment it is its turn |
| Correspondence chess (days per move)? | Out of scope | It is a different product: notifications, not live sockets |
| Anti-cheat with engines? | Out of scope, done on the server after the game | The client sends a few weak signals and nothing else |
Neighbouring guides, linked at the end of this page, cover some pieces, so we do not repeat them: multiplayer trivia (a server-driven game loop and rejoining a live session), countdown timer (timer drift and the server clock offset), drag-and-drop list and Kanban board (pointer and keyboard drag), live comments and live streaming (chat and fan-out to many viewers), and live leaderboard (ranked tables of players). This guide is about what is special to a two-player, turn-based game with clocks.
Functional requirements
- Show the board, both players (name, rating, flag), both clocks, and the move list.
- Move a piece by dragging it or by clicking the piece and then the target square. Show legal targets as dots.
- Reject illegal moves at once, on the client, and ask which piece to promote to when a pawn reaches the last rank.
- Run both clocks with the agreed time control, including increment, and end the game when a clock reaches zero.
- Detect the end of the game: checkmate, stalemate, resignation, time out, draw by agreement, repetition, the 50-move rule, and insufficient material.
- Let a player queue a premove while the opponent is thinking.
- Offer a draw, resign, abort in the first moves, and request a rematch.
- Rejoin a game after a reload or a dropped connection, and see when the opponent is disconnected.
- Let spectators watch the board, clocks, and move list live.
Non-functional requirements
- Instant feel: your own move shows on the board in the same frame you drop the piece, under 16 ms, whatever the network.
- Fast delivery: the opponent sees your move within about 100 ms plus their network latency (a median of 50 to 150 ms on good connections; these are estimates).
- Fair clocks: a player is never flagged (loses on time) because of their network, within a bounded amount of compensation per move.
- One truth: both players and every spectator end on exactly the same position and the same result.
- Resilient: a 3 second drop in a bullet game costs the player 3 seconds of clock, not the game.
- Light: the game page under about 150 KB of JavaScript compressed, and a move message under 200 bytes (estimates).
- Accessible: playable by keyboard and with a screen reader, with moves announced in words.
Five properties that make it hard
The page itself is small: a board, two clocks, a move list, and a few buttons, as the wireframe below shows. Every hard decision in this guide comes from five properties of the game, and it helps to say them out loud before drawing any boxes.
- Strict turns. Exactly one player may move at a time, and every move depends on the one before it, so a lost, duplicated, or late message makes the position wrong.
- Rules with history. Castling needs to know whether the king or rook ever moved, en passant needs the previous move, and repetition draws need every earlier position. The state is a position plus some history.
- Time is part of the game. In most games a slow network is an annoyance; in chess it costs clock time, and running out of time loses. How network delay is charged is a fairness question, not a UX one.
- Two people with very different networks. One player is on fibre 10 ms from the server, the other on 4G 250 ms away with spikes to a second. The design must be fair to both without letting either fake lag.
- Speed of human input. In bullet a strong player moves in 0.2 to 0.5 seconds and queues moves before it is their turn. Any frame the UI wastes, and any round trip it waits for, is visible.
Put together, these give the shape of the answer. The server is the single source of truth for the position, the move order, and the clocks. The client keeps a copy of the rules so it can respond instantly and show legal moves, but it only predicts. Every message carries a move number so stale and duplicate messages are harmless, and the client never trusts its own timer for anything but drawing digits.
Think first
Two players, one game: why not connect the browsers directly with WebRTC and skip the server?
Because neither player can be trusted. With a direct connection each side runs its own clock and checks the other's moves, so a modified client can stop its clock, claim the opponent flagged, or send an illegal move, and there is no neutral referee.
Ratings, spectators, and anti-cheat also need the game on a server anyway. WebRTC data channels would save perhaps 20 to 50 ms of latency (an estimate), which a good lag compensation scheme recovers more fairly.
Check your understanding · Exercise 1
Which of these answers from the interviewer change the frontend design the most? Select all that apply.
The game state: positions, moves, and who checks the rules
Before moving anything over a network we need to agree on what the game is, as data. Chess has two standard text formats for this, and a good design uses both.
- FEN (Forsyth-Edwards Notation) is one line that describes a single position: where every piece is, whose turn it is, which castling moves are still allowed, the en passant square, the halfmove clock (half-moves since the last capture or pawn move, for the 50-move rule), and the move number. The figure below takes one apart.
- A move list is the history. Each move can be written in SAN (Standard Algebraic Notation, what humans read:
Nf3,exd5,O-O,e8=Q+) or UCI (the from and to squares:g1f3,e4d5,e1g1,e7e8q). SAN needs the position to be understood; UCI does not, which makes it the better wire format. - PGN (Portable Game Notation) is the file format for a whole game: header tags like
[White "..."]and[TimeControl "60+0"], then the moves in SAN with move numbers. It is for downloads, not the live game.
KQkq) and the halfmove clock cannot be seen on the board, which is why the client always takes them from the server.The FEN is enough to draw the board and check the next move, but not to decide every draw: threefold repetition needs every earlier position. So the true state of a game is the starting position plus the ordered list of moves. The FEN of the current position is a cached result of replaying that list. Each move has a number, the ply (one half-move: White's first move is ply 1, Black's reply is ply 2), and the ply is the most important number in this whole design: it names a version of the game.
Who checks that a move is legal?
Decision Where do the chess rules run?
What we pick: Both. The server is authoritative: its copy of the game is the game. The client runs a rules engine only to predict, so it can draw legal-move dots and show your move in the same frame. Lichess takes a lighter route to the same split: its server (built on the scalachess library) checks every move and sends the legal destinations with each update, so its board shows dots and accepts drops without a full rules engine in the page.
On the client, a small library does the rules. chess.js is the common choice: about 12 KB minified and gzipped (version 1.4), with moves({ square, verbose: true }) for legal targets, move({ from, to, promotion }) which throws an error on an illegal move, fen(), pgn(), and checks such as isCheckmate(), isStalemate(), isInsufficientMaterial(), and isThreefoldRepetition(). A thin wrapper turns a drop into a prediction:
import { Chess, type Square } from "chess.js";
export function legalTargets(fen: string, from: Square): Square[] {
return new Chess(fen).moves({ square: from, verbose: true }).map((m) => m.to);
}
export function predictMove(fen: string, from: Square, to: Square, promotion?: "q" | "r" | "b" | "n") {
const game = new Chess(fen);
try {
const move = game.move({ from, to, promotion });
return { ok: true as const, uci: move.lan, san: move.san, fen: game.fen() };
} catch {
return { ok: false as const };
}
}
How a game ends, and who decides
Endings are where client prediction is most dangerous. Under the FIDE Laws of Chess, threefold repetition and the 50-move rule are claims: a player may ask for the draw, but the game does not stop by itself, while fivefold repetition and the 75-move rule end the game automatically. Lichess follows this: a claim button appears on threefold, there is a setting to claim automatically, and fivefold ends the game at once. So the client must not end a game because isThreefoldRepetition() returned true; it should show the claim button and let the server decide.
| Ending | Detected by | Automatic? | What the client does |
|---|---|---|---|
| Checkmate, stalemate | Rules engine after a move | Yes | Shows the result only when the server's move reply says so |
| Insufficient material | Rules engine (for example king against king and bishop) | Yes | Same: waits for the server's result |
| Threefold repetition, 50-move rule | Rules engine with history | No, a claim | Shows a claim button; sends a claim message |
| Fivefold repetition, 75-move rule | Server | Yes | Shows the result from the server |
| Time out (flag) | Server clock | Yes | Shows the result; it is a draw if the other side cannot possibly mate |
| Resign, draw agreed, abort | Player action | Yes | Sends the action, shows the server's confirmation |
Two details catch people. Promotion is part of the move (e7e8q), so when a pawn reaches the last rank the client opens a small picker before anything is sent, and treats the pick as part of the same move. Castling rights live in the FEN (KQkq), not on the board: a king that walked away and back looks the same but can no longer castle. This is why the client always rebuilds its position from the server's FEN or move list and never from the pieces it drew.
Check your understanding · Exercise 2
Which statements about representing and checking the game are true? Select all that apply.
Sending a move: instant on screen, agreed by the server
You drop your knight on f3. The naive version waits: send the move, keep the knight on g1 (or in a ghost state) until the server says yes, then draw it. On a 4G connection with 150 ms round trips that is a visible lag on every move.
Decision How does the client show its own move?
What we pick: Optimistic with a pending move and ply numbers. It is the optimistic updates pattern, made exact by the fact that chess has a total order: the ply. At most one of your moves can be pending at a time, because after it you must wait for the opponent anyway. That keeps the rollback trivial: go back to the last confirmed position.
The figure shows both paths. On the happy path the server checks the move, applies it, stops your clock, starts the opponent's, and sends one message to both players and spectators: the move in UCI and SAN, the new ply, and both clocks. Your client sees its own move come back with the expected ply and clears the pending flag. On the failure path (say the game had already ended on time) the server rejects it and the client restores the last confirmed position.
The ply rules that make the network harmless
Messages get duplicated (a resend whose first copy did arrive), arrive late (flushed after a reconnect), or skip ahead (missed while offline). Three rules on the ply handle all of them:
- ply less than or equal to the confirmed ply: already applied. Ignore it. This makes every server message idempotent: applying it twice does nothing.
- ply equal to confirmed + 1: the next move. Apply it. If it matches your pending move, just confirm it; if it does not, your pending move lost a race (the game ended, for example) and is dropped.
- ply greater than confirmed + 1: you missed something. Do not guess. Ask the server for the full state (a resync) and replace yours.
type Confirmed = { ply: number; fen: string; moves: string[] };
type Local = { confirmed: Confirmed; pending: { ply: number; uci: string } | null };
type ServerMove = { ply: number; uci: string; fen: string };
export function onServerMove(s: Local, m: ServerMove): Local | "resync" {
if (m.ply <= s.confirmed.ply) return s; // duplicate or late: ignore
if (m.ply > s.confirmed.ply + 1) return "resync"; // gap: ask for full state
const confirmed = { ply: m.ply, fen: m.fen, moves: [...s.confirmed.moves, m.uci] };
const stillPending = s.pending && s.pending.ply > m.ply ? s.pending : null;
return { confirmed, pending: stillPending };
}
export function onReject(s: Local): Local {
return { confirmed: s.confirmed, pending: null }; // board redraws from confirmed.fen
}
The resend is the other half. Lichess marks move messages as "ackable": the client keeps each one until the server acknowledges it, and in the current source sends it again every 1.2 seconds once it is 2.5 seconds old, and at once after a reconnect.
Because the move carries its ply, the server can treat a resend as idempotent: if ply 23 already exists and is the same move, it acknowledges again; if ply 23 is a different move, the resend is stale and is rejected. No move is ever applied twice, and no move is silently lost.
Try it below. You control the network: deliver messages one at a time, duplicate them, deliver the newest first, or make the server reject your next move. Watch the confirmed ply, the pending move, and how the board always ends on the server's position.
Your move: you are White.
Check your understanding · Exercise 3
The client has confirmed ply 22 and has your move pending as ply 23. Which handling is right? Select all that apply.
Clocks: fair time when the network is not
Online, the clock is the most argued-about part of the game: the network sits between the player and the clock, and every millisecond of delay is charged to somebody.
Why each browser cannot run its own clock
Say each client runs its own clock and reports "I have 12.4 seconds left" with every move. Two things go wrong. A modified client can simply report more time. And the two clients disagree about when Black's clock started (when White's move left, or when it arrived 250 ms later), so who flagged first has no single answer.
Decision Who measures the time a move took?
What we pick: Server, with bounded lag compensation. The server's clock is the only clock; the client's countdown is a display. This is what lichess does: its server refunds measured lag from a per-player quota, so a slower connection is a small handicap rather than a lost game.
The timeline above follows one move by Black, 120 ms from the server each way. The server sends White's move and starts Black's clock; the move reaches Black 120 ms later, Black thinks for 800 ms, and the reply reaches the server 120 ms after that. The server measured 1,040 ms but Black only thought for 800, so 240 ms was lag, which the server refunds from Black's quota. Without compensation, Black would lose 240 ms per move to geography.
How a lag quota works
The scalachess source (the rules and clock library behind lichess) shows the shape of a good scheme: the server treats whatever it measured beyond the thinking time the client reports as lag. Each player has a quota of compensation: every move, the refund is the lag but no more than the current quota, and the quota then refills by a fixed gain, up to a cap.
In the 2026 source the gain is the smaller of 1 second and (0.4 x the estimated game length in seconds + 15) hundredths of a second, the estimated length being the initial time plus 40 increments: about 0.39 s per move in 1+0, 0.87 s in 3+0, and 1 s from 3+1 up. The quota starts at 3 times the gain and is capped at 7 times.
For signed-in players, lichess also caps the gain at the round trip its server measures with WebSocket pings, plus 0.14 s; the examples here ignore that cap. Treat these tuned constants as an example, not a law.
- Steady lag of 250 ms per move in 1+0 is fully refunded, because the quota refills by 390 ms per move.
- A spike of 3 seconds once is refunded only up to what is left in the quota (about 1.2 s at the start of a 1+0 game). The rest is charged. That is fair: a player whose connection froze cannot get unlimited free time.
- A cheater who claims 0 ms of thinking on every move gains at most the gain per move: about 0.4 s in bullet, and less on a fast connection, because the gain follows the measured round trip.
Increment and delay
Time controls are written as minutes + seconds, like 3+2. The second number is usually an increment: after each move, that many seconds are added to the mover's clock (the FIDE Laws call this cumulative or Fischer mode). An increment can leave a player with more time than they had before the move.
The FIDE Laws also define a delay mode, which their glossary also calls Bronstein mode: on each move the clock waits a fixed extra time before the main time starts counting down. Some clocks get the same result another way: they count down at once and, after the move, add back the time used, but never more than the delay. Neither kind of delay can raise a player's time above what they had at the start of the move, and the client only needs to know which mode runs to draw the clock correctly.
Drawing the clock on the client
Every server message that changes the turn carries both clocks (remaining milliseconds for each side, and which one runs). The client stores them with the local performance.now() at arrival (monotonic, unaffected by changes to the system clock), and each animation frame computes the display from that pair. A naive setInterval that subtracts 100 ms per tick loses track in a background tab, where Chrome runs timers at most once per second and much less often after a few minutes; computed from timestamps, the next visible frame shows the right number at once.
type ClockSnap = { white: number; black: number; running: "white" | "black" | null; at: number };
export function onClockUpdate(msg: { white: number; black: number; running: ClockSnap["running"] }): ClockSnap {
return { ...msg, at: performance.now() };
}
export function displayMs(snap: ClockSnap, side: "white" | "black", now = performance.now()): number {
const base = snap[side];
if (snap.running !== side) return base;
return Math.max(0, base - (now - snap.at));
}
export function format(ms: number): string {
if (ms < 10_000) return (ms / 1000).toFixed(1); // tenths under 10 s, like "4.7"
const s = Math.ceil(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
Flagging is the server's decision: when your display of the opponent's clock reaches zero, the client only sends an "out of time?" message (lichess calls it outoftime). The server checks its own clock, with the opponent's compensation, and either ends the game or replies with the real times. It also checks clocks on its own, so a game cannot hang because both clients went silent. Your own display reaching zero means nothing until the server says so: the move you sent 50 ms ago may still arrive in time.
Black flags on move 34, though both players thought equally fast. One honest truth, but Black pays two network trips on every move.
Check your understanding · Exercise 4
In a 1+0 game, Black has a steady 200 ms one-way latency. Which statements are true with server clocks and lichess-style lag compensation? Select all that apply.
Premoves: moving before it is your turn
In bullet, the strongest players often move in less time than a round trip. They do it with premoves: during the opponent's turn you drag a piece, the board keeps it as a highlighted intention, and the moment the opponent's move arrives your client plays it, without waiting for you. A recapture you already know you will make costs almost no clock time.
The design question is where the premove lives and who checks it. It cannot go to the server early: the server accepts only moves from the side to move, and a premove has to be checked against a position that does not exist yet. So a premove is purely client state until it is played.
When the opponent's move arrives, the client applies it, then checks the premove against the new position. If it is legal, the client sends it as a normal move at once. If not (the opponent moved the piece you meant to capture, or the move would leave your king in check), it is dropped silently.
| Behaviour | Lichess | Chess.com |
|---|---|---|
| How many premoves | One; its board library, chessground, stores a single from and to square | Up to five, played in the order you made them |
| Cancel | Right-click the board, or click a square that is not a move | Right-click, or tap the premoved piece |
| When it plays | Right after the opponent's move, if still legal; otherwise it is dropped | The same: cancelled if the opponent's move makes it illegal |
| Clock cost | The client reports 0 ms of thinking time | A flat 0.1 s per premove |
The lichess client shows a careful detail. When the opponent's move arrives, it waits one tick (a setTimeout of about 1 ms), then plays the premove only if the ply and position are still the ones it saw. That guards against a race where a second server message (a game end, a takeback) changes the game in between. When it sends the premove, it reports a thinking time of zero, so the server charges only network lag, which the lag quota usually covers.
export function onOpponentMove(state: GameView, sendMove: (uci: string, thinkMs: number) => void) {
const queue = state.premoves; // e.g. ["d1d8", "e1g1"]
if (queue.length === 0) return;
const plyAtArrival = state.confirmed.ply;
setTimeout(() => {
if (state.confirmed.ply !== plyAtArrival) return; // something else happened first
const [next, ...rest] = queue;
const result = predictUci(state.confirmed.fen, next);
if (!result.ok) {
state.premoves = []; // first illegal premove clears the whole queue
return;
}
state.premoves = rest;
state.pending = { ply: plyAtArrival + 1, uci: next };
sendMove(next, 0);
}, 1);
}
With several premoves, only the first can be played now: after it you have to wait for the opponent again. The rest stay queued and are checked one at a time as each opponent move arrives. If any one is illegal, the safest rule is to drop the whole queue, because the later moves were planned for a position that will not happen.
Check your understanding · Exercise 5
Which statements about premoves are true? Select all that apply.
Reconnecting in the middle of a game
Sockets drop: a phone switches from Wi-Fi to 4G, a load balancer restarts. In a chat app a 3 second gap is invisible. In a 1+0 game it is 5 percent of your clock, and the clock does not stop: the server keeps running it, because pausing it on disconnect would let any player buy thinking time by pulling the cable. The client's job is to get back into the game fast and land on exactly the server's state.
The WebSockets lesson covers heartbeats and how to detect a dead connection; the retries and backoff lesson covers retry timing; the multiplayer trivia guide covers rejoining a live session with a token. Here we focus on what is special to a chess game: the last ply, the pending move, and the clock.
- Notice fast. The client pings every few seconds and treats a missed pong as a drop, rather than waiting for the browser's
closeevent, which can take tens of seconds on a dead mobile link. It also reconnects at once on theonlineevent and when a hidden tab becomes visible. - Retry fast. Backoff in a live game starts at 0 and caps at a few seconds, with jitter. A 30 second cap that suits a news feed would lose every bullet game.
- Say where you are. The reconnect request names the game and the last ply the client confirmed:
?gameId=abc123&ply=41. The server answers with only the missing moves when there are a few, or a full snapshot (moves, FEN, clocks, status) when the gap is large or the game ended. - Resend what was pending. If your move was pending, the client resends it with the same ply. The ply rules from the move chapter make this safe: the server either acknowledges it (first copy never arrived, or it did and this is a repeat of the same move) or rejects it (the game moved on).
- Show the truth meanwhile. The board stays visible with a clear "Reconnecting" banner, and your own clock keeps counting down on screen, because that is what the server is doing.
When the opponent disappears
The server knows when a player's socket has gone and tells the other player, who sees "Opponent left the game" and a countdown; when it ends, lichess lets them claim victory, call a draw, or keep waiting. In the 2026 lila source (lichess's server) the wait is 30 seconds for bullet, 60 for blitz, 2 minutes for rapid, and 5 minutes for classical, shorter when the absent player is well behind on material or closed the page. In bullet this rarely matters: if it is the absent player's turn, their clock usually runs out first.
This timer is server state, like the clocks. The client only shows the countdown from a server deadline (claimableAt, an absolute server time), and the claim button sends a request that the server checks; if the opponent comes back first, the server clears it and the banner goes away. Chess.com uses the same shape with a different rule: the game ends by itself as abandoned when a player stays away for a tenth of (base time + 40 x increment), at least 30 seconds and at most 3 minutes.
Think first
A player opens the same game in two tabs and moves in one of them. What does the other tab do, and what could go wrong?
Each tab has its own socket, and the server broadcasts every move with its ply to both, so the second tab applies it like any server move.
The risk is a pending move or premove in the second tab. If both tabs send a move for the same ply, the server accepts the first and rejects the second, which rolls back: a clean race instead of a corrupted board.
Check your understanding · Exercise 6
A player in a 2+1 game drops for 4 seconds while it is their turn, with a move pending. Which behaviour is right? Select all that apply.
The board component: drag, click, animate, and stay fast
The board must accept a move in two ways (drag, or click the piece then click the target), show legal targets, highlight the last move and a king in check, flip for Black, animate the opponent's move, play a sound, and do all of that within one frame. The general mechanics of pointer and keyboard drag are in the drag-and-drop list and Kanban board guides; here we look at what a chess board adds.
Decision How do we render the board and pieces?
What we pick: DOM with CSS transforms, with an SVG layer on top only for arrows and circles the player draws. This is what lichess's chessground library does. With 32 pieces at most, the DOM is fast enough, and the composited transform keeps animation smooth. The reflow, repaint, composite lesson explains why a transform skips layout and paint.
transform, which skips layout and paint, so even a cheap phone keeps up at 60 frames a second.Input: drag and click-click
- Use pointer events (
pointerdown,pointermove,pointerup) so mouse, pen, and touch share one code path, and settouch-action: noneon the board so a drag does not scroll the page on phones. - On
pointerdownon your own piece: select it and fetch its legal targets from the rules engine (well under a millisecond). Show them as dots, and as rings on squares with an enemy piece. - Start a drag only after the pointer moves a few pixels. A press without movement becomes the first click of a click-click move. This is how both styles live together.
- During a drag, move only the dragged piece's transform, inside a
requestAnimationFramecallback. Never re-render the board onpointermove. - On drop: if the target is legal, apply the move locally (pending) and send it. If not, the piece slides back. If the move is a promotion, show the picker over the target square first.
- On touch, lift the dragged piece above the finger (about one square up) so the player can see the target square under their thumb.
Animation, flipping, and sound
The opponent's move slides in about 200 ms (chessground's default; lichess lets players choose) by changing the piece's transform on each animation frame, and a captured piece fades out. If a new move arrives before the animation ends, the board starts the new animation from where the pieces are instead of queueing it. With prefers-reduced-motion: reduce, moves appear without sliding.
Flipping the board for Black is a change of orientation: the square-to-position function maps a1 to the bottom left for White and the top right for Black, and the coordinates on the edges follow. Sounds (move, capture, check, low time) are short files decoded once with the Web Audio API and played on the move, not on the server's acknowledgement, so they feel instant.
const FILES = "abcdefgh";
export function squareToXY(square: string, size: number, orientation: "white" | "black") {
const file = FILES.indexOf(square[0]); // 0..7
const rank = Number(square[1]) - 1; // 0..7
const col = orientation === "white" ? file : 7 - file;
const row = orientation === "white" ? 7 - rank : rank;
const cell = size / 8;
return { x: col * cell, y: row * cell };
}
export function placePiece(el: HTMLElement, square: string, size: number, o: "white" | "black") {
const { x, y } = squareToXY(square, size, o);
el.style.transform = `translate(${x}px, ${y}px)`;
}
Check your understanding · Exercise 7
Which choices keep the board fast and correct on a mid-range phone? Select all that apply.
Spectators, broadcast delay, and fair-play signals
Most games have no watchers; a few have tens of thousands. Spectators need the same moves and clocks as the players, read-only, and they must never be able to slow the players down. The live comments and live streaming guides cover chat and large fan-out in depth; here is what changes for a game.
- A separate channel. Players talk to the game server that owns the game. Spectators subscribe to a broadcast channel fed by that server (through a pub/sub layer), served by different socket servers. A spike of 50,000 viewers then lands on the fan-out tier, not on the process that runs the clocks.
- The same message, minus private parts. A spectator gets each move with its ply, SAN, and clocks, but not draw offers in progress, takeback requests, or the players' private chat.
- Join mid-game with a snapshot. A new spectator first loads the game state by HTTP (cacheable for a second or two), then subscribes from that ply. The ply rules handle the overlap: moves already in the snapshot are ignored.
- Clocks from snapshots. The spectator draws clocks from the last server snapshot exactly like a player. Its display can be off by its own latency, which is fine for watching.
Should spectators see moves late?
A live feed is also a cheating tool: a friend watches the game, runs an engine, and passes the best move to the player. Over-the-board events defend against this with a broadcast delay. Lichess's broadcast tool lets organizers set an optional delay, in seconds, for the moves it relays, and its help text uses 15 minutes as the example.
For ordinary online games the trade-off is different: the cheater can run an engine on their own screen, so delaying spectators protects little and spoils the experience. A sensible design makes delay a per-game setting on the server (0 for casual games, minutes for prize events), applied on the spectator channel only, never on the players' channel.
Decision How do we delay spectators when an event needs it?
What we pick: Delay by time, on the server, per game. Anything sent to the browser is public, so a client-side delay protects nothing. The spectator page shows a clear "Delayed by 15 minutes" label, and the result is delayed with the moves.
Fair-play signals from the client
Real anti-cheat compares a player's moves with engine choices across many games, on the server, after the fact, and is out of scope here. The client can add weak signals: lichess's client flags a move when its window regained focus since the previous move (as when a player switches to an engine tab), and move times measured on the device are another. These are hints, never proof (people switch tabs to answer a message), so the client sends them quietly, never acts on them, and contains no detection logic a cheater could read.
Check your understanding · Exercise 8
Which statements about spectators are true? Select all that apply.
Architecture, data model, and interface
The architecture puts each piece where its decision says it belongs: rules and clocks on the server, a prediction of both on the client, and a socket layer that hides drops and duplicates from the UI. The design diagram shows the components; the table explains each boundary.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Game page | Route /game/:id; loads the snapshot, picks player or spectator mode, lays out the parts | The only part that knows about routing and the first HTTP load |
| Board | Renders pieces and highlights, takes drag and click input, animates, plays premoves | Pure view of a FEN plus selections; knows nothing about sockets |
| Clocks | Draws both clocks every frame from the last server snapshot; low-time warning | Display only, so a throttled tab can never change the real time |
| Move list and action bar | SAN moves by ply, review earlier positions; resign, draw, abort, rematch, claim | Read-mostly views of the store; actions go out as messages |
| Game store | Confirmed state (moves, FEN, ply), pending move, premove queue, clock snapshot, status | One place that applies the ply rules, so every view agrees |
| Rules engine (chess.js) | Legal targets, move prediction, SAN, check and end detection for display | Pure functions; the same answers as the server for normal chess |
| Game socket | Connection state machine, ping, reconnect with last ply, resend of unacknowledged moves | Hides the network's mess; hands the store clean, ordered events |
| Announcer and sound | Speaks moves in words through a live region; plays move and capture sounds | Side effects kept out of the views |
| Game server (authoritative) | Validates moves, charges clocks with lag compensation, detects endings, owns the claim timer | The only trusted judge; one owner per game |
| Fan-out tier | Delivers moves to spectators, with optional delay | Protects the game server from viewer spikes |
The second diagram follows the data. On first load, the page fetches a snapshot over HTTP (moves, clocks, players, status), which renders the board even before the socket opens. Then the socket carries everything live: your moves up with their ply and thinking time, everyone's moves down with the authoritative ply, SAN, FEN, and clocks. The store is the only thing that touches both directions.
uci + ply + think. The first load is one HTTP snapshot, then the socket takes over from that ply.Check your understanding · Exercise 9
Where should each responsibility live? Select all that apply.
The data model on the client
The client's state has one rule: everything confirmed comes from the server, keyed by ply; everything local is a marked guess or a view setting. The game store holds it all in memory. Only user preferences (piece set, sounds, premoves on or off, board flip default) go to localStorage and the account.
type Color = "white" | "black";
type Status = "started" | "mate" | "resign" | "stalemate" | "timeout" | "draw" | "aborted";
type GameState = {
id: string;
initialFen: string; // usually the standard start position
moves: { uci: string; san: string }[]; // index i is ply i + 1
ply: number; // confirmed ply === moves.length
fen: string; // cached: position after the last confirmed move
clocks: { white: number; black: number; running: Color | null; at: number };
timeControl: { initialMs: number; incrementMs: number };
status: Status;
winner?: Color;
players: Record<Color, { name: string; rating: number; online: boolean }>;
claimableAt?: number; // server time when "claim victory" unlocks
};
type LocalState = {
me: Color | "spectator";
pending: { ply: number; uci: string; sentAt: number } | null;
premoves: string[]; // UCI, played in order
viewPly: number; // which position the board shows (review mode)
connection: "connecting" | "open" | "reconnecting" | "offline";
};
Note viewPly. Players often step back through the move list during a game. The board then shows an older position, but the game goes on: a new opponent move updates ply and should jump the view back to the live position (or at least flash the move list). Keeping the viewed position separate from the confirmed one makes this a one-line rule instead of a bug.
Messages
One HTTP call loads the page and one WebSocket carries the game. The choosing a real-time technique lesson explains why a two-way, low-latency stream like this wants a WebSocket rather than server-sent events plus POST requests: moves go both ways several times a second in bullet, and one ordered connection keeps the ply logic simple.
GET /api/game/abc123 -> { "id": "abc123", "initialFen": "startpos", "moves": ["e2e4", "c7c5"],
"clocks": { "white": 58210, "black": 59130, "running": "white" }, "status": "started", "players": { ... } }
// client -> server
{ "t": "move", "ply": 3, "u": "g1f3", "think": 820, "blur": false }
{ "t": "outoftime" }
{ "t": "draw-offer" } { "t": "resign" } { "t": "claim-victory" } { "t": "claim-draw" }
{ "t": "ping", "id": 41 }
// server -> client (players and spectators)
{ "t": "move", "ply": 3, "u": "g1f3", "san": "Nf3", "fen": "rnbqkbnr/pp1ppppp/8/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2",
"clocks": { "white": 57610, "black": 59130, "running": "black" } }
{ "t": "reject", "ply": 3, "reason": "not-your-turn" }
{ "t": "end", "status": "timeout", "winner": "black", "clocks": { "white": 0, "black": 12040, "running": null } }
{ "t": "gone", "color": "black", "claimableAt": 1792680000000 }
{ "t": "pong", "id": 41 }
- Every message that changes the game carries
plyand both clocks, so any one message is enough to bring a client up to date. - The FEN in each move message lets the client verify its own replay cheaply: if its predicted FEN differs, it trusts the server's and logs the mismatch for the team.
- Errors are typed (
not-your-turn,illegal,game-over,stale-ply) so the client can choose between a rollback, a resync, and a message to the player. - The Board component's interface is small: props
fen,orientation,lastMove,check,legalTargets(square),premovable,interactive, and eventsonMove(uci)andonPremove(uci). It knows nothing about sockets or clocks.
Check your understanding · Exercise 10
The player is reviewing ply 12 in the move list while the live game is at ply 30. The opponent plays ply 31. What should happen? Pick one.
Accessibility and performance
A grid of pictures moved by dragging does not have to be inaccessible. Lichess has offered a blind mode for years, an accessibility setting that replaces the visual board with a text interface built for screen readers: you type moves into a text field, walk the board square by square with the keyboard, read the pieces and the clocks with short commands, and hear new moves through a live region.
Announcing moves in words
A screen reader needs to hear the opponent's move the moment it arrives, in words: "Knight to f3, check" rather than "N f 3 plus". The game page keeps one visually hidden aria-live="polite" region and writes the spoken form of each opponent move into it. Your own moves are announced only when they are rejected or give check, and clock warnings are spoken at a few thresholds (for example 30 and 10 seconds), never every second, or the reader would talk over everything. The screen readers lesson covers live regions in depth.
const PIECES: Record<string, string> = { K: "King", Q: "Queen", R: "Rook", B: "Bishop", N: "Knight" };
export function speakSan(san: string): string {
if (san.startsWith("O-O-O")) return "Castles queen side" + suffix(san);
if (san.startsWith("O-O")) return "Castles king side" + suffix(san);
const m = san.match(/^([KQRBN])?([a-h]?[1-8]?)(x)?([a-h][1-8])(=([QRBN]))?/);
if (!m) return san;
const piece = m[1] ? PIECES[m[1]] : "Pawn";
const verb = m[3] ? " takes " : " to ";
const promo = m[6] ? `, promotes to ${PIECES[m[6]]}` : "";
return piece + verb + m[4] + promo + suffix(san);
}
function suffix(san: string): string {
return san.endsWith("#") ? ", checkmate" : san.endsWith("+") ? ", check" : "";
}
Playing by keyboard
- Typed moves. A text field that accepts SAN (
Nf3) or squares (g1f3) is the fastest keyboard input, and many sighted players use it too. Lichess offers it as a setting. The input reuses the rules engine to find the unique legal move that matches, or reports "ambiguous" or "illegal". - Arrow keys on the board. The board can be a grid where one square holds focus at a time (a roving
tabindex, covered in the keyboard navigation lesson). Arrow keys move focus, Enter or Space picks up the piece, a second Enter drops it. Each square has an accessible name such as "e4, white pawn" or "d5, empty". - Not color alone. Last-move and check highlights use a shape or outline as well as color, and must meet contrast against both light and dark squares.
Performance
The first budget is load: the board within about a second on 4G. The server renders the first position into the HTML while the JavaScript (board, rules engine, socket, about 100 to 150 KB compressed, an estimate) loads in parallel. The second is INP (Interaction to Next Paint, the Core Web Vital for responsiveness, good at 200 ms or less at the 75th percentile). A move should paint in one frame, so the drop handler only predicts, updates the store, moves one element, and sends; analysis panels and chat wait for idle time.
The clocks deserve care too. A requestAnimationFrame loop runs 60 or more times a second, but the text only changes every 100 ms under 10 seconds and every second above. Write to the DOM only when the formatted string changes. When the tab is hidden, the loop stops by itself and the next visible frame recomputes from the snapshot, as the clock chapter showed.
Check your understanding · Exercise 11
Which choices make the game playable with a screen reader? Select all that apply.
Running it in the interview
Forty-five minutes is enough for this design if you spend it on the parts that are special to chess. A plan that works:
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope: time controls, spectators, devices, disconnects | "Bullet drives the design, so I will design for 1+0 and everything slower follows." |
| 5 to 10 | Game state and authority | FEN plus move list; ply as the version; server validates, client predicts |
| 10 to 20 | The move flow | The sequence diagram with pending, acknowledgement, rejection, and the three ply rules |
| 20 to 30 | Clocks | Server owns time; client draws from a snapshot; lag measured and refunded from a bounded quota; increment |
| 30 to 38 | Premoves and reconnect | Premove as client-only state; reconnect with last ply; claim-victory timer from the server |
| 38 to 45 | Board, spectators, accessibility | DOM with transforms; separate fan-out with optional server delay; live region with spoken SAN |
Draw the architecture early but small, then spend your drawing time on the move sequence and the clock timeline, because those two pictures carry the ideas that most candidates miss. Say numbers out loud: "In 1+0 a player has about 1.5 seconds per move, so a 250 ms round trip uncompensated is a sixth of their clock over a game."
Follow-up questions to expect
Think first
Can a player cheat lag compensation by claiming they thought for 0 ms on every move?
Only a little. The refund per move is capped by a quota that refills by a fixed gain per move (about 0.4 seconds in 1+0 in lichess's scheme, and less for a player whose measured round trip is short), so the most a cheater can gain is about that much per move.
The server also records the claimed lag. Someone whose lag is always exactly the maximum looks very different from a real 4G player whose lag varies, and that statistic can feed fair-play review.
Think first
The game server holding a live bullet game restarts. What do the players see, and what should happen?
The sockets drop and the clients show "Reconnecting" and keep drawing the clocks. They reconnect to the new owner of the game with their last ply.
If the game state was persisted after every move, the new server resumes it. Time lost during the restart is not the players' fault, so a fair policy is to refund it, or to abort games where someone would otherwise lose on time. Lichess aborts games lost on time because of a restart.
Think first
Product wants Chess960 and other variants next quarter. What changes in the frontend?
The starting position is no longer fixed, Chess960 castling differs, and some variants change the rules entirely, so the client rules engine must support them or be replaced.
Because the state keeps initialFen and the client engine only predicts, a variant it cannot predict still works: the board waits for the server instead. That is a graceful fallback, not a rewrite.
Summary
- The server is authoritative for the position, the move order, and the clocks. The client runs the same rules only to predict, so moves feel instant.
- The game is the starting position plus an ordered move list. The ply numbers every version: ignore old plies, apply the next one, resync on a gap.
- Your move is drawn at once and marked pending; the server's echo confirms it and a rejection rolls back. Resends carry the same ply, so they are idempotent.
- Clocks run on the server. The client draws them from the last server snapshot and
performance.now(), never by counting ticks. - Lag compensation refunds measured network delay from a bounded per-player quota: fair to slow connections, and cheating gains at most the quota.
- Premoves are client-only state, re-checked against the new position the moment the opponent's move arrives, and sent with near-zero thinking time.
- On reconnect, send the game id and last ply, resend the pending move, keep the clock visible; the opponent's claim-victory timer is server state.
- A DOM board with CSS transforms is fast enough for 32 pieces and keeps accessibility; announce moves in words through a live region and accept typed moves.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Lichess FAQ (time controls, repetition, losing on time) How lichess classifies bullet to classical and applies FIDE draw rules.
- scalachess LagTracker source The lag compensation quota: gain, starting quota, and cap.
- Is Lichess lagging? (lichess) Lichess's own explanation of server latency, network lag, and lag compensation.
- lila RoundSocket source How long lichess waits before the opponent can claim victory, by game speed.
- chessground, the lichess board library DOM board with CSS transforms, drag and click moves, a single premove.
- chess.js documentation Move generation, validation, FEN, PGN, and end-of-game checks in TypeScript.
- FIDE Laws of Chess Draw claims, fivefold and 75-move rules, increment and delay modes.
- What are pre-moves and how do they work? (Chess.com Help) Up to five premoves, how to cancel them, and their 0.1 s clock cost.
- How does game abandonment work? (Chess.com Help) The disconnect timer: a tenth of base time plus 40 increments, 30 seconds to 3 minutes.
- Page Visibility API (MDN) Hidden tabs and timer throttling: why clocks are computed from timestamps.
- ARIA live regions (MDN) Announcing opponent moves to screen readers.
- Interaction to Next Paint (web.dev) The responsiveness metric a move handler must keep low.
Concepts behind it
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Choosing a real-time techniqueHow to pick between short polling, long polling, server-sent events, WebSockets, and WebRTC for each feature, starting from who talks, how often, and how fresh the data must be, then checking scale, proxies, battery, and the fallback you will ship.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- Layout, paint, and compositingWhat the browser redoes when something on the page changes after the first render, why moving a box with
transformis cheap while changing itswidthis not, and how to keep scrolling and animation inside a 16 ms frame. - Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.