Minimax: The Perfect Player
This is the one that cannot lose. Not "hard" — unbeatable. It works by playing the entire rest of the game in its head before every move, assuming you play perfectly too. The technique is your simulate-check-undo, turned on itself: a function that calls itself, all the way to the end.
The idea in one breath
Minimax asks: "for each move I could make, if both of us play perfectly from here on, what's the final result?" It scores every possible ending, assumes the opponent always picks their best reply, and chooses the move with the best guaranteed outcome. Because it sees every consequence to the end, no trap surprises it.
Two players want opposite things
Give every finished game a number from the computer's point of view:
| Ending | Score |
|---|---|
| Computer wins | +10 |
| Human wins | -10 |
| Draw | 0 |
Now the two players have opposite goals on this single number:
-
The computer maximizes —
it steers toward
+10. -
The human minimizes —
minimax assumes you steer toward
-10.
That tug-of-war is the whole algorithm. As it imagines the game forward, it alternates: on the computer's turn take the maximum of the options, on the human's turn take the minimum. Hence mini-max.
Read the bottom up: the human (MIN) will always pick the worst outcome for the computer among each branch's leaves, so the computer (MAX) evaluates each move by its opponent's best reply, then takes the best of those. It never assumes you'll blunder.
The base case: when do we stop?
A self-calling function needs a base case — a point where it stops recursing and returns a concrete answer. Here it's simple: stop when the game is over, and return that ending's score.
if (checkWin(computer)) return 10;
if (checkWin(human)) return -10;
if (isBoardFull()) return 0;
Every recursive call eventually hits one of these, because each call fills one more cell — the board can't fill forever. That guarantee is what keeps the recursion from running away.
The recursive engine
function minimax(isMaximizing) {
// base cases: a finished game returns its score
if (checkWin(computer)) return 10;
if (checkWin(human)) return -10;
if (isBoardFull()) return 0;
if (isMaximizing) {
// computer's turn — find the BEST (highest) reachable score
let best = -Infinity;
for (let i = 0; i < board.length; i++) {
if (board[i] === "") {
board[i] = computer; // simulate
const score = minimax(false); // human replies next
board[i] = ""; // undo
best = Math.max(best, score);
}
}
return best;
} else {
// human's turn — assume they pick the WORST (lowest) for us
let best = Infinity;
for (let i = 0; i < board.length; i++) {
if (board[i] === "") {
board[i] = human; // simulate
const score = minimax(true); // computer replies next
board[i] = ""; // undo
best = Math.min(best, score);
}
}
return best;
}
}
The inner loop is exactly your
findWinningMove: place a mark, evaluate, undo.
The only new thing is that "evaluate" is now
minimax itself, calling forward into the next
turn. You already knew 90% of this. Recursion is just
simulate-check-undo where the "check" is another
simulate-check-undo, until the game ends.
The chooser: crazyMove
minimax returns a score. But your strategy
needs to return a cell. So the top level mirrors the
maximizing branch, except it remembers which move gave
the best score:
function crazyMove() {
let bestScore = -Infinity;
let bestCell = null;
for (let i = 0; i < board.length; i++) {
if (board[i] === "") {
board[i] = computer; // try this move
const score = minimax(false); // human replies next
board[i] = ""; // undo
if (score > bestScore) {
bestScore = score;
bestCell = i; // remember the winning move
}
}
}
return bestCell;
}
And the two-line payoff promised last lesson — point
crazy at the new strategy:
const strategies = {
easy: easyMove,
normal: normalMove,
crazy: crazyMove // was: normalMove
};
That's it. The dropdown, dispatcher, and
makeMove from Lesson 9 don't change at all. A
perfect player drops into the slot you built for it.
At every branch it assumes you play your best (the MIN steps). So it only ever picks moves whose worst-case outcome is as good as possible. Since tic-tac-toe is a draw under perfect play, its guaranteed worst case is a draw — and if you slip, it takes the win. There's no line you can walk that it didn't already see to the end.
Live demo — try to beat it
Set to Crazy. Go ahead, give it your best. The honest goal here is a draw; a loss for you means it found a mistake. (Easy and Normal still work too.)
Build it yourself
- Add the
minimax(isMaximizing)function. -
Add
crazyMove()— the maximizing chooser that returns the best cell. -
Repoint
crazyin yourstrategiesobject tocrazyMove.
(1) Play five games on Crazy and confirm
you never win — best you can do is draw.
(2) Add
console.log("evaluated", bestCell, "score",
bestScore)
at the end of crazyMove and watch the scores:
an early move usually scores 0 (heading for a
draw against your best play); if you blunder, you'll see a
move jump to 10 as it spots the forced win.
(3) Reflect: why does the very first move
take the longest to compute? (Hint: how many empty cells,
and how deep does the tree go?)
What you just learned
- Recursion on a game tree: a function that calls itself, with a base case (game over) that returns a concrete score.
- Minimax: alternating maximize/minimize because the two players want opposite things; assume the opponent plays perfectly.
- Scoring terminal states and bubbling those scores back up the tree.
- Same core, deeper: minimax is simulate-check-undo where "evaluate" recurses to the end of the game.
You finished the series. Look at what that means.
Ten lessons ago you rendered a board from an array and weren't sure how a click reached your code. You now have a complete game with turns, win detection and highlighting, draws, a scoreboard, reset, a selectable opponent, and a provably perfect AI — built from a blank folder, typed by your own hands, debugged through every silent failure JavaScript could throw at you.
But the game was never the point. You learned the architecture under all games: state → render → input → update → render. You learned that the screen is a mirror of data, that rules and strategy are just ordering, that new behaviors plug into a clean loop. Snake, a card game, a small RPG — they are this, scaled. You own the shape now.
That was the mission: not tic-tac-toe, but the fundamentals of game development. Mission complete.
Where to go from here
- Build a new game from scratch — the real test of whether the loop is yours. Snake is the classic next step (state = the snake's body as an array of cells, plus a food position; input = arrow keys; the "render" runs on a timer).
- Ship it. Put a finished game on itch.io and let real people play. Feedback from actual players is the wisdom no lesson can give you.
- Go deeper on the AI: minimax with alpha-beta pruning (skip branches that can't matter) is the gateway to real game-tree search.
- Join the conversation: r/gamedev and r/javascript are good places to share what you built and learn what's next.