ESC

Type to search the knowledge base.

Tic-Tac-Toe Board

Machine-coding a tic-tac-toe grid of size N: turns, win lines, draw, reset, and keyboard. Interviewers care about the winner function more than CSS.

intermediate7 min read
  • machine-coding
  • interview
  • react
  • games

Most boards die on the winner function. Someone hardcodes eight triples for a 3×3, then the interviewer asks for n = 4 and the scan is garbage. A tic-tac-toe board of size N is a flat array of length n * n. After every legal move you generate rows, columns, and both diagonals and ask if any line is a single player.

CSS for the grid is ten minutes. The state machine is the round: whose turn, empty cell, win, draw, reset. Keyboard and a live announcement are what separate a toy from a widget.

Key takeaways

  • Store cells as Array<Player | null> of length n * n. Index is r * n + c.
  • Generate win lines from n. Do not paste eight triples.
  • Check the winner after a move. A full board with no line is a draw.
  • Cells are buttons in a grid. Announce the result in a live region.
  • X starts unless you take a startPlayer prop. Reset clears the array, not the component tree.

Board state is a flat array, winner is a scan

A 2D array looks like the UI and makes bounds easy to get wrong when you copy rows. One array plus n is enough:

type Player = "X" | "O";
type Cell = Player | null;

function index(n: number, r: number, c: number) {
  return r * n + c;
}

function emptyBoard(n: number): Cell[] {
  return Array.from({ length: n * n }, () => null);
}

Win lines are a data structure, not a switch. For an N-in-a-row game on an N×N board:

function lines(n: number): number[][] {
  const out: number[][] = [];
  for (let r = 0; r < n; r++) {
    out.push(Array.from({ length: n }, (_, c) => index(n, r, c)));
  }
  for (let c = 0; c < n; c++) {
    out.push(Array.from({ length: n }, (_, r) => index(n, r, c)));
  }
  out.push(Array.from({ length: n }, (_, i) => index(n, i, i)));
  out.push(Array.from({ length: n }, (_, i) => index(n, i, n - 1 - i)));
  return out;
}

function winnerOf(board: Cell[], n: number): Player | null {
  for (const line of lines(n)) {
    const first = board[line[0]];
    if (first && line.every((i) => board[i] === first)) return first;
  }
  return null;
}

function isDraw(board: Cell[], n: number) {
  return winnerOf(board, n) === null && board.every((c) => c !== null);
}

That is 2n + 2 lines. For n = 3 it is the classic eight. For n = 4 you did not rewrite anything. If they want k-in-a-row on a larger board, slide a window of length k along each row, column, and diagonal. Do not start there.

Recompute lines(n) when n changes, not on every click. The scan after a move is cheap: dozens of cells, not thousands.

Problem statement

Build a tic-tac-toe grid of size n (default 3). Two players alternate. A player who fills a row, column, or diagonal wins. A full board with no winner is a draw. Support reset and keyboard use of the cells.

Requirements

Must have

  • n × n board, default n = 3
  • Click empty cell places the current player
  • Ignore clicks on filled cells and after the game ends
  • Detect win and draw
  • Reset to an empty board
  • Visible status: turn, winner, or draw
  • Keyboard: each cell is a button

Should have

  • Configurable n (3, 4, 5)
  • aria-live status
  • Disable cells when the game is over without removing them

Nice to have

  • Undo last move
  • k-in-a-row on a larger board
  • Score across rounds
  • Bot second player

Planning (5 minutes out loud)

  1. Flat board plus n, current, derived winner
  2. Line generator as a pure function, test it first if they let you
  3. MVP: click, alternate, win, draw, reset
  4. Then status text and keyboard
  5. Do not store winner as a flag you forget to clear on reset. Derive it, or set it in the same reducer as the move.

Architecture

TicTacToe
├── Status (live region)
├── Board
│   └── Cell × n*n
└── Reset

API

type TicTacToeProps = {
  n?: number;
  startPlayer?: Player;
  onWin?: (player: Player) => void;
};

Implementation sketch

function TicTacToe({ n = 3, startPlayer = "X" }: TicTacToeProps) {
  const [board, setBoard] = useState<Cell[]>(() => emptyBoard(n));
  const [turn, setTurn] = useState<Player>(startPlayer);

  const winner = winnerOf(board, n);
  const draw = isDraw(board, n);
  const locked = winner !== null || draw;

  function play(i: number) {
    if (locked || board[i]) return;
    const next = board.slice();
    next[i] = turn;
    setBoard(next);
    setTurn(turn === "X" ? "O" : "X");
  }

  function reset() {
    setBoard(emptyBoard(n));
    setTurn(startPlayer);
  }

  const status = winner
    ? `${winner} wins`
    : draw
      ? "Draw"
      : `${turn}'s turn`;

  return (
    <div>
      <p role="status" aria-live="polite">
        {status}
      </p>
      <div
        role="grid"
        aria-label={`Tic tac toe, ${n} by ${n}`}
        style={{
          display: "grid",
          gridTemplateColumns: `repeat(${n}, 3rem)`,
        }}
      >
        {board.map((cell, i) => (
          <button
            key={i}
            type="button"
            role="gridcell"
            aria-label={
              cell
                ? `${cell} at row ${Math.floor(i / n) + 1}, column ${(i % n) + 1}`
                : `Empty, row ${Math.floor(i / n) + 1}, column ${(i % n) + 1}`
            }
            disabled={locked}
            onClick={() => play(i)}
          >
            {cell ?? ""}
          </button>
        ))}
      </div>
      <button type="button" onClick={reset}>
        Reset
      </button>
    </div>
  );
}

When n is a prop that can change, reset the board in an effect keyed on n, or lift n so a change remounts with key={n}. A leftover 9-length array on a 4×4 grid is a real bug.

Roving tabindex on the grid is extra credit. Tab-to-each-button is acceptable for n = 3. For n = 5, mention arrows and a single tab stop if time remains.

Accessibility essentials

Piece Approach
Board role="grid" and a name that includes size
Cell <button> with role="gridcell" and a name that includes row/column and mark
Status role="status" / aria-live="polite"
Over disabled on every cell when the game ends, not pointer-events: none alone. Filled cells stay enabled so the board is still readable.
Reset Real button, not a clickable heading

Do not announce every click. The status line already changes. Empty buttons with no accessible name fail a keyboard pass.

Interview scoring

Signal What they want
Model Flat array, n, turn. No parallel xCells / oCells sets unless you justify them
Winner Generated lines, works for N, not eight magic triples
Rules No overwrite, no move after win, draw on full board
Reset Board and turn together
A11y Buttons, status, labels
Talk You name k-in-a-row as an extension, you do not build it first

They will probe winnerOf with a drawn board, a diagonal win, and n = 4. Walk a line with your finger. If you cannot narrate the scan, the CSS grid will not save you.

Footguns

  1. Hardcoded 3×3 triples while the prompt said N
  2. Checking only the row of the last move and forgetting diagonals
  3. winner state that survives reset
  4. Using array index as a React key after undo/reorder (fine here because cells are positions, not identities)
  5. Putting marks in CSS ::after with no text for the name
  6. Allowing a click on a filled cell that still toggles turn

Interview out-loud answer

I would keep an N by N board as a flat array of X, O, or null, and a turn flag. On click I reject filled cells and ended games, write the current player, then scan generated rows, columns, and both full diagonals. If a line is uniform, that player wins. If every cell is filled and there is no line, it is a draw. Status is derived and pushed to a live region. Reset allocates a new empty array and restores the starting player. If they want four-in-a-row on a bigger board, I slide windows of k instead of requiring the whole line.

Further reading