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:
- 1Win now. Is there a move that completes three-in-a-row for me? Play it.
- 2Block. Otherwise, could the human win next turn? Take that square away.
- 3Otherwise, pick randomly from what's left.
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.
"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()
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
}
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."
=== 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
- Refactor
checkWin()tocheckWin(player), and update the call inmakeMovetocheckWin(currentPlayer). - Add the
findWinningMove(player)helper (simulate, check, undo). - Rewrite
computerMoveas the three-tier priority: win → block → random.
(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
- Simulate → check → undo: the foundational technique of game AI. Try a move on the real board, evaluate, restore.
- Remove hidden dependencies:
checkWin(player)beats a version that secretly reads a global — explicit inputs make functions reusable. - Strategy = ordered priorities: win > block > random, expressed as fall-through checks.
- The falsy-zero trap: when
0is a real value, test with=== null, never!x.
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.