Ian Watkins iankwatkins
Lesson 4 · Game Dev Fundamentals

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:

0
1
2
3
4
5
6
7
8
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
];
Why data beats a wall of ifs

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();
}
Trace it once, slowly

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

  1. Add the winningLines array and the isGameOver flag up top.
  2. Write checkWin().
  3. In handleClick: add || isGameOver to the guard, and insert the win check between placing the mark and flipping the player.
Your task

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

That's all ten lessons! See your course progress and celebration →