Detecting a Win
A game you can't win isn't a game yet. Today the program learns to recognize three-in-a-row, declare a winner, and stop the play. This is where rules stop being about a single move and start being about the whole board.
The new idea: rules as data
There are exactly eight ways to win tic-tac-toe: three rows,
three columns, two diagonals. The naive approach is a wall of
if statements. The professional approach is to
treat the winning lines as data — a list
you can loop over — just like the board itself.
Each line is the three board indices that must match. Picture the indices on the grid:
const winningLines = [
[0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
[0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
[0, 4, 8], [2, 4, 6] // diagonals
];
An array of lines is shorter, readable, and — the real payoff — portable. The same checking loop works for any board if you swap the lines. Encoding rules as data instead of branches is one of the highest-leverage habits in game programming. You'll do the same thing for a chessboard or a match-3 grid.
The check
Loop the lines. For each, ask: are all three of those board slots the current player's mark? If any line passes, that's a win.
function checkWin() {
for (const line of winningLines) {
const [a, b, c] = line; // the three indices
if (
board[a] === currentPlayer &&
board[b] === currentPlayer &&
board[c] === currentPlayer
) {
return true; // found a winning line, stop early
}
}
return false; // checked all eight, no win
}
The ordering that makes it correct
This is the crux, and it's the same instinct from Lesson 3.
checkWin() tests against
currentPlayer, so it must run
after you place the mark but before you flip the
player:
function handleClick(i) {
if (board[i] !== "" || isGameOver) return;
board[i] = currentPlayer; // 1. place the mark
if (checkWin()) { // 2. did THAT move win? (player not yet flipped)
isGameOver = true;
render();
statusEl.textContent = currentPlayer.toUpperCase() + " wins!";
return; // stop: no flip, game is done
}
currentPlayer = currentPlayer === "x" ? "o" : "x"; // 3. otherwise flip
render();
}
If you flipped the player before
checkWin(), you'd be asking "did the player who
didn't just move win?" The answer is always no, and
your game would never detect a victory. Place, check, then
flip. Use the value before you change it — the same
rule, now protecting your win logic.
Stopping the game: the isGameOver flag
A won game must reject further clicks. One more piece of state — a boolean — does it:
let isGameOver = false;
It's checked in the guard clause (|| isGameOver) so
every click after a win bails out immediately, and set to
true the moment someone wins. This is a common
pattern: a single boolean that gates the whole interaction.
Pause, game-over, and "loading" screens all work this way.
Live demo
Play until someone wins. The status announces the winner and the board locks — further clicks do nothing.
Build it yourself
-
Add the
winningLinesarray and theisGameOverflag up top. - Write
checkWin(). -
In
handleClick: add|| isGameOverto the guard, and insert the win check between placing the mark and flipping the player.
Get a win working, then probe the ordering: temporarily move
the win check to after the player flip. Play a
winning line and watch the victory never get announced.
Predict why first (whose mark is
checkWin looking for now?), then confirm, then
put it back. You proved the guard clause this way in Lesson
2; do the same for win detection.
What you just learned
-
Rules as data: the eight winning lines are
an array you loop over, not a thicket of
ifs. Portable and readable. -
Destructuring (
const [a,b,c] = line) and the logical operators&&,||. - Early return to stop checking once a line matches.
- Ordering, again: place → check → flip. The win test depends on the player not yet being flipped.
-
A boolean flag (
isGameOver) to gate all further input.