InterviewKata
Low-level design · Problem P04 of 15 · Warm-up problems

Tic-tac-toe

Small enough to finish in the time, rich enough to show judgement: who owns the rules, how to check for a win without scanning the board, and how to make it work for any board size.

1. The prompt

The prompt"Design a game of tic-tac-toe for two players on an n × n board. Reject invalid moves, and detect a win or a draw."

2. Clarify the requirements

Spend the first five minutes here. Ask, then write the answers down where the interviewer can see them.

AskAssume (unless told otherwise)
Board size?n × n, default 3; a win needs a full row, column or diagonal.
What happens on an invalid move?Rejected with a reason; the same player moves again.
Human or computer players?Humans for now; leave room for a strategy-based bot.

Must do

  • Two players alternate; X starts
  • Reject moves off the board or on a taken cell
  • Detect win or draw; reject moves after the game ends

Must be

  • Win check faster than scanning the board
  • Any n × n board
  • Easy to add a computer player

Out of scope

  • Graphics
  • Networking
  • Undo (a follow-up)

3. The classes

The class diagram: arrows show who uses or holds whom.
ClassIts one job
GameThe rules: whose turn, when it's over, what to return
BoardCells, and one running sum per row, column and diagonal
PlayerX or O, with the value it adds to the sums

4. How it works

Play it. The sums under the board are the whole trick: X adds +1 and O adds −1 to the move's row, column and diagonals. A win is a sum of +n or −n, found in O(1) instead of by scanning.

5. Patterns and why

PatternWhereWhy it's worth it here
Single responsibilityBoard knows cells and sums; Game knows turns and resultsRules can change (e.g. a bot, or a timer) without touching the board
EncapsulationSums are updated only by place()They can never drift out of step with the cells
Strategy (follow-up)A MoveChooser interface for computer playersRandom, minimax or human input, all plugged in the same way
Memory hook: "Plus one, minus one, look for n"Each move adds its player's value to its lines; a line summing to ±n is a win.

6. The code

The board raises errors for bad moves; the game turns them into messages and owns the turn order.

from __future__ import annotations
from enum import Enum
from typing import Optional


class Player(Enum):
    X = 1
    O = -1


class Board:
    """n x n board with an O(1) win check: one running sum per row, column and diagonal."""

    def __init__(self, n: int = 3) -> None:
        self.n = n
        self.cells: list[list[Optional[Player]]] = [[None] * n for _ in range(n)]
        self.rows, self.cols = [0] * n, [0] * n
        self.diag = self.anti = 0
        self.moves = 0

    def place(self, r: int, c: int, p: Player) -> bool:
        """Returns True if this move wins."""
        if not (0 <= r < self.n and 0 <= c < self.n):
            raise ValueError("off the board")
        if self.cells[r][c] is not None:
            raise ValueError("cell taken")
        self.cells[r][c] = p
        self.moves += 1
        v, n = p.value, self.n
        self.rows[r] += v
        self.cols[c] += v
        if r == c:
            self.diag += v
        if r + c == n - 1:
            self.anti += v
        return n in (abs(self.rows[r]), abs(self.cols[c]), abs(self.diag), abs(self.anti))

    def full(self) -> bool:
        return self.moves == self.n * self.n


class Game:
    """Owns the rules: whose turn it is, and when the game is over."""

    def __init__(self, n: int = 3) -> None:
        self.board = Board(n)
        self.turn = Player.X
        self.result: Optional[str] = None

    def play(self, r: int, c: int) -> str:
        if self.result:
            return f"rejected: game over ({self.result})"
        try:
            won = self.board.place(r, c, self.turn)
        except ValueError as e:
            return f"rejected: {e}"
        mover = self.turn.name
        if won:
            self.result = f"{mover} wins"
        elif self.board.full():
            self.result = "draw"
        self.turn = Player.O if self.turn is Player.X else Player.X
        return f"{mover} at ({r},{c})" + (f": {self.result}" if self.result else "")


if __name__ == "__main__":
    g = Game()
    for r, c in [(0, 0), (1, 1), (1, 1), (0, 1), (2, 2), (0, 2), (2, 0)]:
        print(g.play(r, c))
enum Player { X = 1, O = -1 }

/** n x n board with an O(1) win check: one running sum per row, column and diagonal. */
class Board {
  private cells: (Player | null)[][];
  private rows: number[];
  private cols: number[];
  private diag = 0;
  private anti = 0;
  private moves = 0;

  constructor(readonly n = 3) {
    this.cells = Array.from({ length: n }, () => Array<Player | null>(n).fill(null));
    this.rows = Array(n).fill(0);
    this.cols = Array(n).fill(0);
  }

