Skip to content

Practice 4 · 2026-10-06 · 7 min read · Source code →

Hangman

ContentsShow
  1. Introduction
  2. Project setup and the static example
  3. 1. The state of one round
  4. 2. Start or restart a round
  5. 3. Turn letters into HTML
  6. 4. Count mistakes and draw the SVG
  7. 5. Detect a win or loss
  8. 6. Handle clicks with event delegation
  9. 7. The complete final script.js
  10. Classroom notes and common mistakes
  11. Self-check
  12. Task

Introduction

In Practice 3, we changed an array of students and called render() to update the attendance list. Practice 4 uses the same idea for a game. We keep the Hangman round in JavaScript variables; the word, alphabet buttons, drawing, and result are rebuilt from those variables after each guess.

Group 2 built this game on Monday, 5 October, and Group 1 worked through the same implementation on Tuesday, 6 October. Both classroom script.js files now contain the same game. This article explains that implementation and presents a clean final version. The temporary Console messages from class are explained below, but are not part of the final game.

The complete interaction is:

text
Start click → choose a word and reset guesses → render()
Letter click → record one guess → render()
render() → update the word, buttons, drawing, and result

The game ends when every letter is found or the player makes nine wrong guesses. Start new game remains visible and can reset the round at any time.

Project setup and the static example

The supplied folder has three files in starter/:

text
practice-04/
└── starter/
    ├── example.html  # a static picture of a round in progress
    ├── index.html    # the page we run
    └── script.js     # the game logic we complete

Open the task starter’s example.html first. It shows javascript after guessing j, a, b, and d. Both a positions are visible, four alphabet buttons are disabled, and two SVG lines have been drawn because b and d are wrong. It contains only HTML, so its buttons do not play a game. Its purpose is to show the markup our JavaScript will generate. In both classroom folders, the SVG inside this static example was changed to one diagonal test line; that experiment does not affect index.html or the game drawing generated by script.js.

In index.html, the same areas start empty. #game has style="display: none"; it becomes visible when a round starts. The <script> is at the end of <body>, so the elements already exist when script.js selects them. The starter supplies wordList, the lowercase alphabet, nine SVG strings in visualElements, and the DOM selections. We write the functions and event listeners below those selections.

1. The state of one round

Three variables describe the current round:

js
let targetWord = "";
let guessedLetters = [];
let gameOver = false;
  • targetWord is one entry from wordList.
  • guessedLetters holds each accepted letter once, including correct and wrong guesses.
  • gameOver stops new guesses after a win or loss.

We do not store a separate wrong-guess counter. If the word is javascript and the guesses are ["j", "a", "b", "d"], filtering out letters that occur in the word leaves ["b", "d"]. Its length is the number of drawing pieces to show. Calculating this from the existing state means it cannot drift out of sync with guessedLetters.

The DOM is the view of this state. Reading underscores or SVG lines back from the page to decide the rules would make the game harder to reason about.

2. Start or restart a round

startGame() chooses an array index, resets the state, reveals the game, and renders it:

js
function startGame() {
    targetWord = wordList[Math.floor(Math.random() * wordList.length)];
    guessedLetters = [];
    gameOver = false;
    gameDiv.style.display = "block";
    render();
}

startButton.addEventListener("click", startGame);

Math.random() gives a number from 0 up to, but not including, 1. Multiplying by wordList.length and rounding down gives a valid index. We pass startGame to addEventListener without parentheses; the browser calls it when the button is clicked.

The same function handles a restart. Resetting guessedLetters removes every used letter and every wrong guess. The next render() clears the old drawing and result message. We do not call render() on initial page load, because an empty word would make the later every(...) win check true.

3. Turn letters into HTML

The first half of render() works out what the current state means. The second half writes the resulting HTML. To draw the word, turn it into an array of characters, map each character to a table cell, and join the strings:

js
tr.innerHTML = targetWord.split("").map((letter) =>
    `<td>${guessedLetters.includes(letter) ? letter : "_"}</td>`
).join("");

split("") turns "javascript" into individual letters. map makes one <td> for every position. includes decides whether to show that letter or _. Because each position is checked, guessing a reveals both occurrences. join("") makes one string for innerHTML; leaving it out would insert commas between the cells.

The alphabet buttons use the same split → map → join pattern:

js
buttonsDiv.innerHTML = alphabet.split("").map((letter) =>
    `<button type="button" ${gameOver || guessedLetters.includes(letter) ? "disabled" : ""}>${letter}</button>`
).join("");

A used letter is disabled. When the round has ended, every letter is disabled. The disabled attribute is boolean in HTML: its presence disables a button, even if written as disabled="false". We therefore generate either disabled or nothing.

4. Count mistakes and draw the SVG

The starter’s visualElements array contains nine SVG shapes, in drawing order. Count guesses that are absent from the target word:

js
const wrongGuesses = guessedLetters.filter(
    (letter) => !targetWord.includes(letter)
).length;

Then take exactly that many shapes:

js
svg.innerHTML = visualElements.slice(0, wrongGuesses).join("");

With zero wrong guesses, slice(0, 0) returns an empty array and the SVG is empty. After two wrong guesses it returns the base and upright lines shown in example.html. A correct guess changes the word but leaves the drawing alone. Restarting with an empty guessedLetters array clears it.

5. Detect a win or loss

The player wins when every character in the target word appears among the guesses:

js
const won = targetWord.split("").every(
    (letter) => guessedLetters.includes(letter)
);

Repeated letters do not need repeated guesses. If a is present in guessedLetters, every a in the word passes the test. The player loses when the number of wrong guesses reaches the number of SVG parts:

js
const lost = wrongGuesses >= visualElements.length;
gameOver = won || lost;

