Tutorial: Reaction Time

In this tutorial you will build a reaction time game. The screen turns red, then after a random delay turns green. The player clicks as fast as they can. Clicking too early resets the round without counting against them.

This tutorial builds on the Click Counter concepts and introduces FixedProgression for variable difficulty, GameRoundFailEvent with retry, and post-game result logic.

Play the finished game

Step 1: HTML with a FixedProgression

Start with the HTML skeleton. This time we add progression attributes to the shell to control difficulty -- specifically, the delay before the screen turns green:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Reaction Time</title>
    <link rel="stylesheet" href="path/to/htmlgamekit/src/game-base.css" />
  </head>
  <body>
    <game-shell
      id="game"
      game-id="reaction-time"
      storage-key="reaction-time"
      rounds="5"
      score-order="asc"
      between-delay="800"
      progression="fixed"
      progression-params='{"delay":{"start":2000,"end":4000}}'
      progression-rounds="5"
    >
      <div when-some-scene="intro" data-overlay>
        <h1>Reaction Time</h1>
        <p>
          Wait for the screen to turn green, then click as fast as you can. 5
          rounds. Don't jump the gun -- clicking too early resets the round.
        </p>
        <button commandfor="game" command="--start">Ready</button>
      </div>

      <div when-some-scene="playing between paused">
        <game-round-counter></game-round-counter>
        <game-stat key="best" format="ms">Best</game-stat>
      </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>

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

      <div when-some-scene="result" data-overlay>
        <h1>Results</h1>
        <game-result-stat
          format="ms"
          label="Average reaction time"
        ></game-result-stat>
        <game-result-message>
          <option when-max-score="999">
            Lightning fast. Are you even human?
          </option>
          <option when-min-score="1000" when-max-score="1249">
            Very quick reflexes. Well above average.
          </option>
          <option when-min-score="1250" when-max-score="1499">
            Solid reaction time. Right around the human average.
          </option>
          <option when-min-score="1500" when-max-score="1999">
            Not bad. A bit above average, but nothing to worry about.
          </option>
          <option when-min-score="2000">
            A bit slow. Maybe lay off the coffee. Or have more coffee.
          </option>
        </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">Try again</button>
        </div>
      </div>
    </game-shell>

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

The FixedProgression linearly interpolates named parameters over the course of the game. Here delay goes from 2000ms to 4000ms, so earlier rounds have shorter waits and later rounds make the player wait longer. Your game reads this value from the difficulty signal, destructured in effectCallback as { difficulty } and passed to #startRound.

The HUD tracks the player's best reaction time instead of a running total.

Step 2: Component Skeleton with Colour States

Create reaction-game.js. The component fills the entire game area and uses background colour to communicate state to the player:

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

class ReactionGame extends GameComponent {
  static styles = css`
    :host {
      display: flex;
      align-items: center;
      justify-content: center;
      position: absolute;
      inset: 0;
      cursor: pointer;
      transition: background 0.15s ease;
    }
    .prompt {
      font-size: clamp(20px, 3vw, 32px);
      font-weight: 700;
      pointer-events: none;
      user-select: none;
    }
  `;

  static template = `<div class="prompt"></div>`;

  #prompt;
  #round = 0;
  #active = false;
  #ready = false; // true once screen turns green
  #timer = 0;
  #goTime = 0;
  #best = Infinity;
  #lastDelay = 2000;
}

The component has two boolean flags: #active (the round is in progress) and #ready (the screen has turned green and the player should click). This distinction is how we detect early clicks.

Step 3: Observing Signals

Wire up connectedCallback for one-time setup and effectCallback for reactive signal observation. When a new round starts, call #startRound():

  connectedCallback() {
    this.#prompt = this.shadowRoot.querySelector(".prompt");
    super.connectedCallback();
  }

  effectCallback({ scene, round, difficulty }) {
    const s = scene.get();
    const r = round.get();
    if (s === "playing" && r !== this.#round) {
      this.#round = r;
      this.#startRound(difficulty.get());
    } else if (s !== "playing" && s !== "between") {
      this.#active = false;
      this.#ready = false;
      clearTimeout(this.#timer);
      this.style.background = "";
      this.#prompt.textContent = "";
    }
  }

Notice we also check for the "between" scene -- we do not want to reset the UI during the brief pause between rounds, only when the game is fully inactive (intro or result screen).

Step 4: The Red/Green Round Logic

The #startRound() method sets the screen to red, then after a delay (from the progression) switches to green:

  #startRound(diff) {
    clearTimeout(this.#timer);
    this.#active = true;
    this.#ready = false;
    this.style.background = "#dc2626";
    this.#prompt.textContent = "Wait for green...";

    const delay = diff?.delay ?? 2000 + Math.random() * 2000;
    this.#lastDelay = delay;

    this.#timer = setTimeout(() => {
      if (!this.#active) return;
      this.#ready = true;
      this.#goTime = performance.now();
      this.style.background = "#16a34a";
      this.#prompt.textContent = "Click!";
    }, delay);
  }

The fallback 2000 + Math.random() * 2000 handles the edge case where no director is configured. In practice the FixedProgression provides delay on every round.

Step 5: Handling Clicks -- Pass and Retry Fail

