Tic-tac-toe
A complete first design, from requirements to entities to code to extensions.
What you will learn
- 01How to attack a low-level design problem
- 02Step 1: requirements
- 03Step 2: entities
- 04Step 3: responsibilities
- 05Step 4: detecting a win efficiently
- 06Step 5: the code
- 07Step 6: tests worth writing
- 08Step 7: extending the design
- 09Common mistakes
How to attack a low-level design problem
Interview or real, the approach is the same sequence, and it follows everything you have learned.
- Clarify the requirements and the edge cases.
- Identify the entities, using nouns and verbs.
- Assign responsibilities with expert, cohesion and coupling in mind.
- Define the interfaces and the relationships.
- Write the core code and show one flow.
- Handle errors and invalid input.
- Extend: show how the design absorbs a likely change.
Tic-tac-toe is small, so the process is easy to see. Do not skip steps because the game is simple. The habit is the point.
Step 1: requirements
Ask before building. Our answers:
- Two players, X and O, alternate turns, X first.
- A 3 by 3 board, though we will keep the size configurable.
- A move names a row and a column on an empty cell.
- The game ends when someone fills a full row, column or diagonal, or the board is full (a draw).
- Invalid moves are rejected with a clear error and do not change the game.
- Out of scope: networking, AI, a user interface.
Step 2: entities
Read the sentences for nouns: player, board, cell, mark, move, game, result. Keep what carries data or rules and drop what is only a value.
- Mark: a value, X or O.
- Board: owns the cells and knows whether a move wins.
- Game: owns whose turn it is and the overall status, and enforces the rules.
- Status: playing, won or draw.
A Cell class is unnecessary, since a cell is just a mark or empty. A Player is only a mark here, which is worth keeping as a value until players gain behavior.
Swipe sideways to pan · drag a box to move it
Swipe sideways to pan · drag a box to move it
Step 3: responsibilities
- By Information Expert, the board knows the cells, so the board places a mark and detects a win.
- The Game coordinates turns and status. It does not look inside cells.
- Win detection is its own concern, and it is where the interesting thinking lives.
Swipe sideways to pan · drag a box to move it
Step 4: detecting a win efficiently
The obvious method scans every row, column and diagonal after each move. For an n by n board that is about n squared work per move. There is a better idea. A win needs a full line of one mark, so keep a running count per line instead: one counter per row, one per column, and two for the diagonals. Add 1 for X and subtract 1 for O. A line is won when its counter reaches n or minus n.
Each move now updates at most four counters, so win detection is O(1) per move, at the cost of O(n) extra memory. This is a general lesson: store a small summary that is cheap to update instead of recomputing from scratch.
Step 5: the code
type Mark = "X" | "O";
type Status = "playing" | "won" | "draw";
class Board {
private cells: (Mark | null)[][];
private rows: number[]; private cols: number[];
private diag = 0; private anti = 0; private filled = 0;
constructor(readonly n: number) {
this.cells = Array.from({ length: n }, () => Array<Mark | null>(n).fill(null));
this.rows = Array(n).fill(0); this.cols = Array(n).fill(0);
}
/** Places a mark and returns true if it completes a line. */
place(r: number, c: number, mark: Mark): boolean {
if (r < 0 || c < 0 || r >= this.n || c >= this.n) throw new Error("out of range");
if (this.cells[r][c]) throw new Error("cell taken");
this.cells[r][c] = mark; this.filled++;
const d = mark === "X" ? 1 : -1;
this.rows[r] += d; this.cols[c] += d;
if (r === c) this.diag += d;
if (r + c === this.n - 1) this.anti += d;
return [this.rows[r], this.cols[c], this.diag, this.anti].some((v) => Math.abs(v) === this.n);
}
get full() { return this.filled === this.n * this.n; }
}
class Game {
status: Status = "playing";
winner?: Mark;
private turn = 0;
constructor(private board: Board, private order: Mark[] = ["X", "O"]) {}
move(r: number, c: number) {
if (this.status !== "playing") throw new Error("game over");
const mark = this.order[this.turn % 2];
if (this.board.place(r, c, mark)) { this.status = "won"; this.winner = mark; }
else if (this.board.full) this.status = "draw";
else this.turn++;
}
}
An invalid move throws before any state changes, so the game stays consistent. The Game has a tiny state machine, three states with playing as the only one that accepts moves.
Swipe sideways to pan · drag a box to move it
Step 6: tests worth writing
- A row, a column and both diagonals each produce a win.
- A full board with no line is a draw.
- Moving on a taken cell or outside the board throws and changes nothing.
- A move after the game ends throws.
- The last move that fills the board and also wins is a win, not a draw. This is the classic bug, and the code checks the win first.
Step 7: extending the design
A good design absorbs change with a small edit.
- Bigger board or longer line:
nis already a parameter. Winning on k in a row needs a different rule, so extract win detection behind an interface (strategy). - Undo: store each move as a command with an inverse, and keep a stack.
- More than two players: counters per player instead of plus and minus one.
- A computer opponent: a strategy that picks the next cell, using only the board's public view.
- A screen: an observer receiving events such as
MoveMadeandGameEnded, so the game never imports UI code.
Common mistakes
- Putting rules in the user interface, so they cannot be tested.
- Using a global board or a singleton game.
- Re-scanning the board on every move without noticing the cost.
- Forgetting the draw and win ordering on the last move.
- Over-designing: a
Cellclass, aPlayerhierarchy and a factory for a game this small.