cf.completefrontend
My progress
SYSTEM DESIGN · #136

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.

QuestionAnswer we assumeWhat it changes
Which time controls?Bullet (1+0, 2+1) up to classical (30+20). Written as minutes + increment in secondsClocks must be fair to within tens of milliseconds
Who plays?Two humans, matched by the server or by a challenge linkNo 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 gameA read-only spectator stream that fans out cheaply
Which devices?Desktop browsers and phones (touch), on Wi-Fi and 4GDrag 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 rejoinResume by game id; a claim-win option for the opponent
Premoves?Yes: they decide many bullet gamesA queued move that the client plays the moment it is its turn
Correspondence chess (days per move)?Out of scopeIt is a different product: notifications, not live sockets
Anti-cheat with engines?Out of scope, done on the server after the gameThe 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.

Wireframe of the game page. On the left, the 8 by 8 board with the last move's two squares highlighted (Black's pawn from c5 to d4); you move by dragging or by clicking. On the right, from top to bottom: the opponent's bar with name and rating 2215, the opponent's clock at 0:41, the move list (1. e4 c5, 2. Nf3 d6, 3. d4 cxd4), your clock at 0:38 and running, your bar with rating 2190, and the actions resign, draw, abort. A banner area under the board shows reconnecting or opponent-left messages.lichess.org/abc123board: drag or click to movebanner: reconnectingopponent, 22150:41their clock1. e4 c52. Nf3 d63. d4 cxd4 0:38your clockyou, 2190resign, draw, abort
Everything the player needs in one glance: the board, both clocks next to the players they belong to, and the move list between them. Your clock and bar sit at the bottom, nearest your side of the board.
  • 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?

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.
The FEN after 1. e4 c5 2. Nf3, split into its six fields. Field 1, piece placement: rnbqkbnr/pp1ppppp/8/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R, rank 8 down to rank 1, digits count empty squares. Field 2, side to move: b for Black. Field 3, castling rights: KQkq, all four still allowed. Field 4, en passant target: none. Field 5, halfmove clock: 1, moves since the last capture or pawn move. Field 6, move number: 2. Below, the same game as a move list in UCI: e2e4, c7c5, g1f3, which is ply 3.1rnbqkbnr/.../RNBQKB1R2b3KQkq4-51621 pieces, rank 8 to rank 1; digits are empty squares2 side to move: w or b3 castling rights still allowed (not visible on the board)4 en passant target square, or -5 halfmove clock, for the 50-move rule6 full move numberhistory, as UCI: e2e4 c7c5 g1f3 = ply 3
The FEN is a snapshot; the move list is the history. Castling rights (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?

Client only

The browser checks moves with a rules library and sends the result.

The server relays moves to the opponent without checking them.

Pros

  • Simplest server, no chess code on it.
  • Instant feedback.

Cons

  • A modified client can send any move: a king that jumps three squares, a second move in a row.
  • The two clients can disagree about the result, and nobody can settle it.

Server only

The client sends whatever the user dragged.

The server checks it and replies legal or illegal; the board waits for the answer.

Pros

  • One source of truth, impossible to cheat on the rules.
  • No rules code in the bundle.

Cons

  • Every move costs a full round trip before it shows: 100 to 300 ms on mobile, an eternity in bullet.
  • No legal-move dots or instant rejection of illegal drops, unless the server also sends the legal moves with every update.

Both: client predicts, server decides

The client runs the same rules to show legal targets and to apply your move at once.

The server re-checks every move against its own copy of the game and is the only one that can say what happened.

Pros

  • Instant feel and full safety.
  • The client can reject obvious mistakes without a round trip.

Cons

  • Two implementations of the rules must agree (or share one engine).
  • The client needs a rollback path for the rare case where the server disagrees.

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:

ts
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 };
  }
}
Predict a move locally. The server still has the final word.

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.

