Ian Watkins iankwatkins
Lesson 6 · Game Dev Fundamentals

Highlighting the Winning Line

The game knows someone won. It just won't show you which three squares did it. Today you make the winning line light up — and you'll do it by changing what a function returns, not by adding new logic.

The core shift: return data, not a yes/no

Your checkWin() answers a yes/no question: true or false. But to highlight, you need a richer answer: which line won? The fix is to have the function return the winning line itself when there's a win, and null when there isn't.

// BEFORE: answers "did someone win?"
function checkWin() {
  for (const combo of winningCombinations) {
    if (/* all three match currentPlayer */) {
      return true;
    }
  }
  return false;
}

// AFTER: answers "which line won, if any?"
function checkWin() {
  for (const combo of winningCombinations) {
    const [a, b, c] = combo;
    if (
      board[a] === currentPlayer &&
      board[b] === currentPlayer &&
      board[c] === currentPlayer
    ) {
      return combo;        // the winning line, e.g. [0, 4, 8]
    }
  }
  return null;             // no win
}
Why this doesn't break your win check

Here's the elegant part: if (checkWin()) still works unchanged. An array like [0,4,8] is truthy; null is falsy. So your existing if reads the new return value correctly without edits. You've made the function say more without making the old caller say less. That's a clean upgrade.

Store the winning line in state

The board is rebuilt from scratch every render(), so the highlight can't live on a cell — it'd be wiped. It has to live in state, like everything else the screen depends on. Add one variable:

let winningLine = null;   // null, or an array like [0,4,8]

Set it in handleClick when a win happens:

function handleClick(i) {
  if (board[i] !== "" || isGameOver) return;
  board[i] = currentPlayer;

  const line = checkWin();      // combo or null
  if (line) {
    winningLine = line;          // remember it for render
    if (currentPlayer === "x") xScore++; else oScore++;
    isGameOver = true;
    render();
    statusEl.textContent = "Game Over! " + currentPlayer.toUpperCase() + " Wins!";
    return;
  }
  // ... draw check, then flip ...
}

And clear it in resetGame, because a fresh game has no winning line:

function resetGame() {
  // ... empty board, currentPlayer = "x", isGameOver = false ...
  winningLine = null;
  render();
}
The pattern you keep reusing

Anything the screen shows must live in state, get set when it changes, and get cleared on reset. winningLine follows the exact lifecycle as isGameOver: born null/false, set on a win, reset on new game. Once you see this rhythm, adding any feature becomes mechanical.

Render the highlight

Now render() paints the highlight from state. As you build each cell, ask: is this index part of the winning line? If so, give it an extra class.

function render() {
  boardEl.innerHTML = "";
  for (let i = 0; i < board.length; i++) {
    const cell = document.createElement("div");
    cell.className = "cell";
    cell.textContent = board[i];

    if (winningLine && winningLine.includes(i)) {
      cell.classList.add("win");   // add a SECOND class
    }

    cell.addEventListener("click", () => handleClick(i));
    boardEl.appendChild(cell);
  }
  // ... status + scores ...
}

Two new tools, and a callback to Lesson 2

winningLine.includes(i) asks "is i one of the numbers in this array?" The guard winningLine && ... short-circuits: if winningLine is null, JS never even calls .includes (you can't call a method on null), so this is safe on every non-winning render.

className vs classList.add — the moment from Lesson 2 pays off

Back in Lesson 2 I flagged that cell.className = "cell" replaces all classes, and that one day you'd need to add a class without wiping the first. This is that day. cell.classList.add("win") adds win alongside cell, giving class="cell win". If you'd written cell.className = "win" instead, you'd lose the cell styling entirely. classList is the surgical tool; className is the sledgehammer.

The CSS

Finally, define what .win looks like. Because the cell now has both classes, a .cell.win rule layers on top of the base .cell styling:

.cell.win {
  background: #b9e6c9;
  color: #1c6b3f;
}

That selector means "an element that has both cell and win." No space between them: a space would mean "a win inside a cell," which is not what you want.

Live demo

Win a game and the three winning squares turn green. Reset clears them.

Build it yourself

  1. Change checkWin() to return the combo (or null).
  2. Add let winningLine = null; to state. Set it on a win, clear it in resetGame.
  3. In render(), add the win class to cells whose index is in winningLine.
  4. Add the .cell.win CSS rule.
Your task

Get the highlight working, then test the lifecycle: win (line lights up), Play Again (line clears), win a different line (only the new three light up). If an old highlight lingers into a new game, you forgot to clear winningLine in reset — the same "did I restore all state?" lesson from Lesson 5. Bonus: change checkWin to return true again and watch the highlight break. Why? Because true has no indices to highlight. That proves why returning the data was the whole point.

What you just learned

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