Ian Watkins iankwatkins
Lesson 8 · Game Dev Fundamentals

A Smarter Opponent

Random is harmless. Today the computer starts to think: it takes a win when it sees one, blocks yours when it must, and only then guesses. You'll learn the technique behind every game AI ever written — try a move, judge it, take it back.

The strategy: a priority list

A decent tic-tac-toe player follows a simple ranking every turn. We'll encode exactly that:

The order is the intelligence: winning beats blocking, blocking beats guessing. Notice this is the same "priority via ordering" idea as your win/draw/continue checks — strategy is just rules in the right sequence.

The core technique: simulate, check, undo

To ask "would playing cell 5 win for me?", the cleanest way is to actually try it: drop the mark in, run your existing win check, then put the board back exactly as it was. The board is your scratchpad; you just have to tidy up after.

This is the seed of all game AI

"Try a move on a copy of reality, evaluate the result, undo it" scales from this three-line check all the way up to chess engines exploring millions of positions. You're learning the fundamental move of artificial opponents, in miniature.

First, a small refactor: checkWin takes a player

Your checkWin() currently tests the global currentPlayer. But the AI needs to ask hypothetical questions about either player ("would x win here? would o?"). So make the player an explicit argument:

function checkWin(player) {          // was: no argument, used global currentPlayer
  for (const combo of winningCombinations) {
    const [a, b, c] = combo;
    if (board[a] === player && board[b] === player && board[c] === player) {
      return combo;
    }
  }
  return null;
}

Then update its one existing caller in makeMove to pass the current player:

const line = checkWin(currentPlayer);   // was: checkWin()
Why removing the hidden dependency matters

A function that secretly reads a global is hard to reuse — it can only ever answer about whoever currentPlayer happens to be. Passing the player as a parameter makes checkWin answer any question you ask it. Making inputs explicit is what turns a one-off function into a reusable tool.

The helper: find a winning move for a player

Now the simulate-check-undo move, wrapped in a function. "Is there an empty cell where player would immediately win?"

function findWinningMove(player) {
  for (let i = 0; i < board.length; i++) {
    if (board[i] !== "") continue;   // only consider empty cells

    board[i] = player;                // 1. simulate the move
    const wins = checkWin(player) !== null;
    board[i] = "";                   // 2. UNDO — always, before returning

    if (wins) return i;            // 3. this cell wins for player
  }
  return null;                       // no immediate win available
}
The undo must happen no matter what

Notice board[i] = "" runs before the if (wins) return. If you returned while the simulated mark was still on the board, you'd leave a phantom move behind and corrupt the game. Clean up your scratch work before you leave the function. This is the discipline that makes simulation safe.

Putting it together in computerMove

function computerMove() {
  if (isGameOver) return;

  // 1. take a winning move if one exists
  let choice = findWinningMove(computer);

  // 2. otherwise block the human's winning move
  if (choice === null) {
    choice = findWinningMove(human);
  }

  // 3. otherwise pick a random empty cell
  if (choice === null) {
    const emptyCells = [];
    for (let i = 0; i < board.length; i++) {
      if (board[i] === "") emptyCells.push(i);
    }
    choice = emptyCells[Math.floor(Math.random() * emptyCells.length)];
  }

  makeMove(choice);
}

Read it top to bottom: it's your priority list, line for line. Each if (choice === null) means "the higher-priority option found nothing, so fall through to the next."

The trap: why === null and not if (!choice)

This is the bug that would bite almost everyone. A valid winning move could be cell 0 (top-left). But 0 is falsy in JavaScript! So if (!choice) would treat "win at cell 0" as "no move found" and skip right past a winning play. You must check choice === null explicitly, because null means "nothing" while 0 means "cell zero." Remember falsy values from your reference card: 0, "", null all trip !x. When 0 is a legitimate value, never test it with truthiness.

Live demo

You're X. The computer now takes wins and blocks yours. It's much harder — a draw is a good result. (It still isn't perfect; it doesn't plan two moves ahead, so a clever fork can beat it.)

Build it yourself

  1. Refactor checkWin() to checkWin(player), and update the call in makeMove to checkWin(currentPlayer).
  2. Add the findWinningMove(player) helper (simulate, check, undo).
  3. Rewrite computerMove as the three-tier priority: win → block → random.
Your task

(1) Test the block: get two X's in a row with the third cell open, and confirm the computer takes that cell. (2) Test the win-priority: set up a position where the computer can both win and needs to block — it should take its own win, not the block (winning ranks first). (3) Prove the falsy trap is real: temporarily change both checks to if (!choice), then engineer a game where the computer's only win is at cell 0 and watch it miss the win. Change it back. Seeing the bug fire is worth more than being told it exists.

What you just learned

You've now built a real opponent: it sees threats and opportunities one move deep. That "one move deep" is the only thing separating it from a perfect player.