EndingDetected byAutomatic?What the client does
Checkmate, stalemateRules engine after a moveYesShows the result only when the server's move reply says so
Insufficient materialRules engine (for example king against king and bishop)YesSame: waits for the server's result
Threefold repetition, 50-move ruleRules engine with historyNo, a claimShows a claim button; sends a claim message
Fivefold repetition, 75-move ruleServerYesShows the result from the server
Time out (flag)Server clockYesShows the result; it is a draw if the other side cannot possibly mate
Resign, draw agreed, abortPlayer actionYesSends 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?

Wait for the server

Send the move, show nothing until the server's reply arrives, then draw it.

Pros

  • The board never shows anything the server has not accepted.
  • No rollback code.

Cons

  • Every move lags by a round trip (100 to 300 ms on mobile).
  • The piece snaps back to its square while waiting, which feels broken.

Optimistic, fire and forget

Draw the move at once and send it.

Assume the server agreed; apply whatever moves arrive from the server on top.

Pros

  • Instant feel.
  • Very little code.

Cons

  • If the move is rejected or lost, the boards silently disagree.
  • A duplicated or late echo of your own move gets applied twice.

Optimistic with a pending move and ply numbers

Draw the move at once and mark it pending.

Send it with the ply it creates. The server replies with the authoritative move, ply, FEN, and clocks.

On acknowledgement, clear pending. On rejection, roll back to the last confirmed state. Ignore any message for a ply already applied.

Pros

  • Instant feel and an exact agreement with the server.
  • Duplicates, late echoes, and resends are harmless.

Cons

  • A small state machine and a rollback path to build and test.

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.

Sequence of one move. The player drops a knight on f3; the board draws it at once and the store marks it pending. The store sends it through the socket as ply 3. The socket sends move ply 3 with a thinking time to the server. The server validates it, charges the clock, and sends ply 3 with both clocks to the opponent and back to the player's socket. The socket hands the acknowledgement to the store, which clears pending. Failure path: the server instead replies reject ply 3, and the socket tells the store and board to roll back to ply 2, so the knight slides back to g1.BoardStoreSocketServerOpponentpendingsend ply 3ply 3, thinkply 3, clocksply 3, clocksconfirmif rejected:reject ply 3roll back
The board never waits for the network. The server's echo of ply 3 is both the opponent's update and your acknowledgement; a rejection rolls back to the last confirmed ply.

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:

  1. ply less than or equal to the confirmed ply: already applied. Ignore it. This makes every server message idempotent: applying it twice does nothing.
  2. 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.
  3. ply greater than confirmed + 1: you missed something. Do not guess. Ask the server for the full state (a resync) and replace yours.
ts
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
}
Applying a server move. The ply decides everything; the pending move is just a guess about the next ply.

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.

Move lab. Server at ply 0. Client confirmed ply 0, pending none, premoves none. In flight: nothing. Recent: Your move: you are White.server plyyour plypendingpremoves00--in flight, oldest firstYour move: you are White.
Messages are delivered one at a time, so you can break things on purpose. One WebSocket keeps order, but resends and reconnects do not, which is why every message carries its ply. Whatever you do, the client ends on the server's ply.

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?

Each client, trusted

The client times its own thinking and reports its remaining time with each move.

Pros

  • Network delay never costs the player anything.
  • Simple server.

Cons

  • Trivial to cheat: report a bigger number or pause the timer.
  • The two sides disagree about the opponent's clock and about who flagged.

Server, with no allowance

The server starts a side's clock when it sends the opponent's move and stops it when the reply arrives.

Whatever time passed on the server is charged.

Pros

  • One truth, impossible to fake.
  • Easy to explain.

Cons

  • A player with a 250 ms round trip to the server pays it on every move: about 10 seconds over a 40-move game. In 1+0 that is a sixth of the whole clock.

Server, with bounded lag compensation

The server measures the time, as above, then refunds the part of it that was network lag, up to a quota per player that refills a little with each move.

The client reports how long the player actually thought, measured on the device, so the server can estimate the lag.

Pros

  • One truth, and a slow connection is not a handicap.
  • A cheater who fakes lag can gain at most the quota, which is small and bounded.

Cons

  • More code and tuning on the server.
  • A huge lag spike is only partly refunded once the quota is spent.

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.

