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.
- 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 lengthn * n. Index isr * 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.
Xstarts unless you take astartPlayerprop. 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 × nboard, defaultn = 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-livestatus- 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)
- Flat board plus
n,current, derivedwinner - Line generator as a pure function, test it first if they let you
- MVP: click, alternate, win, draw, reset
- Then status text and keyboard
- Do not store
winneras 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
- Hardcoded 3×3 triples while the prompt said N
- Checking only the row of the last move and forgetting diagonals
winnerstate that survives reset- Using array index as a React key after undo/reorder (fine here because cells are positions, not identities)
- Putting marks in CSS
::afterwith no text for the name - 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.
Related on this site
- Lite Spreadsheet Grid
- Calendar Month View
- Keys in lists
- Keyboard Accessibility Checklist
- Machine Coding Interview Framework