Code a tic-tac-toe game
Open in the interactive app: practice, quiz, mock interview →In one minute
- Separate the rules (whose turn it is, which moves are legal, when the game ends) from the data (the grid).
Gameowns the rules andBoardowns the cells. The UI never writes to the board directly. - Detect a win in O(1) per move. Keep one counter per row, one per column and one for each diagonal. Add +1 for X and -1 for O. A counter that reaches +N or -N means that line is complete. You never rescan the board.
- Model the game status as an explicit state (
IN_PROGRESS,X_WON,O_WON,DRAW) and reject every move once the state is no longerIN_PROGRESS. Most bugs in this problem come from moves that slip in after the game has ended. - Validate before you mutate. A move that is out of bounds, lands on an occupied cell, or arrives after the game is over must leave the game exactly as it was, including whose turn it is.
- Size the board with N from the start. The interviewer's follow-up is almost always "now make it N×N" or "now K in a row", and a 3×3 hard-coded win table can't answer it.
Requirements
Functional:
- Two players, X and O, alternate turns on an N×N board. X moves first.
- A move names a row and a column. The game rejects illegal moves with a clear reason.
- After each move the game reports its state: still in progress, won by X, won by O, or drawn.
- A player wins by filling a whole row, column or diagonal. If the board fills up with no winner, the game is a draw.
- Callers can read the board and the current player at any time.
Non-functional:
- Each move costs O(1) time. Memory is O(N²) for the grid and O(N) for the counters.
- The game is deterministic and easy to test without a UI.
- The core is a library: a CLI, a web UI or a bot can drive it.
Out of scope: networking, matchmaking, persistence, an AI opponent and timers. They come up in the Extensibility section, but they don't belong in the core.
Entities and relationships
Start from the nouns in the requirements and keep only the ones that have behaviour or invariants.
- Game: the aggregate root. It holds the board, the players, the turn and the state, and it is the only object that accepts moves. It enforces the order "validate, place, check for a win, check for a draw, switch turns".
- Board: an N×N grid of marks. It knows its bounds and whether a cell is empty. It does not know whose turn it is or what counts as a win.
- Player: a name and a mark. Keeping it separate lets a human, a bot or a remote client stand behind the same interface later.
- WinDetector: records each placed mark and answers "did this move win?". The counter version is one implementation. A K-in-a-row scanner is another.
- Move: an immutable record (row, column, player). The history of moves gives you undo, replay and an audit log almost for free.
A Cell class is a common trap. A cell has no behaviour beyond holding a mark, so a two-dimensional array of marks is clearer and cheaper.
Class diagram
classDiagram
class Game {
-Board board
-Player current
-GameState state
-int movesMade
+move(row, col) GameState
+currentPlayer() Player
+state() GameState
}
class Board {
-int n
+isInside(row, col) bool
+isEmpty(row, col) bool
+place(row, col, mark)
+cell(row, col) Mark
}
class Player {
+String name
+Mark mark
}
class WinDetector {
<<interface>>
+record(row, col, mark) bool
}
class CounterWinDetector {
-int n
-int diag
-int anti
+record(row, col, mark) bool
}
class Move {
+int row
+int col
+Mark mark
}
Game *-- Board
Game o-- Player
Game --> WinDetector
WinDetector <|.. CounterWinDetector
Game *-- Move : historyKey flows
The only interesting flow is a single move. Every check happens before the board changes, so a rejected move has no side effects.
sequenceDiagram
participant UI
participant Game
participant Board
participant Win as WinDetector
UI->>Game: move(row, col)
Game->>Game: state is IN_PROGRESS?
Game->>Board: isInside and isEmpty?
Board-->>Game: ok
Game->>Board: place(row, col, mark)
Game->>Win: record(row, col, mark)
Win-->>Game: won = true or false
Game->>Game: set WON, DRAW, or switch turn
Game-->>UI: new stateThe order inside the last step matters. Check for a win before you check for a draw, because the move that fills the last cell can also complete a line. That game is a win, not a draw.
Design patterns used and why
- Strategy for win detection. The counter detector only handles "fill a whole line". Gomoku-style rules (five in a row on a 15×15 board) need a different detector that scans outward from the last move. Putting it behind
WinDetectorletsGamestay unchanged. - State as an explicit enum, not scattered booleans.
isOver,hasWinnerandwinneras three separate fields can disagree. OneGameStatevalue can't. A full State pattern, with one class per state, is overkill for four states with no per-state behaviour. Say so in the interview, because it shows you know when not to reach for a pattern. - Command for moves. Storing each
Moveas a value object makes undo a matter of popping the last move, clearing its cell and subtracting its delta from the counters. It also gives you replay for debugging and a log for a server to persist. - Observer for the UI (optional). A console renderer, a web socket broadcaster and an analytics hook can each subscribe to "move applied" and "game ended" events without
Gameknowing about any of them. - Factory for players.
PlayerFactory.human("Ana", X)andPlayerFactory.bot(level, O)hide how a player picks a move. This only matters once bots exist.
Concurrency and edge cases
In a local game one thread drives everything, so there is nothing to lock. In a server, two requests for the same game can arrive at once: a double-clicked button, or both players' clients racing. That is the contention shape. The fix is to serialise moves per game. Use a mutex on the Game object in a single process, or optimistic concurrency in a database: each move carries the expected move number, and the server applies it only if the number still matches. The same version-check pattern protects a shopping cart or a wiki page from lost updates.
Edge cases worth naming before you code:
- A move outside the board, including negative indices.
- A move onto an occupied cell. It must not switch the turn.
- Any move after the game has ended.
- The last empty cell completes a line. This is a win, not a draw.
- N = 1: the first move wins.
- A player taking two turns in a row. The game decides whose turn it is, so callers can't pass in a player who is out of turn.
In the wild
- Chess.com, 2023: the chess boom outgrows the database. Traffic nearly doubled from early December 2022. On 20 January 2023 the site had 10 million daily active members, a record 31.7 million games and about 16,000 moves a second on average. During the noon to 4 p.m. ET peak the database could not keep up with the writes, servers were exhausted, and users saw 502 errors and game timeouts. The plan was new hardware, separate tables, sharding, in-memory services and splitting off a gameplay database (Chess.com).
- Lichess, 2022: lag compensation tuned after "lag cheater" complaints. In the fastest time controls, players argued that compensating for network lag rewarded laggers. Lichess grants a per-move quota with a capped bank, now min(1 s, total seconds × 2/5 + 0.15 s), so a 1+0 game earns 0.39 s per move instead of 0.45 s. The server, not the client, owns the clock and the rules (Lichess issue 12097, LagTracker.scala).
Down the stack
Layer 1The contract
move(row, col) either applies exactly one mark and returns the new state, or raises an error with a reason and changes nothing. Once the state is terminal, it never changes again. Each call runs in constant time regardless of N, so a 1000×1000 board is as cheap per move as a 3×3 one.
Layer 2The mechanism
Give X the value +1 and O the value -1. Keep rows[N], cols[N], diag and anti. A move at (r, c) adds its value to rows[r] and cols[c]. It also adds to diag if r == c, and to anti if r + c == N - 1. A line is complete for X exactly when its counter equals N. That can only happen if all N cells hold X, because any O would pull the sum below N. Only the lines through the latest move can have changed, so you check at most four counters. A naive version rescans the row, column and both diagonals, which is O(N) per move. A full-board rescan is O(N²). LeetCode 348 is this exact counter trick.
Layer 3The machine
For N = 3 the whole state fits in a few cache lines: 9 cells, plus 8 small integers, plus a turn and a state. Every operation is a handful of integer adds that finish in nanoseconds. Even for N = 10,000, the counters take 2 × 10,000 × 4 bytes, about 80 KB, which fits in L2 cache. The grid, at 100 million cells, is what grows. A server holding a million concurrent games cares more about per-object overhead than about the algorithm. In CPython a list of lists of one-character strings costs pointers plus object headers. A flat bytearray of N² bytes would cut that by roughly 8×. Go and TypeScript typed arrays give you the flat layout directly.
Why it's built this way
- Players expect instant feedback, so a move must cost O(1), not a board scan.
- O(1) needs incremental state, so you keep counters that change with each move instead of recomputing.
- Incremental state can drift from the grid if a half-applied move is ever left in place, so you validate completely before mutating anything.
- Validation and turn order are rules, not data, so they live in
Game, andBoardstays a dumb container you can reuse for Connect Four or chess.
Extensibility
- K in a row on an N×N board (Gomoku, the m,n,k-game family). Swap the detector. From the last move, walk outward in four directions (horizontal, vertical and the two diagonals) and count consecutive marks. That costs O(K) per move. Line counters no longer work because K < N.
- More than two players. The ±1 trick breaks. Use one set of counters per player, or keep per-line "owner and count" pairs that mark a line dead once a second player touches it.
- Undo and replay. Pop the
Move, clear the cell, subtract the delta, decrement the move count and restore the turn. Reset the state toIN_PROGRESSif the undone move had ended the game. - Bots. A
Playerwith achooseMove(board)method. Minimax works for 3×3, which has 5,478 reachable positions. Larger boards need heuristics or Monte Carlo search. - Networked play. Put
Gamebehind a service. Add a game ID and a move sequence number for optimistic concurrency, and broadcast events through the observer hook.
Variant: snake and ladder board game
A dice-and-board race keeps the same skeleton (a Game that owns the rules, a Board that stores data, an explicit status, validate-then-mutate) but changes three things.
Chance enters the rules. Tic-tac-toe is deterministic: the next state depends only on the move. Here a turn depends on a die roll, so randomness must be injected as a Dice interface that the game asks for the next roll, never a random call inside the rules. Tests and replays pass a scripted sequence. A production server draws from a server-side generator and logs every roll, because Python's general-purpose random module is documented as unsuitable for security purposes, while Go's math/rand/v2 top-level functions use a stronger default source. Reject a roll outside 1 to 6 and leave the game unchanged.
The board is a lookup table, not a counter set. Treat a snake and a ladder as one thing, a jump from a start cell to an end cell. Store dest[0..N] with dest[i] = i and one overwritten entry per jump. A move is pos = dest[pos + roll], still O(1), and snakes and ladders differ only in direction. Validate at build time: jumps inside the board, a snake ending below its start, a ladder above, at most one jump per cell, and no jump ending on another jump's start unless you define chaining and guard against cycles. Nothing like the win-detection Strategy is needed, because the win rule is "reach cell N".
A turn can contain several moves. Typical house rules make a player roll again after a six, forfeit the turn after three sixes in a row (returning to where the turn began, even after a ladder), and land exactly on N to win. Put these behind a RuleSet strategy with hooks such as onOvershoot and grantsExtraRoll, and return one TurnResult listing each roll, jump and final position, so the UI can animate it. Compute the whole turn in locals and commit once, which keeps the atomicity of tic-tac-toe's move.
Players also have positions, not marks, so turn order is a rotating index over two or more players, and there is no draw. Online play adds a turn number to each request to reject stale ones, plus an idempotency key so a retried request returns the stored TurnResult instead of rolling again. With a seeded die the board is also a Markov chain, so a million simulated games can be compared against the analytic expected game length to check a layout.
Leadership lens
- In review, ask where the rules live. If the UI checks whose turn it is, the first bot or API client will cheat by accident.
- Make the error type part of the contract (out of bounds, occupied, game over). Clients render these errors to users, so changing them is a breaking change.
- Insist on a headless core with table-driven tests: win in each direction, a draw, a win on the last cell, illegal moves. These tests document the rules better than a spec does.
- Watch for over-engineering. Six classes with interfaces for a 3×3 game is fine in an interview that tests design. In production code, a reviewer should ask which extension point has a real customer.
Check yourself
Why can't a row counter reach +N unless every cell in that row is X?
Show answer
Each X adds 1 and each O subtracts 1. Empty cells add 0, so the sum reaches N only when all N cells contribute +1.
The last empty cell is filled and that move also completes a diagonal. What should the game report, and what ordering in the code guarantees it?
Show answer
A win. Check for a win before you check whether the board is full.
Why do the line counters fail for "5 in a row on a 15×15 board"?
Show answer
A counter summarises an entire line. A row holding five Xs and some Os, or five Xs that aren't contiguous, gives sums that can't tell you whether five Xs sit next to each other. You have to count consecutive marks outward from the last move.
Two HTTP requests try to make a move in the same game at the same moment. What goes wrong without coordination, and what is a cheap fix?
Show answer
Both can pass validation against the same old state, so one player moves twice or two marks land on one turn. Serialise per game with a lock, or require an expected move number and reject stale requests.
Sources and further study
Primary
- Tic-tac-toe (Wikipedia): rules, the count of reachable positions and why perfect play draws.
- m,n,k-game (Wikipedia): the general family behind the K-in-a-row extension.
Systems docs
- PostgreSQL: transaction isolation: serialization failures and retry, the database form of a per-game move-number check.
- Python random module: the Mersenne Twister and its warning against security use, relevant to the dice variant.
- Go math/rand/v2: seeded generators for reproducible games and a stronger default source.
- Stripe: idempotent requests: making a retried move or turn safe to repeat.
Books
- Design Patterns: Elements of Reusable Object-Oriented Software, Gamma, Helm, Johnson and Vlissides: chapter 5 (Behavioral, including Strategy, Command, Observer and State).
- Head First Design Patterns, Freeman and Robson: chapter 1 (Strategy) and chapter 6 (Command).
Other curricula
- Refactoring Guru: Strategy and Command: how that catalogue frames the two patterns used for win detection and undo.
Watch
- Low-Level Design of Tic Tac Toe (SCALER): an interview-style walk from entities to classes for the game.
- System Design: Tic Tac Toe, Low Level System Design (The Tech Granth): an object-oriented breakdown with players, board and game classes.
- Leetcode 348: Design Tic-Tac-Toe (LC Bear): the O(1) row, column and diagonal counter trick, explained step by step.
Free resources
- Tic-tac-toe (Wikipedia): rules, game-tree size and why perfect play always ends in a draw.
- m,n,k-game (Wikipedia): the general family (N×M boards, K in a row) that the extensibility questions lead to.
- Strategy pattern (Refactoring Guru): the pattern behind a swappable win detector.
- Command pattern (Refactoring Guru): moves as objects, which is how undo and replay come cheap.
- State pattern (Refactoring Guru): when a full State pattern pays off and when an enum is enough.