Timeline of one move by Black in a 1+0 game, 120 ms latency each way. At 0 ms the server sends White's move and starts Black's clock. It reaches Black's browser at 120 ms. Black thinks for 800 ms and moves at 920 ms. The move reaches the server at 1,040 ms. The server measured 1,040 ms, Black thought 800 ms, so 240 ms was lag. The server refunds it from Black's quota: 1.17 s minus 0.24 s plus the 0.39 s gain leaves 1.32 s.server: Black's clock runs 1,040 ms01,040White's move, 120 msBlack's reply, 120 msBlack's browser: thinks 800 mslag = 1,040 - 800 = 240 ms, refundedquota: 1.17 s - 0.24 s + 0.39 s gain = 1.32 s
The server's clock runs for the whole bar on top; Black only thinks during the bar below. The difference is two trips across the network, and that is what lag compensation refunds.

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.

ts
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")}`;
}
The display is a pure function of the last server snapshot and the current time. The countdown timer guide covers this pattern, and estimating a server clock offset, in depth.

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.

Clock lab, 1+0, Server clock, no allowance. White: thought 51.7 s, network 1.6 s, refunded 0.0 s, 6.7 s left. Black: thought 44.2 s, network 17.1 s, refunded 0.0 s, flagged on move 34.thoughtnetworkrefundedresultWhite51.7 s1.6 s0.0 s6.7 s leftBlack44.2 s17.1 s0.0 sflag, move 34charged = thought + network - refund
Both players think for exactly the same total time; White sits 20 ms from the server. Only the network and the clock rule differ. Red means that player lost on time.

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.

Premove state machine on the client. From opponent's turn, dragging a piece goes to premove set. When the opponent moves, premove set goes to checking. If the premove is legal in the new position, checking goes to sent, pending, with a thinking time of 0, and the turn passes back to the opponent. If it is illegal, checking goes to dropped, the queue is cleared, and it is your turn with no premove. From your turn, making a move goes to opponent's turn. A right-click or a click on an empty square cancels a premove.right-click or empty click: canceldragopponent movesopponent's turnwaitingpremove sete.g. d1d8checkinglegal now?your turnthink, movesent, pendingthink = 0droppedqueue clearedlegalillegalturn passesyou moveyour turn, no premove
A premove never reaches the server until it is checked against the real position. Cancelling (right-click or an empty click) just returns to waiting.
BehaviourLichessChess.com
How many premovesOne; its board library, chessground, stores a single from and to squareUp to five, played in the order you made them
CancelRight-click the board, or click a square that is not a moveRight-click, or tap the premoved piece
When it playsRight after the opponent's move, if still legal; otherwise it is droppedThe same: cancelled if the opponent's move makes it illegal
Clock costThe client reports 0 ms of thinking timeA 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.

ts
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);
}
Playing queued premoves after the opponent's move. Each one must be legal in the position at that moment.

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.

Connection state machine. connecting goes to open when the snapshot loads. open goes to reconnecting on a missed pong, a close, or the offline event. reconnecting retries at 0, 0.5, 1, and 2 seconds with jitter; when a socket opens it goes to syncing, which sends the game id and last ply. syncing goes to open when caught up, or to ended if the game finished meanwhile. reconnecting goes to offline on the offline event, and offline goes back to reconnecting on the online event or when the tab becomes visible.snapshotcaught upconnectingopenmoves flowsyncingsend last plymissed pongofflinewait for onlinereconnecting0, 0.5, 1, 2 sendedshow resultsocket openoverofflineonline, or tab visible
The clock keeps running on the server through every state. syncing is where the last ply and the pending move are sent, so the client lands on exactly the server's game.
  • Notice fast. The client pings every few seconds and treats a missed pong as a drop, rather than waiting for the browser's close event, which can take tens of seconds on a dead mobile link. It also reconnects at once on the online event 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.
