01Why Tic-Tac-Toe?
Calenote is a productivity app. A game seems out of place — until you notice how many people open the calendar, check a date, and have thirty idle seconds before their next meeting.
But the real reason is that tic-tac-toe is the perfect vehicle for learning minimax. The game tree is small enough to search exhaustively, yet large enough that brute-forcing it teaches you why optimization matters.
02How Minimax Thinks
Minimax assumes both players play optimally. It recursively explores every possible future and assigns each terminal position a score:
- +10 — AI wins
- 0 — draw
- −10 — human wins
On the AI's turn it picks the move with the maximum score. On the human's turn it assumes they will pick the minimum — the worst outcome for the AI. Hence “minimax.”
AI to move (MAX) / | \ move A move B move C / | \ Human (MIN) Human (MIN) Human (MIN) / \ / \ / \ +10 0 -10 0 0 +10 ↓ ↓ ↓ MIN=0 MIN=-10 MIN=0 MAX picks 0 → move A or C
03The Implementation
const AI = "O", HUMAN = "X"; function minimax(board, depth, isMaximizing) { const result = checkWinner(board); // Terminal states — stop recursing if (result === AI) return 10 - depth; if (result === HUMAN) return depth - 10; if (result === "draw") return 0; if (isMaximizing) { let best = -Infinity; for (let i = 0; i < 9; i++) { if (board[i]) continue; // square taken board[i] = AI; // try it best = Math.max(best, minimax(board, depth + 1, false)); board[i] = null; // undo } return best; } else { let best = Infinity; for (let i = 0; i < 9; i++) { if (board[i]) continue; board[i] = HUMAN; best = Math.min(best, minimax(board, depth + 1, true)); board[i] = null; } return best; } } // Pick the best available move function bestMove(board) { let bestScore = -Infinity, move = null; for (let i = 0; i < 9; i++) { if (board[i]) continue; board[i] = AI; const score = minimax(board, 0, false); board[i] = null; if (score > bestScore) { bestScore = score; move = i; } } return move; }
board[i] = null after recursing leaves phantom moves on the board and produces baffling bugs. Ask me how I know.04Preferring Faster Wins
Notice 10 - depth rather than a flat 10. Without it, minimax treats winning in one move and winning in five as equally good — and the AI will happily stall, making pointless moves before finishing you off.
With depth weighting:
- Win in 1 move → score 10
- Win in 3 moves → score 8
- Lose in 1 move → score −10
- Lose in 3 moves → score −8
The AI now wins as fast as possible and loses as slowly as possible — which reads as genuinely intelligent play.
05Alpha-Beta Pruning
Plain minimax evaluates roughly 5,000 board positions for the opening move. Most of that work is wasted: once you know a branch is worse than one you have already examined, there is no reason to keep exploring it.
function minimax(board, depth, isMax, alpha, beta) { const result = checkWinner(board); if (result === AI) return 10 - depth; if (result === HUMAN) return depth - 10; if (result === "draw") return 0; if (isMax) { let best = -Infinity; for (let i = 0; i < 9; i++) { if (board[i]) continue; board[i] = AI; best = Math.max(best, minimax(board, depth+1, false, alpha, beta)); board[i] = null; alpha = Math.max(alpha, best); if (beta <= alpha) break; // ✂ prune — opponent avoids this } return best; } else { let best = Infinity; for (let i = 0; i < 9; i++) { if (board[i]) continue; board[i] = HUMAN; best = Math.min(best, minimax(board, depth+1, true, alpha, beta)); board[i] = null; beta = Math.min(beta, best); if (beta <= alpha) break; // ✂ prune } return best; } } // Call with: minimax(board, 0, false, -Infinity, Infinity)
06Making It Beatable
Here is the uncomfortable discovery: a perfect AI is not fun. Players tried three times, drew three times, and left. Never losing isn't satisfying if winning is impossible.
So the AI got difficulty levels — implemented by deliberately injecting imperfection:
🟢 Easy
Random legal move most of the time. Blocks an immediate loss only occasionally. Beginners win regularly.
🟡 Medium
Runs full minimax, but a fixed percentage of turns fall back to a random move. Feels human — sharp, then a lapse.
🔴 Hard
Pure minimax, every turn, no mercy. Mathematically cannot be beaten. Draw is the best you get.
const MISTAKE_RATE = { easy: 0.8, medium: 0.25, hard: 0 }; function chooseMove(board, difficulty) { if (Math.random() < MISTAKE_RATE[difficulty]) { return randomLegalMove(board); } return bestMove(board); }
07Takeaways
Small games teach big algorithms.
Everything here — recursive search, position evaluation, pruning — is the same machinery behind serious chess engines, just at a scale you can hold in your head.
Always undo your moves.
Mutating a shared board and forgetting to restore it is the single most common minimax bug. Restore immediately after every recursive call.
Depth-weight your scores.
Without it the AI plays technically correct but visibly stupid moves. It's a one-line change with an outsized effect on perceived intelligence.
Ship the imperfect version.
The unbeatable AI was the interesting engineering problem. The beatable one was the better product.