Magyar zászlóMagyar

Practice 4 - Hangman (State-Driven UI)

2025-03-06 4 min read GitHub

Introduction

In this practice, we build a small but complete game: Hangman.

The goal is not only the game itself, but also a key frontend mindset:

  • keep your state in JavaScript variables,
  • render the DOM from that state,
  • and re-render after every user action.

This is the same pattern used in modern frameworks (React, Vue, Svelte), only here we do it manually in vanilla JavaScript.

By the end of this session you will be able to:

  1. Model game state with variables (targetWord, guessedLetters, gameOver)
  2. Build a simple UI lifecycle (start -> render -> user input -> render)
  3. Use event delegation for dynamic button lists
  4. Check win conditions with array methods (every, includes)
  5. Understand why state-driven rendering reduces bugs

Project Structure

p4/
├── index.html   # Game layout (controls + hidden game area)
└── main.js      # Game logic and rendering

HTML Overview

index.html contains two main sections:

  1. #controls (visible at first):
    • start button
    • result message
  2. #game (hidden initially):
    • table row for the masked word
    • alphabet buttons container
    • SVG placeholder for drawing the hangman later
<h1>Hangman</h1>
<div id="controls">
  <button id="start">Start new game</button>
  <div id="result"></div>
</div>

<div id="game" style="display: none">
  <table>
    <tr></tr>
  </table>
  <div id="buttons"></div>
  <svg width="200px" height="200px" stroke="black"></svg>
</div>
ℹ️

The SVG is currently just a placeholder. This is great design: we can add losing logic and drawing phases later without changing the base page structure.


1. Game State

At the top of main.js, we store the whole game in a few variables:

const wordList = ["javascript", "programming"];

let targetWord;
let guessedLetters;
let gameOver = false;

Why this matters

  • targetWord: the selected word for the current round
  • guessedLetters: all letters guessed so far
  • gameOver: blocks input after finishing

This is your source of truth. The DOM should be treated as a visual output, not as the real data model.


2. Starting a New Game

The startGame() function initializes state and toggles visibility:

function startGame() {
  targetWord = wordList[Math.floor(Math.random() * wordList.length)];
  guessedLetters = [];
  gameOver = false;

  document.querySelector("#controls").style.display = "none";
  document.querySelector("#game").style.display = "block";

  renderState();
}

document.querySelector("#start").addEventListener("click", startGame);

Key idea

startGame() does not manually paint each small UI part. It just resets state and calls renderState(). That keeps responsibilities clean.


3. Render Lifecycle

The central function:

function renderState() {
  renderButtons();
  renderWord();
  checkEndGame();
}

Think of it as a mini render pipeline:

  1. draw alphabet buttons,
  2. draw current word mask,
  3. evaluate if the game has ended.

Whenever state changes, call renderState().

💡

A reliable rule: if a user action changes data, call a single render entry point right after.


4. Rendering the Alphabet Buttons

function renderButtons() {
  const ABC = "ABCDEFGHIJKLMNOPQRSTVUWXYZ";

  document.querySelector("#buttons").innerHTML = ABC.split("")
    .map((letter) => {
      return `<button id=${letter}
        ${guessedLetters.includes(letter.toUpperCase()) ? "disabled" : ""}
      >${letter}</button>`;
    })
    .join("");
}

What is happening

  • ABC.split("") creates an array of characters.
  • map(...) converts each letter to <button>...</button> HTML.
  • Already-guessed letters are rendered with disabled.
  • join("") merges everything into one HTML string.

This is the same list-rendering pattern you used in Practice 3.

Small quality note

The string "ABCDEFGHIJKLMNOPQRSTVUWXYZ" has an unusual order around U/V/W. For now this does not break the architecture, but you may want to replace it with a correctly ordered alphabet constant later.


5. Rendering the Word Mask

function renderWord() {
  const lines = document.querySelector("tr");

  lines.innerHTML = targetWord.split("")
    .map((char) => {
      return `<td>${guessedLetters.includes(char.toUpperCase()) ? char : '_'}</td>`;
    })
    .join("");
}

Logic

For each character in targetWord:

  • if guessed, show the real letter,
  • otherwise show _.

This gives instant visual feedback after each click.


6. Handling Letter Clicks (Event Delegation)

function handleButtonClick(e) {
  if (!gameOver && e.target.matches("button")) {
    guessedLetters.push(e.target.innerText.toUpperCase());
    renderState();
  }
}

document.querySelector("#buttons").addEventListener("click", handleButtonClick);

Why delegation is perfect here

The letter buttons are recreated with innerHTML on every render. If you attached listeners to each button individually, they would be lost after rerender.

Instead, one listener on #buttons catches all button clicks through event bubbling.


7. Win Detection

function checkEndGame() {
  if (targetWord.toUpperCase().split("").every(letter => guessedLetters.includes(letter))) {
    document.querySelector("#result").innerText = `Congratulations, you have guessed the word!`;
    document.querySelector("#controls").style.display = "block";
    gameOver = true;
  }
}

Why every(...)

every(...) returns true only when all letters satisfy the condition.

Condition here: each letter in targetWord must exist in guessedLetters.

When true:

  • show success message,
  • show controls again,
  • lock interactions with gameOver = true.

Data Flow Summary

Start button click
    -> startGame()
    -> state reset (targetWord, guessedLetters, gameOver)
    -> renderState()

Letter click
    -> handleButtonClick()
    -> guessedLetters update
    -> renderState()
    -> checkEndGame()

This is the complete loop of an interactive UI.


Suggested Extensions

  1. Add lose condition:
    • introduce wrongGuesses
    • stop game after N wrong letters
  2. Draw the hangman in the SVG step by step.
  3. Add a Play again button that resets both state and UI.
  4. Show used letters in a dedicated area (e.g., Used: A, C, T).
  5. Improve accessibility:
    • add aria-label to controls,
    • ensure focus styles are visible.

Common Pitfalls

  • Forgetting to reset guessedLetters when starting a new game
  • Adding duplicate letters to guessedLetters (disabled buttons usually prevent this)
  • Reading game state from DOM text instead of JS variables
  • Updating one part of UI but forgetting to rerender the others
⚠️

State first, UI second. If your state is correct and renderState() is consistent, the UI will stay correct too.


Takeaway

Practice 4 is your first full mini-application with a clear architecture.

The most important lesson is not the game rules. It is the pattern:

  1. keep state in JavaScript,
  2. render from state,
  3. rerender after actions,
  4. keep event handling simple with delegation.

If you master this pattern now, larger frontend projects will feel much more manageable later.