Reconnect sequence. The player's move for ply 42 is pending when the connection drops; the socket reports the drop to the store. The server tells the opponent the player is gone, with a claimableAt deadline. The socket reconnects and sends resume game abc at ply 41. The server replies that the game is at ply 41, with both clocks, which kept running. The socket resends the pending move as ply 42. The server accepts it and sends ply 42 with clocks to the player and to the opponent. The store clears pending.StoreSocketServerOpponentdrop: no ponggone + deadlineresume, ply 41at ply 41resend ply 42ack ply 42ply 42clear pending
The resend carries the same ply, so it cannot be applied twice. If the first copy had arrived before the drop, the server would have answered with ply 42 in the resume reply, and the store would have confirmed it without a resend.

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?

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?

DOM elements moved with CSS transforms

One element for the board background, one per piece (at most 32), and a few for highlights and dots.

Each piece is placed with transform: translate(x, y) from its square and marked will-change: transform, so it gets its own compositor layer; a move changes the transform.

Pros

  • Transforms are handled by the compositor, so moving a piece does not trigger layout.
  • Hit-testing, focus, and accessibility come from the DOM for free.
  • Piece sets are just CSS background images.

Cons

  • Needs care to avoid re-rendering 64 squares on every pointer move.

SVG board

Squares as rect, pieces as use or image elements inside one scalable SVG.

Pros

  • Scales perfectly at any size, crisp arrows and circles.
  • Still in the DOM, so events and accessibility work.

Cons

  • Changing attributes on SVG elements is generally slower than composited transforms on HTML elements.
  • Dragging a piece over everything needs z-order tricks inside the SVG.

Canvas

Draw the board and pieces into a canvas each frame; map pointer coordinates to squares yourself.

Pros

  • Fast for thousands of objects; full control over drawing.

Cons

  • Thirty-two pieces are not thousands of objects, so the speed buys nothing.
  • No DOM: hit-testing, focus, screen reader output, and text all have to be rebuilt by hand.

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.

The board's layers from top to bottom: the drag ghost and promotion picker, only while dragging; an SVG layer for arrows and circles; the pieces, each placed with a CSS transform; highlights for the last move, check, and legal-move dots; and the squares, one background image. During a drag only the dragged piece's transform changes, which the compositor handles without layout or paint.topdrag ghost, promotiononly while draggingSVG arrows, circlesdrawn by right-dragpiecestransform: translate(x, y)highlights, dotslast move, check, targetssquaresone background imagebottompointermove changesone piece's transform:no layout, no paint
Five thin layers, at most 32 pieces. A move or a drag changes a 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 set touch-action: none on the board so a drag does not scroll the page on phones.
  • On pointerdown on 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 requestAnimationFrame callback. Never re-render the board on pointermove.
  • 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.