  /** Returns true if this move wins. */
  place(r: number, c: number, p: Player): boolean {
    const n = this.n;
    if (r < 0 || r >= n || c < 0 || c >= n) throw new Error("off the board");
    if (this.cells[r][c] !== null) throw new Error("cell taken");
    this.cells[r][c] = p;
    this.moves++;
    this.rows[r] += p;
    this.cols[c] += p;
    if (r === c) this.diag += p;
    if (r + c === n - 1) this.anti += p;
    return [this.rows[r], this.cols[c], this.diag, this.anti].some((s) => Math.abs(s) === n);
  }

  full(): boolean { return this.moves === this.n * this.n; }
}

/** Owns the rules: whose turn it is, and when the game is over. */
class Game {
  private board: Board;
  private turn = Player.X;
  private result: string | null = null;

  constructor(n = 3) { this.board = new Board(n); }

  play(r: number, c: number): string {
    if (this.result) return `rejected: game over (${this.result})`;
    let won: boolean;
    try {
      won = this.board.place(r, c, this.turn);
    } catch (e) {
      return `rejected: ${(e as Error).message}`;
    }
    const mover = Player[this.turn];
    if (won) this.result = `${mover} wins`;
    else if (this.board.full()) this.result = "draw";
    this.turn = this.turn === Player.X ? Player.O : Player.X;
    return `${mover} at (${r},${c})` + (this.result ? `: ${this.result}` : "");
  }
}

const g = new Game();
for (const [r, c] of [[0, 0], [1, 1], [1, 1], [0, 1], [2, 2], [0, 2], [2, 0]]) console.log(g.play(r, c));
enum Player {
    X(1), O(-1);

    final int value;

    Player(int value) {
        this.value = value;
    }
}

/** n x n board with an O(1) win check: one running sum per row, column and diagonal. */
class Board {
    final int n;
    private final Player[][] cells;
    private final int[] rows, cols;
    private int diag, anti, moves;

    Board(int n) {
        this.n = n;
        cells = new Player[n][n];
        rows = new int[n];
        cols = new int[n];
    }

    /** Returns true if this move wins. */
    boolean place(int r, int c, Player p) {
        if (r < 0 || r >= n || c < 0 || c >= n) throw new IllegalArgumentException("off the board");
        if (cells[r][c] != null) throw new IllegalArgumentException("cell taken");
        cells[r][c] = p;
        moves++;
        rows[r] += p.value;
        cols[c] += p.value;
        if (r == c) diag += p.value;
        if (r + c == n - 1) anti += p.value;
        return Math.abs(rows[r]) == n || Math.abs(cols[c]) == n || Math.abs(diag) == n || Math.abs(anti) == n;
    }

    boolean full() {
        return moves == n * n;
    }
}

/** Owns the rules: whose turn it is, and when the game is over. */
class Game {
    private final Board board;
    private Player turn = Player.X;
    private String result;

    Game(int n) {
        board = new Board(n);
    }

    String play(int r, int c) {
        if (result != null) return "rejected: game over (" + result + ")";
        boolean won;
        try {
            won = board.place(r, c, turn);
        } catch (IllegalArgumentException e) {
            return "rejected: " + e.getMessage();
        }
        String mover = turn.name();
        if (won) result = mover + " wins";
        else if (board.full()) result = "draw";
        turn = turn == Player.X ? Player.O : Player.X;
        return mover + " at (" + r + "," + c + ")" + (result != null ? ": " + result : "");
    }
}

public class Main {
    public static void main(String[] args) {
        Game g = new Game(3);
        int[][] moves = {{0, 0}, {1, 1}, {1, 1}, {0, 1}, {2, 2}, {0, 2}, {2, 0}};
        for (int[] m : moves) System.out.println(g.play(m[0], m[1]));
    }
}

Output (the same in all three languages)

X at (0,0)
O at (1,1)
rejected: cell taken
X at (0,1)
O at (2,2)
X at (0,2): X wins
rejected: game over (X wins)

7. Concurrency and edge cases

8. Follow-up questions

What interviewers ask once the core works, and a short answer for each.

How would you add a computer player?
A Player interface with chooseMove(board); a human implementation reads input, a bot picks a move (random, or minimax for perfect play on 3 × 3).
How would you support undo?
Keep a stack of moves; undo pops one and subtracts its value from the sums.
What about k-in-a-row on a big board (gomoku)?
Sums no longer work; check the four directions from the last move, counting consecutive cells: O(k).

9. Common mistakes

Scanning the board after every moveO(n²) per move. Running sums make it O(1).
Putting turn logic in the boardThe board shouldn't know whose turn it is.
Checking for a draw before a winThe ninth move can win; check the win first.

10. Check yourself

How does the code detect a win in O(1)?

Only the lines through the last move can change.

Which class should own whose turn it is?

The board only knows cells; the game owns the rules.

The last empty cell is filled with a winning move. What's the result?

Check for a win before checking for a full board.

Your turn

  1. Add undo (10 min). A move stack; undo subtracts from the sums.
  2. Add a random bot (10 min). A Player interface with two implementations.