Ian Watkins iankwatkins
Lesson 7 · Game Dev Fundamentals

A Computer Opponent

Your game needs two humans. Today it needs one. The computer will look at the board, pick an empty square, and play it — and the surprising lesson is how little new code that takes, because a move is a move no matter who makes it.

The key insight: the board doesn't care who moves

A human move and a computer move do the exact same things: place a mark, check for a win, check for a draw, flip the turn, render. The only difference is how the cell index gets chosen — a click, or a decision. So the first job isn't writing an AI. It's separating "which cell" from "what happens when a cell is played."

Refactor first, then add the feature

Right now all your move logic lives inside handleClick. We'll pull the "what happens on a move" part out into its own function, makeMove(i). Then a click and the computer can both call it. This is the single most useful habit in growing a program: when two things need the same behavior, give that behavior one home.

Step 1: Extract makeMove(i)

Move everything that happens after a valid move out of handleClick and into a new function. It's your existing logic, just relocated:

function makeMove(i) {
  board[i] = currentPlayer;

  const line = checkWin();
  if (line) {
    winningLine = line;
    if (currentPlayer === "x") xScore++; else oScore++;
    isGameOver = true;
    render();
    statusEl.textContent = "Game Over! " + playerNames[currentPlayer] + " Wins!";
    return;
  }

  if (isBoardFull()) {
    isGameOver = true;
    render();
    statusEl.textContent = "It's a tie, game over!";
    return;
  }

  currentPlayer = currentPlayer === "x" ? "o" : "x";
  render();
}

Step 2: Slim down handleClick

Now the click handler only does what's unique to a human click: validate it's a legal human move, then delegate. After the move, if the game's still going, hand the turn to the computer.

const HUMAN = "x";
const COMPUTER = "o";

function handleClick(i) {
  if (board[i] !== "" || isGameOver) return;   // taken or finished
  if (currentPlayer !== HUMAN) return;            // not your turn

  makeMove(i);

  if (!isGameOver) {
    setTimeout(computerMove, 400);              // let the computer respond
  }
}
Two new guards, both important

currentPlayer !== HUMAN stops you from clicking during the computer's turn (including the brief pause before it moves). And checking !isGameOver before scheduling the computer means a winning human move doesn't trigger a pointless computer reply. Guards encode when things are allowed to happen.

Step 3: The computer picks a move

The AI is three small jobs: bail if the game's over, gather the empty squares, pick one at random, play it through the same makeMove.

function computerMove() {
  if (isGameOver) return;

  // 1. collect the indices of every empty cell
  const emptyCells = [];
  for (let i = 0; i < board.length; i++) {
    if (board[i] === "") emptyCells.push(i);
  }

  // 2. pick one at random
  const choice = emptyCells[Math.floor(Math.random() * emptyCells.length)];

  // 3. play it — same path a human move takes
  makeMove(choice);
}

The random pick, decoded

Math.random() returns a decimal from 0 up to (but not including) 1. Multiply by the list length to scale it into range, then Math.floor() chops the decimal to a whole-number index:

// say emptyCells has 5 items, so valid indices are 0..4
Math.random()              // e.g. 0.734...
* emptyCells.length     // 0.734 * 5 = 3.67
Math.floor(...)            // 3  -> emptyCells[3]

Step 4: setTimeout — a gentle pause

You could call computerMove() immediately, but an instant reply feels robotic and the screen jumps. setTimeout(fn, 400) says "run fn 400 milliseconds from now," letting your move render first and giving the opponent a human-feeling beat.

Notice the echo of addEventListener

You pass computerMove without parentheses — the function itself, for setTimeout to call later. Exactly like handing resetGame to addEventListener. "Run this later" is everywhere in JavaScript, and it always means: pass the function, don't call it now. setTimeout(computerMove(), 400) would run it instantly and pass the result — the same trap as before.

Live demo

You're X. Click to play; the computer (O) answers with a random legal move. It's beatable on purpose — it has no strategy yet.

Build it yourself

  1. Add const HUMAN = "x"; and const COMPUTER = "o"; near your state.
  2. Create makeMove(i) by moving the post-validation logic out of handleClick.
  3. Slim handleClick to: two guards, makeMove(i), then if (!isGameOver) setTimeout(computerMove, 400).
  4. Write computerMove().
Your task

Get it playing, then probe the design. (1) Try clicking rapidly during the computer's 400ms pause — nothing should happen. Which guard is stopping you? (2) Win as X and confirm the computer does not move afterward. Which check prevents it? (3) Convince yourself the computer can never play a taken square — trace why, given how emptyCells is built. Answering these means you understand the control flow, not just that it works.

What you just learned

Step back: you just added a whole new kind of player by writing one genuinely new function. That's the dividend of the state/render/input architecture you've been building since Lesson 1. New move sources, new rules, new opponents — they all plug into the same loop.