This is where the game gets interesting. A click can mean two things:

  1. Screen is green (#ready is true) -- record the reaction time.
  2. Screen is still red (#ready is false) -- too early! Retry.
// inside connectedCallback, before super.connectedCallback():

this.addEventListener(
  "click",
  () => {
    if (!this.#active) return;

    // Too early -- screen is still red
    if (!this.#ready) {
      clearTimeout(this.#timer);
      this.style.background = "#dc2626";
      this.#prompt.textContent = "Too early!";
      this.#active = false;
      this.dispatchEvent(new GameRoundFailEvent("Too early!", true));
      setTimeout(() => {
        if (this.#round) {
          this.#startRound({ delay: this.#lastDelay });
        }
      }, 1000);
      return;
    }

    // Valid click -- screen is green
    this.#active = false;
    this.#ready = false;
    const elapsed = Math.round(performance.now() - this.#goTime);
    if (elapsed < this.#best) this.#best = elapsed;
    this.dispatchEvent(new GameStatUpdateEvent("best", this.#best));
    this.dispatchEvent(new GameRoundPassEvent(elapsed, `${elapsed}ms`));
    this.style.background = "";
    this.#prompt.textContent = `${elapsed}ms`;
  },
  { signal: this.signal },
);

Key details:

Step 6: Cleanup

Add disconnectedCallback to clear any pending timeout:

  disconnectedCallback() {
    super.disconnectedCallback();
    clearTimeout(this.#timer);
  }

Step 7: Declarative Result Messages

Add a single <game-result-message> with <option> children. Each <option> carries when-min-score / when-max-score conditions -- the component picks one at random from all matching options. The score is the total across all 5 rounds, so threshold values are per-round averages multiplied by 5:

<div when-some-scene="result" data-overlay>
  <h1>Results</h1>
  <game-result-stat
    format="ms"
    label="Average reaction time"
  ></game-result-stat>
  <game-result-message>
    <option when-max-score="999">Lightning fast. Are you even human?</option>
    <option when-min-score="1000" when-max-score="1249">
      Very quick reflexes. Well above average.
    </option>
    <option when-min-score="1250" when-max-score="1499">
      Solid reaction time. Right around the human average.
    </option>
    <option when-min-score="1500" when-max-score="1999">
      Not bad. A bit above average, but nothing to worry about.
    </option>
    <option when-min-score="2000">
      A bit slow. Maybe lay off the coffee. Or have more coffee.
    </option>
  </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">Try again</button>
  </div>
</div>

The shell accumulates the raw scores, so after 5 rounds an average of 200ms/round gives a total of 1000. The 999 threshold catches anything faster, the 2000 threshold catches anything above 400ms/round average. No JavaScript needed.

Full Code

Here is the complete reaction-game.js:

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

class ReactionGame extends GameComponent {
  static styles = css`
    :host {
      display: flex;
      align-items: center;
      justify-content: center;
      position: absolute;
      inset: 0;
      cursor: pointer;
      transition: background 0.15s ease;
    }
    .prompt {
      font-size: clamp(20px, 3vw, 32px);
      font-weight: 700;
      pointer-events: none;
      user-select: none;
    }
  `;

  static template = `<div class="prompt"></div>`;

  #prompt;
  #round = 0;
  #active = false;
  #ready = false;
  #timer = 0;
  #goTime = 0;
  #best = Infinity;
  #lastDelay = 2000;

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

    this.addEventListener(
      "click",
      () => {
        if (!this.#active) return;

        if (!this.#ready) {
          clearTimeout(this.#timer);
          this.style.background = "#dc2626";
          this.#prompt.textContent = "Too early!";
          this.#active = false;
          this.dispatchEvent(new GameRoundFailEvent("Too early!", true));
          setTimeout(() => {
            if (this.#round) this.#startRound({ delay: this.#lastDelay });
          }, 1000);
          return;
        }

        this.#active = false;
        this.#ready = false;
        const elapsed = Math.round(performance.now() - this.#goTime);
        if (elapsed < this.#best) this.#best = elapsed;
        this.dispatchEvent(new GameStatUpdateEvent("best", this.#best));
        this.dispatchEvent(new GameRoundPassEvent(elapsed, `${elapsed}ms`));
        this.style.background = "";
        this.#prompt.textContent = `${elapsed}ms`;
      },
      { signal: this.signal },
    );

    super.connectedCallback();
  }

  effectCallback({ scene, round, difficulty }) {
    const s = scene.get();
    const r = round.get();
    if (s === "playing" && r !== this.#round) {
      this.#round = r;
      this.#startRound(difficulty.get());
    } else if (s !== "playing" && s !== "between") {
      this.#active = false;
      this.#ready = false;
      clearTimeout(this.#timer);
      this.style.background = "";
      this.#prompt.textContent = "";
    }
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    clearTimeout(this.#timer);
  }

  #startRound(diff) {
    clearTimeout(this.#timer);
    this.#active = true;
    this.#ready = false;
    this.style.background = "#dc2626";
    this.#prompt.textContent = "Wait for green...";

    const delay = diff?.delay ?? 2000 + Math.random() * 2000;
    this.#lastDelay = delay;

    this.#timer = setTimeout(() => {
      if (!this.#active) return;
      this.#ready = true;
      this.#goTime = performance.now();
      this.style.background = "#16a34a";
      this.#prompt.textContent = "Click!";
    }, delay);
  }
}

defineAll();
ReactionGame.define("reaction-game");

What You Learned

Next Steps