Compute these values before rendering the buttons. On the final click, gameOver is already true, so that same render disables every button. Set the result text to a win message, a loss message including the answer, or an empty string while the round is still running.

The win and loss checks depend on the JavaScript state. They do not inspect the table cells or count elements already in the SVG.

6. Handle clicks with event delegation

There is one listener on the permanent button container:

js
buttonsDiv.addEventListener("click", handleLetterClick);

When a button is clicked, its click event bubbles up to buttonsDiv. In the handler, event.target is the clicked button and event.currentTarget is the container. The handler rejects a click on empty container space, a click after the round ends, and a repeated letter. An accepted letter is pushed into guessedLetters, followed by one call to render():

js
function handleLetterClick(event) {
    if (gameOver || !event.target.matches("button")) {
        return;
    }

    const letter = event.target.textContent;
    if (guessedLetters.includes(letter)) {
        return;
    }

    guessedLetters.push(letter);
    render();
}

The parent listener survives every rebuild of the letter buttons. This is the same delegation idea we used for generated attendance checkboxes in Practice 3.

7. The complete final script.js

This is the clean version of the game. It includes the supplied constants and selectors so you can read the whole file in one place:

js
const wordList = ["javascript", "programming"];
const alphabet = "abcdefghijklmnopqrstuvwxyz";
const visualElements = [
    `<line x1="0" y1="99%" x2="100%" y2="99%" />`,
    `<line x1="20%" y1="99%" x2="20%" y2="5%" />`,
    `<line x1="20%" y1="5%" x2="60%" y2="5%" />`,
    `<line x1="60%" y1="5%" x2="60%" y2="20%" />`,
    `<circle cx="60%" cy="30%" r="10%" />`,
    `<line x1="60%" y1="30%" x2="60%" y2="70%" />`,
    `<line x1="40%" y1="50%" x2="80%" y2="50%" />`,
    `<line x1="60%" y1="70%" x2="50%" y2="90%" />`,
    `<line x1="60%" y1="70%" x2="70%" y2="90%" />`
];

const controlsDiv = document.querySelector("#controls");
const startButton = document.querySelector("#start");
const resultDiv = document.querySelector("#result");
const gameDiv = document.querySelector("#game");
const tr = document.querySelector("tr");
const buttonsDiv = document.querySelector("#buttons");
const svg = document.querySelector("svg");

let targetWord = "";
let guessedLetters = [];
let gameOver = false;

function render() {
    const wrongGuesses = guessedLetters.filter(
        (letter) => !targetWord.includes(letter)
    ).length;
    const won = targetWord.split("").every(
        (letter) => guessedLetters.includes(letter)
    );
    const lost = wrongGuesses >= visualElements.length;
    gameOver = won || lost;

    tr.innerHTML = targetWord.split("").map((letter) =>
        `<td>${guessedLetters.includes(letter) ? letter : "_"}</td>`
    ).join("");

    buttonsDiv.innerHTML = alphabet.split("").map((letter) =>
        `<button type="button" ${gameOver || guessedLetters.includes(letter) ? "disabled" : ""}>${letter}</button>`
    ).join("");

    svg.innerHTML = visualElements.slice(0, wrongGuesses).join("");
    resultDiv.textContent = won
        ? "You won!"
        : lost
            ? `You lost! The word was ${targetWord}.`
            : "";
}

function startGame() {
    targetWord = wordList[Math.floor(Math.random() * wordList.length)];
    guessedLetters = [];
    gameOver = false;
    gameDiv.style.display = "block";
    render();
}

function handleLetterClick(event) {
    if (gameOver || !event.target.matches("button")) {
        return;
    }

    const letter = event.target.textContent;
    if (guessedLetters.includes(letter)) {
        return;
    }

    guessedLetters.push(letter);
    render();
}

startButton.addEventListener("click", startGame);
buttonsDiv.addEventListener("click", handleLetterClick);

The starter selects controlsDiv even though this version does not change it: the Start button and result area remain visible throughout the round.

Classroom notes and common mistakes

Both classroom files contain console.log calls for the selected word, generated HTML, and guessed letters. They made intermediate values visible while building the game. Remove the selected-word log for a normal game: it gives away the answer in the Console.

The classroom handler already checked gameOver, so extra clicks could not change a finished round. Its button template only disabled letters that had been guessed, however, leaving the other buttons looking clickable. The final version also checks gameOver while rendering buttons, so their appearance matches their behavior.

SymptomWhat to check
Commas appear between cells or buttonsAdd join("") after map(...).
A repeated letter reveals only one positionRender every position from targetWord; do not change one cell by hand.
Buttons stop responding after the first guessAttach the listener to buttonsDiv, which is not replaced.
Guesses appear not to match buttonsIf button text includes indentation, use textContent.trim().
The drawing advances on correct guessesFilter for letters absent from targetWord.
Restart keeps the old drawing or messageReset guessedLetters and let render() rebuild both.
A finished game still looks playableInclude gameOver when generating disabled attributes.

Self-check

  1. Why does one guess of a reveal two cells in javascript?
  2. What does map(...) return, and why do we call join("") afterward?
  3. Why is wrongGuesses calculated instead of stored as another variable?
  4. What do event.target and event.currentTarget refer to during a letter click?
  5. Why does the delegated listener still work after buttonsDiv.innerHTML changes?
  6. What is the difference between gameOver blocking the handler and disabled on the buttons?
  7. Why is render() called after startGame() selects a word, rather than on initial page load?

Task

Practice 4 — Hangman lets you rebuild the game from the starter in five checkpoints. Test a correct letter, a wrong letter, a repeated letter, a win, a loss after nine wrong guesses, and a fresh round after each ending.