Ian Watkins iankwatkins
Lesson 10 · Game Dev Fundamentals · Finale

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:

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.

MAX (computer to move) — wants the highest / | \ play A play B play C | | | MIN (human replies — wants the lowest) / \ / \ / \ +10 0 -10 0 0 0 \_/ \_/ \_/ MIN picks 0 MIN picks -10 MIN picks 0 | | | MAX compares: 0 vs -10 vs 0 -> picks play A or C (0)

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;
  }
}
Look what's familiar

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.

Why it literally cannot lose

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

  1. Add the minimax(isMaximizing) function.
  2. Add crazyMove() — the maximizing chooser that returns the best cell.
  3. Repoint crazy in your strategies object to crazyMove.
Your task

(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

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

That's all ten lessons! See your course progress and celebration →