LLD problems Tic-tac-toe

Tic-tac-toe

A complete first design, from requirements to entities to code to extensions.

  • 20 min read
  • 9 chapters
  • Lesson 1 of 4

What you will learn

  1. 01How to attack a low-level design problem
  2. 02Step 1: requirements
  3. 03Step 2: entities
  4. 04Step 3: responsibilities
  5. 05Step 4: detecting a win efficiently
  6. 06Step 5: the code
  7. 07Step 6: tests worth writing
  8. 08Step 7: extending the design
  9. 09Common mistakes
01

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.

02

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.
03

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.

moveplacesasksPlayersUSERSX and OGameHLDturns and statusBoardDBcells and countersWin checkCHECKO(1) per move

Swipe sideways to pan · drag a box to move it

100%
The Game owns turns and the overall status.
moveplacesasksPlayersUSERSX and OGameHLDturns and statusBoardDBcells and countersWin checkCHECKO(1) per move

Swipe sideways to pan · drag a box to move it

100%
The Board owns the cells.
04

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.
moveplacesasksPlayersUSERSX and OGameHLDturns and statusBoardDBcells and countersWin checkCHECKO(1) per move

Swipe sideways to pan · drag a box to move it

100%
Win detection keeps a running count per line.
05

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.

06

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.

moveplacesasksPlayersUSERSX and OGameHLDturns and statusBoardDBcells and countersWin checkCHECKO(1) per move

Swipe sideways to pan · drag a box to move it

100%
A bad move is rejected before any state changes.
07

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.
08

Step 7: extending the design

A good design absorbs change with a small edit.

  • Bigger board or longer line: n is 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 MoveMade and GameEnded, so the game never imports UI code.
09

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 Cell class, a Player hierarchy and a factory for a game this small.