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
}
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();
}
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.
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
-
Change
checkWin()to return thecombo(ornull). -
Add
let winningLine = null;to state. Set it on a win, clear it inresetGame. -
In
render(), add thewinclass to cells whose index is inwinningLine. - Add the
.cell.winCSS rule.
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
-
Return data, not a flag. A function that
returns the winning line is strictly more useful than one
returning
true— and truthiness keeps the old caller working. -
State drives every visual. The highlight
lives in
winningLineand follows the set-on-change, clear-on-reset lifecycle. -
classList.addvsclassName: add a class surgically instead of replacing all of them. -
Array
.includes()and short-circuit guards (winningLine && ...). -
Compound CSS selectors (
.cell.win= has both classes).
.filter and
Math.random), then later make it smarter. It reuses
every piece of the loop you've built. That's the next lesson.