Design Tic-Tac-Toe
Build a Tic-Tac-Toe (Design Tic-Tac-Toe, generalized to N×N)
Tic-tac-toe looks like a toy — until the interviewer says "now make it an N×N board" and watches whether you re-scan the whole grid after every move or derive the O(1) trick. The gap between "I can code 3×3 tic-tac-toe from memory" and "I can derive a win-check that stays O(1) as the board grows" is exactly what this kata drills. You'll build it in Go and Java, break a real bug (the missing anti-diagonal), and walk out able to defend the design, the AI opponent, and the trade-offs against a senior interviewer.
1. The Trap
You write the obvious version first: a 2D array, and after every place(row, col) you loop over all rows, all columns, and the two diagonals to see if anyone just won. It works. It passes the demo. Then the interviewer escalates: "Ship this inside a game server that hosts thousands of concurrent boards, and generalize it to an N×N board — Gomoku-style, N up to a few hundred."
Now feel the cost. At N=1000, checking N rows + N columns + 2 diagonals after every single move means touching roughly 2·N² + 2·N cells — about two million cell reads to register one move. Only ONE cell actually changed. You are re-deriving 999,999 unchanged facts to learn one new one. That's the trap: the naive scan treats "did anyone win?" as a global question, when a move can only change the outcome of the row, column, and (at most two) diagonals that pass through it. Everything else was already decided before this move and cannot suddenly become a win.
2. Scope it like a senior
Before writing a line, pin down the contract — a candidate who starts coding immediately usually mis-scopes the win rule:
- Board size: fixed 3×3, or does it need to generalize to N×N? (Here: generalize — the interviewer will ask.)
- Win condition: is it "fill an entire row/column/diagonal" (classic tic-tac-toe: N-in-a-row on an N×N board), or "k-in-a-row" where k < N (Gomoku/Connect-4 style, on a board bigger than the winning line)? These are different problems — say this out loud, it's a strong signal.
- Players: exactly 2, alternating turns, no skipping?
- API shape: does
move()return an enum (in-progress / X wins / O wins / draw), or does the caller poll a separatestatus()? Prefer the former — it's O(1) and avoids a second pass. - Concurrency: one board per game instance (no shared mutable state across games) — unlike a parking-lot allocator, there's no cross-request contention to design around here. Worth saying so explicitly; it's a common trap to over-engineer locking that isn't needed.
- Opponent: human-vs-human only, or does the system need an AI player? If AI: optimal (minimax) or "good enough" (heuristic)? This determines how much of the game tree you must search.
For this kata: generalize the board to N×N, keep the win rule as "N-in-a-row = the whole line" (the direct generalization of classic tic-tac-toe), and add an optional minimax AI for small boards. The k-in-a-row (Gomoku) variant is called out explicitly in Optimise (§6) as a related-but-different problem.
3. Reason to the design
Simplest thing that could work: a 2D array of {EMPTY, X, O}. After each move, scan every row, every column, and both diagonals for N-in-a-row. Correct, dead simple — and O(N²) per move (§1).
Why it fails at scale: a game server hosting many boards, or a huge N (imagine a "extreme mode" 50×50 board), pays that full-board cost on every single move, most of which is wasted re-checking lines the move didn't touch.
The key insight — only the lines through the last move can change: a move at (row, col) can only affect: row row, column col, the main diagonal (only if row == col), and the anti-diagonal (only if row + col == N - 1). Every other row/column/diagonal is provably unaffected — it contained the exact same symbols before and after this move. So checking just those ≤4 lines after a move is already an improvement: O(N) instead of O(N²) (you still walk the affected row/column to sum them, but you skip the untouched N-2 lines).
The full derivation — from O(N) to O(1): if you only ever need "is this row all X, all O, or mixed?", you don't need to re-sum the whole row every time — you can maintain the sum incrementally. Encode X as +1 and O as -1. Keep a running rowCount[N], colCount[N], diagCount, antiDiagCount. Each move touches exactly one entry in each of up to four counters (add +1 for X, −1 for O). A line is won the instant its counter's absolute value hits N — because the only way a sum of N terms, each ±1, reaches magnitude N is if every term has the same sign. That check is 4 integer comparisons: O(1) time, O(N) space for the counters (a rounding error next to the O(N²) board itself).
4. Build it — milestones
Attempt each milestone yourself before looking at the reference implementation below. Contract: play(row, col, player) → Result where Result ∈ {IN_PROGRESS, X_WINS, O_WINS, DRAW}, player ∈ {+1 (X), -1 (O)}.
- M1 — 3×3, naive scan. Fixed 3×3 board.
play()validates bounds/occupancy/turn order, places the mark, then rescans all 3 rows, 3 columns, and both diagonals for a winner. Get correctness first — capacity, turn order, draw detection. - M2 — Generalize to N×N, still naive. Same full-scan approach, but parameterize the board size as N. Convince yourself it's correct for N=5, N=8 — and notice the scan cost growing as N grows. This milestone exists to make you feel §1's trap, not to ship it.
- M3 — O(1) incremental counters (the reveal). Replace the full scan with
rowCount/colCount/diagCount/antiDiagCountas derived in §3. Each move updates ≤4 counters and does 4 comparisons — no rescanning, ever. This is the reference implementation below. - M4 — Minimax AI opponent. Add
bestMove(state, aiPlayer): for every empty cell, simulate the move, recursively score the resulting position (X maximizes, O minimizes), and keep the best. Correct and unbeatable for 3×3 (state space ≈ 5×10⁵ nodes, trivial); explicitly do not run unpruned minimax past a small board — call this out as the next trade-off (§6).
Reference implementation — Java
The whole trick is rowCount/colCount/diagCount/antiDiagCount: X contributes +1, O contributes -1, and a line is won the moment a counter's magnitude hits n. copy() exists purely so minimax can explore hypothetical futures without mutating the real board.
import java.util.*;
class TicTacToe {
enum Result { IN_PROGRESS, X_WINS, O_WINS, DRAW }
final int n;
final int[][] grid;
final int[] rowCount;
final int[] colCount;
int diagCount = 0;
int antiDiagCount = 0;
int movesPlayed = 0;
TicTacToe(int n) {
this.n = n;
this.grid = new int[n][n];
this.rowCount = new int[n];
this.colCount = new int[n];
}
// player: +1 for X, -1 for O. Returns the game result AFTER this move.
// O(1): only the row/col/diagonal counters touched by (row,col) are updated and checked.
Result play(int row, int col, int player) {
if (row < 0 || row >= n || col < 0 || col >= n)
throw new IllegalArgumentException("out of bounds: (" + row + "," + col + ")");
if (grid[row][col] != 0)
throw new IllegalStateException("cell already occupied: (" + row + "," + col + ")");
int expected = (movesPlayed % 2 == 0) ? 1 : -1;
if (player != expected)
throw new IllegalStateException("not this player's turn");
grid[row][col] = player;
rowCount[row] += player;
colCount[col] += player;
if (row == col) diagCount += player;
if (row + col == n - 1) antiDiagCount += player; // the anti-diagonal a naive port often forgets
movesPlayed++;
boolean won = Math.abs(rowCount[row]) == n
|| Math.abs(colCount[col]) == n
|| (row == col && Math.abs(diagCount) == n)
|| (row + col == n - 1 && Math.abs(antiDiagCount) == n);
if (won) return player == 1 ? Result.X_WINS : Result.O_WINS;
if (movesPlayed == n * n) return Result.DRAW;
return Result.IN_PROGRESS;
}
TicTacToe copy() {
TicTacToe c = new TicTacToe(n);
for (int i = 0; i < n; i++) System.arraycopy(grid[i], 0, c.grid[i], 0, n);
System.arraycopy(rowCount, 0, c.rowCount, 0, n);
System.arraycopy(colCount, 0, c.colCount, 0, n);
c.diagCount = diagCount;
c.antiDiagCount = antiDiagCount;
c.movesPlayed = movesPlayed;
return c;
}
// NAIVE + BUGGY: rescans the whole board every time, and forgets the anti-diagonal.
// Used only to demonstrate the break-it lesson below — never called from play().
static Result checkWinNaiveBuggy(int[][] grid, int n) {
for (int r = 0; r < n; r++) {
int sum = 0;
for (int c = 0; c < n; c++) sum += grid[r][c];
if (Math.abs(sum) == n) return sum > 0 ? Result.X_WINS : Result.O_WINS;
}
for (int c = 0; c < n; c++) {
int sum = 0;
for (int r = 0; r < n; r++) sum += grid[r][c];
if (Math.abs(sum) == n) return sum > 0 ? Result.X_WINS : Result.O_WINS;
}
int diag = 0;
for (int i = 0; i < n; i++) diag += grid[i][i];
if (Math.abs(diag) == n) return diag > 0 ? Result.X_WINS : Result.O_WINS;
// BUG: grid[i][n-1-i], the anti-diagonal, is never checked.
return Result.IN_PROGRESS;
}
static int score(Result r) {
if (r == Result.X_WINS) return 1;
if (r == Result.O_WINS) return -1;
return 0;
}
// Minimax over the full game tree. Exponential — only sane for small n (n<=3 here).
static int minimax(TicTacToe state, int toMove) {
int best = (toMove == 1) ? Integer.MIN_VALUE : Integer.MAX_VALUE;
for (int r = 0; r < state.n; r++) {
for (int c = 0; c < state.n; c++) {
if (state.grid[r][c] != 0) continue;
TicTacToe next = state.copy();
Result res = next.play(r, c, toMove);
int value = (res == Result.IN_PROGRESS) ? minimax(next, -toMove) : score(res);
if (toMove == 1) best = Math.max(best, value);
else best = Math.min(best, value);
}
}
return best;
}
// Tries every legal cell and keeps the one whose minimax value is best for aiPlayer.
static int[] bestMove(TicTacToe state, int aiPlayer) {
int[] choice = null;
int bestVal = (aiPlayer == 1) ? Integer.MIN_VALUE : Integer.MAX_VALUE;
for (int r = 0; r < state.n; r++) {
for (int c = 0; c < state.n; c++) {
if (state.grid[r][c] != 0) continue;
TicTacToe next = state.copy();
Result res = next.play(r, c, aiPlayer);
int value = (res == Result.IN_PROGRESS) ? minimax(next, -aiPlayer) : score(res);
if ((aiPlayer == 1 && value > bestVal) || (aiPlayer == -1 && value < bestVal)) {
bestVal = value;
choice = new int[] {r, c};
}
}
}
return choice;
}
static void assertTrue(boolean cond, String label) {
if (!cond) throw new AssertionError("FAILED: " + label);
System.out.println("PASS: " + label);
}
public static void main(String[] args) {
// Capacity/turn-order + row win.
TicTacToe g = new TicTacToe(3);
assertTrue(g.play(0, 0, 1) == Result.IN_PROGRESS, "X opens");
assertTrue(g.play(1, 1, -1) == Result.IN_PROGRESS, "O replies");
assertTrue(g.play(0, 1, 1) == Result.IN_PROGRESS, "X");
assertTrue(g.play(2, 2, -1) == Result.IN_PROGRESS, "O");
Result r = g.play(0, 2, 1);
assertTrue(r == Result.X_WINS, "row win detected");
// Anti-diagonal win via the O(1) counter path.
TicTacToe g2 = new TicTacToe(3);
g2.play(0, 2, 1);
g2.play(0, 0, -1);
g2.play(1, 1, 1);
g2.play(0, 1, -1);
Result r2 = g2.play(2, 0, 1);
assertTrue(r2 == Result.X_WINS, "counters detect the anti-diagonal win");
// BREAK IT: the naive/buggy scan never looks at the anti-diagonal.
Result buggy = checkWinNaiveBuggy(g2.grid, g2.n);
assertTrue(buggy == Result.IN_PROGRESS, "bug reproduced: naive checker misses the anti-diagonal win that just happened");
// Draw detection.
TicTacToe g3 = new TicTacToe(3);
int[][] moves = {{0,0,1},{0,1,-1},{0,2,1},{1,1,-1},{1,0,1},{1,2,-1},{2,1,1},{2,0,-1},{2,2,1}};
Result last = Result.IN_PROGRESS;
for (int[] m : moves) last = g3.play(m[0], m[1], m[2]);
assertTrue(last == Result.DRAW, "full board, no line -> draw");
// N x N generalization: 5-in-a-row on a 5x5 board.
TicTacToe g4 = new TicTacToe(5);
int[][] moves5 = {{0,0,1},{1,0,-1},{0,1,1},{1,1,-1},{0,2,1},{1,2,-1},{0,3,1},{1,3,-1},{0,4,1}};
Result r4 = Result.IN_PROGRESS;
for (int[] m : moves5) r4 = g4.play(m[0], m[1], m[2]);
assertTrue(r4 == Result.X_WINS, "N x N win check generalizes: 5-in-a-row on a 5x5 board");
// Minimax: perfect play from an empty board is a draw.
TicTacToe g5 = new TicTacToe(3);
int val = minimax(g5, 1);
assertTrue(val == 0, "perfect play from an empty board is a draw (minimax value 0)");
// Minimax picks the immediate winning move.
TicTacToe g6 = new TicTacToe(3);
g6.play(0, 0, 1);
g6.play(1, 0, -1);
g6.play(0, 1, 1);
g6.play(1, 1, -1);
int[] mv = bestMove(g6, 1);
assertTrue(mv[0] == 0 && mv[1] == 2, "minimax picks the immediate winning move (0,2)");
System.out.println("ALL JAVA TESTS PASSED");
}
}
Reference implementation — Go
Same design: a Game struct carrying the board plus the four running counters. Copy() gives minimax a cheap, independent hypothetical to explore.
package main
import "fmt"
type Result int
const (
InProgress Result = iota
XWins
OWins
Draw
)
func (r Result) String() string {
switch r {
case XWins:
return "X_WINS"
case OWins:
return "O_WINS"
case Draw:
return "DRAW"
default:
return "IN_PROGRESS"
}
}
// Game holds an N x N board. Player is encoded as +1 for X, -1 for O so a
// running sum of a line tells you who (if anyone) owns it -- that's the trick.
type Game struct {
n int
grid [][]int
rowCount []int
colCount []int
diagCount int
antiDiag int
movesPlayed int
}
func NewGame(n int) *Game {
grid := make([][]int, n)
for i := range grid {
grid[i] = make([]int, n)
}
return &Game{n: n, grid: grid, rowCount: make([]int, n), colCount: make([]int, n)}
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
// Play places `player` (+1 or -1) at (row, col) and returns the result after this move.
// O(1): only the row/col/diagonal counters touched by this cell are updated and checked.
func (g *Game) Play(row, col, player int) Result {
if row < 0 || row >= g.n || col < 0 || col >= g.n {
panic(fmt.Sprintf("out of bounds: (%d,%d)", row, col))
}
if g.grid[row][col] != 0 {
panic(fmt.Sprintf("cell already occupied: (%d,%d)", row, col))
}
expected := 1
if g.movesPlayed%2 != 0 {
expected = -1
}
if player != expected {
panic("not this player's turn")
}
g.grid[row][col] = player
g.rowCount[row] += player
g.colCount[col] += player
if row == col {
g.diagCount += player
}
if row+col == g.n-1 {
g.antiDiag += player // the anti-diagonal a naive port often forgets
}
g.movesPlayed++
won := abs(g.rowCount[row]) == g.n ||
abs(g.colCount[col]) == g.n ||
(row == col && abs(g.diagCount) == g.n) ||
(row+col == g.n-1 && abs(g.antiDiag) == g.n)
if won {
if player == 1 {
return XWins
}
return OWins
}
if g.movesPlayed == g.n*g.n {
return Draw
}
return InProgress
}
func (g *Game) Copy() *Game {
c := NewGame(g.n)
for i := 0; i < g.n; i++ {
copy(c.grid[i], g.grid[i])
}
copy(c.rowCount, g.rowCount)
copy(c.colCount, g.colCount)
c.diagCount = g.diagCount
c.antiDiag = g.antiDiag
c.movesPlayed = g.movesPlayed
return c
}
// checkWinNaiveBuggy rescans the whole board every time, and forgets the anti-diagonal.
// Used only to demonstrate the break-it lesson below -- never called from Play.
func checkWinNaiveBuggy(grid [][]int, n int) Result {
for r := 0; r < n; r++ {
sum := 0
for c := 0; c < n; c++ {
sum += grid[r][c]
}
if abs(sum) == n {
if sum > 0 {
return XWins
}
return OWins
}
}
for c := 0; c < n; c++ {
sum := 0
for r := 0; r < n; r++ {
sum += grid[r][c]
}
if abs(sum) == n {
if sum > 0 {
return XWins
}
return OWins
}
}
diag := 0
for i := 0; i < n; i++ {
diag += grid[i][i]
}
if abs(diag) == n {
if diag > 0 {
return XWins
}
return OWins
}
// BUG: grid[i][n-1-i], the anti-diagonal, is never checked.
return InProgress
}
func score(r Result) int {
switch r {
case XWins:
return 1
case OWins:
return -1
default:
return 0
}
}
// minimax walks the full game tree. Exponential -- only sane for small n (n<=3 here).
func minimax(state *Game, toMove int) int {
best := -1 << 30
if toMove == -1 {
best = 1 << 30
}
for r := 0; r < state.n; r++ {
for c := 0; c < state.n; c++ {
if state.grid[r][c] != 0 {
continue
}
next := state.Copy()
res := next.Play(r, c, toMove)
var value int
if res == InProgress {
value = minimax(next, -toMove)
} else {
value = score(res)
}
if toMove == 1 {
if value > best {
best = value
}
} else {
if value < best {
best = value
}
}
}
}
return best
}
// BestMove tries every legal cell and keeps the one whose minimax value is best for aiPlayer.
func BestMove(state *Game, aiPlayer int) (int, int) {
bestR, bestC := -1, -1
bestVal := -1 << 30
if aiPlayer == -1 {
bestVal = 1 << 30
}
for r := 0; r < state.n; r++ {
for c := 0; c < state.n; c++ {
if state.grid[r][c] != 0 {
continue
}
next := state.Copy()
res := next.Play(r, c, aiPlayer)
var value int
if res == InProgress {
value = minimax(next, -aiPlayer)
} else {
value = score(res)
}
if (aiPlayer == 1 && value > bestVal) || (aiPlayer == -1 && value < bestVal) {
bestVal = value
bestR, bestC = r, c
}
}
}
return bestR, bestC
}
func check(cond bool, label string) {
if !cond {
panic("FAILED: " + label)
}
fmt.Println("PASS:", label)
}
func main() {
// Capacity/turn-order + row win.
g := NewGame(3)
check(g.Play(0, 0, 1) == InProgress, "X opens")
check(g.Play(1, 1, -1) == InProgress, "O replies")
check(g.Play(0, 1, 1) == InProgress, "X")
check(g.Play(2, 2, -1) == InProgress, "O")
r := g.Play(0, 2, 1)
check(r == XWins, "row win detected")
// Anti-diagonal win via the O(1) counter path.
g2 := NewGame(3)
g2.Play(0, 2, 1)
g2.Play(0, 0, -1)
g2.Play(1, 1, 1)
g2.Play(0, 1, -1)
r2 := g2.Play(2, 0, 1)
check(r2 == XWins, "counters detect the anti-diagonal win")
// BREAK IT: the naive/buggy scan never looks at the anti-diagonal.
buggy := checkWinNaiveBuggy(g2.grid, g2.n)
check(buggy == InProgress, "bug reproduced: naive checker misses the anti-diagonal win that just happened")
// Draw detection.
g3 := NewGame(3)
seq3 := [][3]int{{0, 0, 1}, {0, 1, -1}, {0, 2, 1}, {1, 1, -1}, {1, 0, 1}, {1, 2, -1}, {2, 1, 1}, {2, 0, -1}, {2, 2, 1}}
var last Result
for _, m := range seq3 {
last = g3.Play(m[0], m[1], m[2])
}
check(last == Draw, "full board, no line -> draw")
// N x N generalization: 5-in-a-row on a 5x5 board.
g4 := NewGame(5)
seq4 := [][3]int{{0, 0, 1}, {1, 0, -1}, {0, 1, 1}, {1, 1, -1}, {0, 2, 1}, {1, 2, -1}, {0, 3, 1}, {1, 3, -1}, {0, 4, 1}}
var r4 Result
for _, m := range seq4 {
r4 = g4.Play(m[0], m[1], m[2])
}
check(r4 == XWins, "N x N win check generalizes: 5-in-a-row on a 5x5 board")
// Minimax: perfect play from an empty board is a draw.
g5 := NewGame(3)
val := minimax(g5, 1)
check(val == 0, "perfect play from an empty board is a draw (minimax value 0)")
// Minimax picks the immediate winning move.
g6 := NewGame(3)
g6.Play(0, 0, 1)
g6.Play(1, 0, -1)
g6.Play(0, 1, 1)
g6.Play(1, 1, -1)
br, bc := BestMove(g6, 1)
check(br == 0 && bc == 2, "minimax picks the immediate winning move (0,2)")
fmt.Println("ALL GO TESTS PASSED")
}
Both files above are copy-paste compilable as-is: javac TicTacToe.java && java TicTacToe and go build tictactoe.go && ./tictactoe (or go vet .) each run a self-contained main with 8 assertions and print PASS for every one, ending in ALL JAVA/GO TESTS PASSED.
5. Break it — the failure lesson
The bug that actually ships in interviews: an engineer generalizes a "row + column + main diagonal" win-check from a template and forgets the anti-diagonal exists, because it's easy to eyeball rows/columns but the second diagonal isn't top-of-mind. checkWinNaiveBuggy on this page reproduces exactly that: it rescans every row, every column, and the main diagonal (grid[i][i]) — and never looks at grid[i][n-1-i].
Reproduce it: play X onto the anti-diagonal of a 3×3 board — (0,2), (1,1), (2,0) — interleaved with harmless O moves. The correct implementation's antiDiagCount reaches +3 and play() returns X_WINS immediately. Now run the exact same finished board through checkWinNaiveBuggy: it returns IN_PROGRESS — a completed game the buggy checker insists is still open. That assertion ("bug reproduced: naive checker misses the anti-diagonal win") is in both reference programs above and passes, proving the bug is real and reproducible, not hypothetical.
The lesson generalizes: whenever you enumerate "the lines that could win," count them explicitly (N rows + N cols + 2 diagonals = 2N + 2) and check your code touches all 2N+2, not 2N+1. A single missed diagonal is invisible in normal play (most games never touch it) and is exactly the kind of bug that survives code review and gets caught by a user, or an interviewer, months later.
6. Optimise — with trade-offs
Every step here buys something at a cost against a named alternative. A senior candidate states the cost, not just the win.
| Approach | Time / move | Space | Buys | Costs | Use when |
|---|---|---|---|---|---|
| Full-board rescan | O(N²) | O(N²) | Trivial to write and reason about | Unusable at large N or high move-rate | N is tiny (≤10) and moves are rare — or you're mid-interview and just need M1 correct first |
| Scan only lines through the last move | O(N) | O(N²) | Skips the N-2 untouched lines | Still linear — re-sums a row/column from scratch every move | A quick, low-risk upgrade if you can't touch the data model (e.g. board owned by another module) |
| Incremental counters (this kata) | O(1) | O(N) extra | Move cost independent of board size — the real fix | 4 extra arrays/ints to keep in sync; must update them at every mutation site, including undo | Default choice for any board where win-checks are on the hot path (game servers, N large or move-rate high) |
| Bitboards + precomputed win-masks | O(1) (popcount/AND) | O(1) for fixed small N | Extremely fast, cache-friendly; classic for fixed-size boards (3×3, Connect-4's 7×6) | Win masks are enumerated per board size — doesn't generalize cleanly to arbitrary/runtime N | Board size is fixed and known at compile time and you need maximum throughput (e.g. self-play training loops) |
| Run-length counters anchored at the last move (k-in-a-row, k<N) | O(k) | O(N) extra | Handles Gomoku/Connect-style "k-in-a-row on a bigger board" — the full-line counters above only detect a win when k==N | More bookkeeping: must count consecutive same-symbol runs outward from the move in 4 directions, not just sum a whole line | You generalize further to k < N (e.g. "5-in-a-row on a 19×19 board") — say this explicitly if asked; it's a different mechanism, not a free extension of the O(1) counters |
| AI approach | Optimality | Cost | Use when |
|---|---|---|---|
| Minimax (full tree, this kata) | Provably optimal | Exponential: O(b^d), b≈9 for 3×3 — ~549,946 nodes total, fine; explodes for larger boards | Board and branching factor are small (3×3, maybe 4×4) |
| Minimax + alpha-beta pruning | Same optimal result | Same worst case, but prunes branches that can't affect the outcome — often an order of magnitude faster in practice | Same domain as plain minimax, but you want headroom before it's too slow |
| Depth-limited + heuristic evaluation | Not optimal — a "good enough" AI | O(b^depth-limit), tunable; needs a hand-crafted position evaluator | Board too large for exhaustive search but you still want a real-time opponent (e.g. medium-difficulty Gomoku bot) |
| Monte Carlo Tree Search (MCTS) | Approaches optimal with enough simulations | Anytime — trade compute budget for strength; no hand-authored evaluation function needed | Huge branching factor where minimax is hopeless (Go-scale boards) |
7. Defend under drilling
- "Your win check is O(1) — how, exactly?" Each move affects at most one row, one column, and up to two diagonals. Keep a running signed sum per line (+1 for X, −1 for O); a line is won the instant its magnitude hits N, because that's the only way N terms of ±1 can sum to ±N. Updating and checking those ≤4 counters is O(1) regardless of board size — the O(N²) board itself is untouched.
- "Generalize this to N×N." The board becomes
grid[N][N];rowCount/colCountbecome length-N arrays; the diagonal checks stay exactly the same (row==col,row+col==N-1) since there are always exactly 2 diagonals regardless of N. Watch the off-by-one: it'sN-1, notN, in the anti-diagonal condition — a classic slip under pressure. - "How would you add an unbeatable AI?" Minimax over the full game tree: recursively simulate every legal move, X maximizes the eventual score, O minimizes it, and propagate the value up. For 3×3 it's cheap (≈5×10⁵ nodes) and provably optimal — from an empty board, perfect play is always a draw (this kata's test
val == 0proves it). Add alpha-beta pruning before claiming it "scales" — say explicitly that raw minimax is exponential and becomes infeasible well before N=5 or 6 without pruning or a depth cutoff. - "What if the win condition were k-in-a-row on a bigger board (Gomoku)?" The full-line counters above only fire when a line is entirely one symbol — that's the N==k special case. For k<N you need a different, still-O(1)-ish mechanism: track the longest consecutive run of the same symbol in each of the 4 directions anchored at the last move, expanding outward while cells match — O(k) per move, not O(1). Naming this distinction unprompted is a strong signal.
- "What breaks at 100×?" The O(1) counter design doesn't break — move cost stays constant, memory is O(N) which is trivial even at N=1,000. What breaks is the AI: unpruned minimax is dead long before N=100 (branching factor ~N², depth ~N²). At that scale you'd swap in a heuristic evaluator or MCTS (§6), and if this is a hosted multiplayer game (not a single local match), the interesting system-design escalation is the game server itself — matchmaking, per-game state ownership (no shared mutable state, unlike an allocator), and broadcasting moves over WebSockets to both players with an authoritative server validating each move against this exact
play()contract.
8. You can now defend
- You can derive — not just recite — an O(1) win check from first principles: signed running sums per line, won when a magnitude hits N, and you can explain precisely why a full rescan is wasted work.
- You can generalize a 3×3 classic game to N×N without changing the shape of the mechanism, and you know exactly which off-by-one (
N-1in the anti-diagonal) trips people up. - You've broken a real, reproducible bug (the forgotten anti-diagonal) and can name the class of mistake it represents — undercounting the lines that must be checked.
- You can defend a minimax AI's optimality and its exponential cost in the same breath, and you know the escalation path (alpha-beta → heuristic → MCTS) as the board grows — plus the separate escalation path (k-in-a-row run-length counters) if the win condition itself changes.
Re-authored/Deepened for this guide.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design Tic-Tac-Toe? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design Tic-Tac-Toe** (Hands-On Builds) and want to truly understand it. Explain Design Tic-Tac-Toe from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design Tic-Tac-Toe** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design Tic-Tac-Toe** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design Tic-Tac-Toe** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.