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."
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
}
}
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.
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
- Add
const HUMAN = "x";andconst COMPUTER = "o";near your state. - Create
makeMove(i)by moving the post-validation logic out ofhandleClick. - Slim
handleClickto: two guards,makeMove(i), thenif (!isGameOver) setTimeout(computerMove, 400). - Write
computerMove().
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
- Extract shared behavior:
makeMovegives both input sources one path. The board doesn't care who moves. - Guards as turn ownership:
currentPlayer !== HUMANand!isGameOvercontrol when actions are legal. - Random selection: the
arr[Math.floor(Math.random() * arr.length)]idiom. - Scheduling with
setTimeout: run a function later; pass it without parentheses.
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.
winningCombinations and the logic to test a line — the AI just runs those checks on hypothetical moves. Beyond that lies minimax, the algorithm for a perfect player. Both are coming up.