ts
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)`;
}
Square to pixel position, for either orientation. Every piece is placed with a transform from this.

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.

Spectator fan-out. White and Black each talk to the game server, which owns rules and clocks. The game server publishes each move to a per-game pub/sub topic. Several fan-out servers subscribe and push the move to up to 50,000 read-only spectators. An optional delay, from 0 seconds to 15 minutes, is applied before publishing to spectators; the players' channel is never delayed.WhiteBlackGame serverrules, clocksmoves both wayspub/subper gamefan-out 1fan-out 2fan-out N50,000 viewersoptional delay before publishing to spectators;the players' channel is never delayed
Players and spectators never share a process. A viewer spike lands on the fan-out tier, and any broadcast delay happens on the server before the data leaves it.
  • 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?

Delay on the client

Send moves live and have the spectator page wait before showing them.

Pros

  • No server work.

Cons

  • Useless: the moves are already in the browser and readable from DevTools or the socket.

Delay by time on the server

The spectator channel publishes each move N seconds after it happened, with the clocks as they were then.

Pros

  • Real protection: the data is not sent until it is safe.
  • Simple for viewers to understand ("15 minute delay").

Cons

  • In a fast game the delayed feed can lag the result by a long way; the end of the game must also be delayed, or it spoils the result.

Delay by moves on the server

The spectator channel always shows the position N plies behind the live game.

Pros

  • Hides the critical current position whatever the speed.

Cons

  • The delay in time varies a lot, and clock displays become confusing.

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.

Architecture. In the browser: the game page contains the board, clocks, and move list. The board sends onMove to the game store. The store holds ply, pending move, premoves, and the clock snapshot, and feeds the clocks and move list. The store asks the rules engine, chess.js, to predict moves. The store sends moves through the game socket, which handles acknowledgements and resends, and it drives the announcer and sound. The game socket talks over a WebSocket to the game server, the authority, which writes to the game database and publishes to the fan-out tier for spectators.BrowserGame pageBoarddrag, clickClocksrAF displayMove listSAN, reviewGame storeply, pendingchess.jspredict, SANonMoveAnnouncer, soundlive regionGame socketack, resend, pingServerGame DBGame serverauthorityFan-outspectatorsWS
Views never touch the network, and the socket never touches the board. The store sits between them and is the only place that applies the ply rules.
PartResponsibilityWhy the boundary is here
Game pageRoute /game/:id; loads the snapshot, picks player or spectator mode, lays out the partsThe only part that knows about routing and the first HTTP load
BoardRenders pieces and highlights, takes drag and click input, animates, plays premovesPure view of a FEN plus selections; knows nothing about sockets
ClocksDraws both clocks every frame from the last server snapshot; low-time warningDisplay only, so a throttled tab can never change the real time
Move list and action barSAN moves by ply, review earlier positions; resign, draw, abort, rematch, claimRead-mostly views of the store; actions go out as messages
Game storeConfirmed state (moves, FEN, ply), pending move, premove queue, clock snapshot, statusOne place that applies the ply rules, so every view agrees
Rules engine (chess.js)Legal targets, move prediction, SAN, check and end detection for displayPure functions; the same answers as the server for normal chess
Game socketConnection state machine, ping, reconnect with last ply, resend of unacknowledged movesHides the network's mess; hands the store clean, ordered events
Announcer and soundSpeaks moves in words through a live region; plays move and capture soundsSide effects kept out of the views
Game server (authoritative)Validates moves, charges clocks with lag compensation, detects endings, owns the claim timerThe only trusted judge; one owner per game
Fan-out tierDelivers moves to spectators, with optional delayProtects 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.

Data flow. Reading: the game server sends a move with ply and clocks over the WebSocket to the game socket, which passes the event to the game store; the store applies the ply rules and passes state to the board and clocks. Writing: the board calls onMove with a UCI move; the store predicts it, marks it pending, and passes UCI with ply and thinking time to the socket; the socket sends the move message to the server. First load: GET the game snapshot over HTTP into the store, then open the socket from that ply.ply + clockseventstateGame serverauthorityGame socketack, resendGame storeply rulesViewsboard, clocksmove msguci + plyonMove(uci)1. GET /api/game/abc123: snapshot into the store2. WebSocket from that ply: moves, clocks, end3. a drop: store predicts, socket sends uci + ply
Down the top arrows: authoritative moves and clocks. Back along the bottom: your move as 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.

ts
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";
};
Client-side entities. Confirmed state mirrors the server; pending and premoves are local predictions.

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.

json
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 }
Load, then live messages. Short keys keep a move message well under 200 bytes.
  • Every message that changes the game carries ply and 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 events onMove(uci) and onPremove(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.

ts
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" : "";
}
Turn SAN into speech. The live region gets one short sentence per move.

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:

MinutesWhat to doWhat to draw or say
0 to 5Scope: time controls, spectators, devices, disconnects"Bullet drives the design, so I will design for 1+0 and everything slower follows."
5 to 10Game state and authorityFEN plus move list; ply as the version; server validates, client predicts
10 to 20The move flowThe sequence diagram with pending, acknowledgement, rejection, and the three ply rules
20 to 30ClocksServer owns time; client draws from a snapshot; lag measured and refunded from a bounded quota; increment
30 to 38Premoves and reconnectPremove as client-only state; reconnect with last ply; claim-victory timer from the server
38 to 45Board, spectators, accessibilityDOM 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?

Think first

The game server holding a live bullet game restarts. What do the players see, and what should happen?

Think first

Product wants Chess960 and other variants next quarter. What changes in the frontend?

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.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next