Knowledge Guide
HomeHands-On BuildsLLD Katas

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:

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

A 3x3 board with X winning the anti-diagonal (0,2),(1,1),(2,0); running rowCount/colCount/diagCount/antiDiagCount are shown, with antiDiagCount reaching +3 = N to trigger the win.
A 3x3 board with X winning the anti-diagonal (0,2),(1,1),(2,0); running rowCount/colCount/diagCount/antiDiagCount are shown, with antiDiagCount reaching +3 = N to trigger the win.

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)}.

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.

ApproachTime / moveSpaceBuysCostsUse when
Full-board rescanO(N²)O(N²)Trivial to write and reason aboutUnusable at large N or high move-rateN is tiny (≤10) and moves are rare — or you're mid-interview and just need M1 correct first
Scan only lines through the last moveO(N)O(N²)Skips the N-2 untouched linesStill linear — re-sums a row/column from scratch every moveA 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) extraMove cost independent of board size — the real fix4 extra arrays/ints to keep in sync; must update them at every mutation site, including undoDefault choice for any board where win-checks are on the hot path (game servers, N large or move-rate high)
Bitboards + precomputed win-masksO(1) (popcount/AND)O(1) for fixed small NExtremely 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 NBoard 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) extraHandles Gomoku/Connect-style "k-in-a-row on a bigger board" — the full-line counters above only detect a win when k==NMore bookkeeping: must count consecutive same-symbol runs outward from the move in 4 directions, not just sum a whole lineYou 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 approachOptimalityCostUse when
Minimax (full tree, this kata)Provably optimalExponential: O(b^d), b≈9 for 3×3 — ~549,946 nodes total, fine; explodes for larger boardsBoard and branching factor are small (3×3, maybe 4×4)
Minimax + alpha-beta pruningSame optimal resultSame worst case, but prunes branches that can't affect the outcome — often an order of magnitude faster in practiceSame domain as plain minimax, but you want headroom before it's too slow
Depth-limited + heuristic evaluationNot optimal — a "good enough" AIO(b^depth-limit), tunable; needs a hand-crafted position evaluatorBoard 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 simulationsAnytime — trade compute budget for strength; no hand-authored evaluation function neededHuge branching factor where minimax is hopeless (Go-scale boards)

7. Defend under drilling

8. You can now defend


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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes