Tutorial: Word Guess

In this tutorial you will build a Wordle-style word-guessing game. It showcases <game-tile-input> for keyboard-driven tile input, <game-word-source> for fetching real words from the API, declarative <game-audio> for sound effects, and <game-flash> for visual feedback.

By the end you will understand how to use <game-tile-input> with the GameTileSubmitEvent, how to fetch and validate words with <game-word-source>, how to create child elements programmatically inside a GameComponent, and how to score letters with the tile showResult() API.

Play the finished game

Step 1: The HTML Shell

The player gets 5 rounds, each with a different word of the day. Higher scores are better (more rounds won), so set score-order="desc". A longer between-delay gives the player time to read the feedback:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Word Guess</title>
    <link rel="stylesheet" href="path/to/htmlgamekit/src/game-base.css" />
  </head>
  <body>
    <game-shell
      id="game"
      game-id="word-guess"
      storage-key="word-guess"
      rounds="5"
      score-order="desc"
      between-delay="1200"
    >
    </game-shell>

    <script type="module" src="word-game.js"></script>
  </body>
</html>

Step 2: Words API

Add <game-word-source> inside the shell to fetch a real word from the API. In daily mode, game-id is used as a seed so this game gets its own word of the day distinct from every other game on the same API. With validate, only real dictionary words are accepted as guesses:

<game-shell
  id="game"
  game-id="word-guess"
  rounds="5"
  score-order="desc"
  between-delay="1200"
>
  <game-word-source
    words-url="https://words.htmlgamekit.dev"
    length="5"
    mode="daily"
    validate
  >
  </game-word-source>
</game-shell>

That's all the word-fetching configuration. <game-word-source> distributes the current word to child components via context — your <word-game> component subscribes to it and never needs to know about the API directly.

Mode options

mode Behaviour
daily Same word for everyone today. Repeats across all 5 rounds.
random Fresh random word on every Play.
per-round New word each of the 5 rounds — much harder!

Step 3: Overlays, HUD, and Game Area

<game-shell
  id="game"
  game-id="word-guess"
  rounds="5"
  score-order="desc"
  between-delay="1200"
>
  <game-word-source
    words-url="https://words.htmlgamekit.dev"
    length="5"
    mode="daily"
    validate
  >
  </game-word-source>

  <div when-some-scene="intro" data-overlay>
    <h1>Word Guess</h1>
    <p>
      Guess the 5-letter word in 6 tries. Green = right spot. Yellow = wrong
      spot. 5 rounds — score is how many you get right.
    </p>
    <button commandfor="game" command="--start">Play</button>
  </div>

  <div when-some-scene="playing between paused">
    <game-round-counter></game-round-counter>
  </div>

  <game-toast
    when-some-scene="playing between paused"
    trigger="pass"
  ></game-toast>
  <game-toast
    when-some-scene="playing between paused"
    trigger="fail"
  ></game-toast>
  <game-flash when-some-scene="playing between paused"></game-flash>

  <div when-some-scene="playing between paused">
    <word-game></word-game>
  </div>

  <div when-some-scene="result" data-overlay>
    <h1>Results</h1>
    <game-result-stat label="Score"></game-result-stat>
    <game-result-message when-max-score="1"
      >Tough round. Words are hard.</game-result-message
    >
    <game-result-message when-min-score="2" when-max-score="3"
      >Not bad. You know your letters.</game-result-message
    >
    <game-result-message when-min-score="4"
      >Word wizard. Impressive vocabulary.</game-result-message
    >
    <div
      style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center;margin-top:24px"
    >
      <game-share></game-share>
      <button commandfor="game" command="--restart">Play again</button>
    </div>
  </div>
</game-shell>

Step 4: Declarative Audio

Add sound effects with zero JavaScript:

<game-audio>
  <game-sample
    trigger="pass"
    type="marimba"
    scale="pentatonic"
    notes="5"
    gain="0.25"
    scale-root="440"
    scale-spacing="0.08"
  >
  </game-sample>
  <game-sample trigger="fail" type="noise" gain="0.3" duration="0.12">
  </game-sample>
</game-audio>

Step 5: The Game Component

Create word-game.js. The component subscribes to gameWordContext for the current target word, then creates <game-tile-input> rows programmatically and listens for GameTileSubmitEvent:

import {
  defineAll,
  GameComponent,
  css,
  GameRoundPassEvent,
  GameRoundFailEvent,
  gameWordContext,
} from "htmlgamekit";

class WordGame extends GameComponent {
  static styles = css`
    :host {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      position: absolute;
      inset: 0;
      gap: 8px;
      padding: 60px 20px 20px;
    }
    .board {
      display: flex;
      flex-direction: column;
      gap: 6px;
    }
  `;

  static template = `<div class="board"></div>`;
}

Step 6: Subscribing to the Word and Creating Rows

Subscribe to gameWordContext for the target word. The context updates before the scene effect fires for each new round, so this.#target is always ready:

  #board;
  #target = "";
  #row = 0;
  #round = 0;
  #rows = [];
  #done = false;

  connectedCallback() {
    this.#board = this.shadowRoot.querySelector(".board");

    this.subscribe(gameWordContext, (word) => {
      if (word) this.#target = word;
    });

    this.addEventListener("game-tile-submit", (e) => {
      if (this.#done) return;
      this.#guess(e.value.toLowerCase());
    }, { signal: this.signal });

    super.connectedCallback();
  }

  effectCallback({ scene, round }) {
    const s = scene.get();
    const r = round.get();
    if (s === "playing" && r !== this.#round) {
      this.#round = r;
      this.#newBoard();
    } else if (s !== "playing" && s !== "between") {
      this.#done = true;
    }
  }

  #newBoard() {
    this.#row = 0;
    this.#done = false;
    this.#board.innerHTML = "";
    this.#rows = [];
    for (let i = 0; i < 6; i++) {
      const tile = document.createElement("game-tile-input");
      tile.setAttribute("length", "5");
      if (i > 0) tile.disabled = true;
      this.#board.appendChild(tile);
      this.#rows.push(tile);
    }
    this.#rows[0].focus();
  }

<game-tile-input> handles all the keyboard input, tile rendering, and cursor display. You create it with document.createElement, set length, and append. Each row after the first starts disabled; your code enables them one at a time as guesses are processed. The component dispatches GameTileSubmitEvent when the player presses Enter with a full word. Since <game-word-source validate> intercepts submit events before they reach this component, you never receive guesses for words not in the dictionary — no need to validate them yourself.

Step 7: Scoring a Guess

The classic Wordle algorithm: first pass for exact matches (green), second pass for close matches (yellow):

  #guess(word) {
    if (word.length !== 5 || !this.#target) return;
    const row = this.#rows[this.#row];
    if (!row) return;

    const target = this.#target.split("");
    const states = Array(5).fill("wrong");
    const remaining = [...target];

    // First pass: exact matches
    for (let i = 0; i < 5; i++) {
      if (word[i] === target[i]) {
        states[i] = "good";
        remaining[remaining.indexOf(word[i])] = null;
      }
    }
    // Second pass: close matches
    for (let i = 0; i < 5; i++) {
      if (states[i] !== "wrong") continue;
      const idx = remaining.indexOf(word[i]);
      if (idx !== -1) {
        states[i] = "close";
        remaining[idx] = null;
      }
    }

    row.showResult(word.split(""), states);
    row.disabled = true;

row.showResult(letters, states) colours each tile: "good" (green), "close" (yellow), or "wrong" (red). These are built-in tile states styled by <game-tile-input>'s shadow CSS.

Step 8: Win, Lose, or Next Row

    if (word === this.#target) {
      this.#done = true;
      this.dispatchEvent(
        new GameRoundPassEvent(6 - this.#row, `Got it in ${this.#row + 1}!`)
      );
      return;
    }

    this.#row++;
    if (this.#row >= 6) {
      this.#done = true;
      this.dispatchEvent(
        new GameRoundFailEvent(`It was ${this.#target.toUpperCase()}`)
      );
      return;
    }

    this.#rows[this.#row].disabled = false;
    this.#rows[this.#row].focus();
  }

Step 9: Register Elements

defineAll();
WordGame.define("word-game");

What You Learned

Next Steps