Build a Claude Code Game Mod

Build a Claude Code mod that opens a typing game during each main turn.

Introduction

30 Second Summary

Waiting for a coding task to finish often means watching a screen with nothing useful to do. The finish can also be easy to miss once your attention drifts elsewhere.

In this project, you will build a shareable Claude Code mod that turns that wait into a typing game. Lifecycle hooks open the game during a main turn before showing a completion banner when Claude finishes.

What You'll Build

Picture a compact typing sprint in Safari beside an active Claude Code turn before a bright Claude is done banner brings you back to the completed task.

By the end of this project, you'll have:

  • An automatic typing sprint that opens when turn.start detects a main Claude turn. You can play during the wait.
  • A visible finish signal that opens when turn.complete fires. Safari shows Claude is done when the main turn ends.
  • A persistent best score powered by localStorage. Your personal record survives normal Safari reloads and window closes.
  • Secret Mission: A zipped plugin that a friend loads for one session. Your proof screenshot captures the game beside an active Claude Code turn.

Are there any prerequisites?

You need a Mac that can run macOS 15 or later plus an existing signed-in Claude Code installation at version 2.1.287 or later. Complete any required macOS upgrade before the timed project because its download and restart sit outside the 55-minute estimate.

Before We Start

This step locks in the behavior you are about to build before the hands-on work begins. Safari should open a typing game when a main Claude turn starts, then show a clear completion change when that turn ends.

Qualify Your Mac and Set Up Ghostty

Your browser game depends on a Mac setup that supports Intermission's terminal integration. An incompatible macOS version could stop the later demo before your mod runs.

This step confirms macOS 15 readiness. You will also get Ghostty version 1.4.0 running with Claude Code version 2.1.287 or later.

In this step, get ready to:
  • Confirm that your Mac runs macOS 15 or later.
  • Get Ghostty version 1.4.0 running from /Applications.
  • Prepare ~/projects with a compatible Claude Code session.
Check your macOS version

Intermission requires macOS 15 or later. Ghostty version 1.4.0 supports earlier macOS releases, so Intermission sets the stricter requirement for this project.

  • Click the Apple menu in the top-left corner of your screen.
  • Select About This Mac.

You will see the installed macOS name with its version number. Choose the tab that matches what you see.

✔️ My Mac meets the requirement

Your Mac is ready if the displayed version is macOS 15 or later.

  • Continue to the Ghostty setup below.

ⓧ My Mac needs an upgrade

A macOS upgrade can take a while. Its download time depends on your internet connection, so complete it before starting the project's 55 minutes.

  • Press Cmd+Space to open macOS search.
  • Type Software Update into the search field.
  • Press Return.
  • Install macOS 15 or later by following the on-screen steps.
  • Return to this project after your Mac restarts.
  • Check About This Mac again to confirm the new version.

You should now see macOS 15 or later.

ⓧ I cannot find the version

  • Close the current information window.
  • Click the Apple menu in the top-left corner.
  • Select About This Mac.

Look beneath the macOS name for its version number. Confirm that the first number is 15 or higher.

Get Ghostty running

Ghostty provides the terminal where you will run Claude Code. Its macOS installer uses a .dmg file that places the app in /Applications.

Which Ghostty state matches your Mac?

✔️ The current version is installed

  • In Finder, click Applications in the sidebar.
  • Locate Ghostty in the Applications list.
  • Press Cmd+Space to open macOS search.
  • Type Ghostty into the search field.
  • Press Return.

You should see a Ghostty terminal window with a command prompt.

ⓧ An older version is installed

  • Open the official Ghostty download page in your browser.
  • Download the macOS .dmg for version 1.4.0.
  • Click Downloads in the Finder sidebar.
  • Double-click the downloaded Ghostty .dmg file.
  • Drag Ghostty into Applications.
  • Approve the replacement if macOS detects the older copy.
  • Press Cmd+Space to open macOS search.
  • Type Ghostty and press Return.

You should see a Ghostty terminal window from the updated app in /Applications.

ⓧ Ghostty is not installed

  • Open the official Ghostty download page in your browser.
  • Download the macOS .dmg for version 1.4.0.
  • Click Downloads in the Finder sidebar.
  • Double-click the downloaded Ghostty .dmg file.
  • Drag Ghostty into Applications.
  • Press Cmd+Space to open macOS search.
  • Type Ghostty and press Return.

You should see a Ghostty terminal window with a command prompt.

That takes care of the terminal requirement. Ghostty version 1.4.0 is now installed in /Applications and running.

Prepare the workspace

The ~/projects directory gives every later step a predictable location. Safari is included with macOS, so it is ready for the browser game checks.

  • Return to the Ghostty window you just opened.

Before you run these commands, do you expect your existing Claude Code installation to meet the mod requirement?

  • Create the local workspace and check Claude Code by running these commands:
mkdir -p ~/projects
cd ~/projects
claude --version

What do these commands do?

  • The mkdir -p ~/projects command creates the workspace with any missing parent directories.
  • The cd ~/projects command moves your Ghostty session into that workspace.
  • The claude --version command prints the installed Claude Code version.

The final line of output contains your Claude Code version. Choose the tab that matches what you see.

✔️ The version meets the minimum

Claude Code is ready if the version is 2.1.287 or later. Your Ghostty prompt is also working inside ~/projects.

ⓧ I see an older version

  • Update Claude Code by running this command:
claude update

What does this command do?

Claude Code checks for a newer release. It installs the update when one is available.

  • Check the updated version by running this command:
claude --version

Why check again?

This second check proves that the update is active in Ghostty. The project requires version 2.1.287 or later.

You should now see version 2.1.287 or later.

ⓧ The command is unavailable

The missing command means Ghostty cannot find the Claude Code installation. The official native installer restores the command for this terminal environment.

  • Install the Claude Code command by running the official installer:
curl -fsSL https://claude.ai/install.sh | bash

What does this command do?

This command downloads the official Claude Code installer from Claude's website. It runs the installer in your shell.

The installer can stay quiet while it downloads. That silence is expected.

  • Keep the Ghostty window open until the installation completes.
  • Move back into the workspace and check the installed version by running:
cd ~/projects
claude --version

What do these commands confirm?

The first command restores the intended workspace location. The second command proves that Ghostty can now find Claude Code.

You should see version 2.1.287 or later.

Before you start Claude Code, where do you expect its interactive session to begin?

  • Start Claude Code from the current workspace by running this command:
claude

What does this command do?

The claude command starts an interactive Claude Code session. It keeps ~/projects as the session's current directory.

You should see the Claude Code interactive interface inside Ghostty. Your session is now running from ~/projects.

Claude Code does not start?

Confirm that the earlier version check printed 2.1.287 or later. A failed version check means the command still needs an update or installation fix.

If Claude Code asks you to authenticate again, complete its browser sign-in flow. Return to the same Ghostty window afterward.

help me get Claude Code running inside Ghostty

Your setup checkpoint

  • Claude Code is available in Ghostty with version 2.1.287 or later.
  • Ghostty version 1.4.0 is installed in /Applications and open.
  • The ~/projects directory exists as your local project workspace.
  • Safari is available for the browser game.

Your Mac now meets the requirements for the mod workflow. Next, you will play Intermission and connect its hooks to the behavior you can see.

Play Intermission and Decode Its Hooks

Your Mac is ready. Ghostty gives Claude Code a compatible terminal for this project.

Lifecycle events can feel abstract when you only read their names. Playing Intermission first turns those names into a sequence you can see.

In this step, get ready to:
  • Install Intermission 0.1.5 from its marketplace.
  • Experience Doom during a main Claude turn.
  • Group the seven hook events by their roles.
Install and enable Intermission

Intermission is a finished function-hook mod that responds to Claude Code activity. Testing it gives you a visible reference for the smaller mod you build next.

  • Continue in the Claude Code session inside Ghostty.
  • Add Intermission from its marketplace and switch it on by entering these commands one at a time:
/plugin install intermission --marketplace jarrodwatts/intermission
/intermission

What do these commands do?

The first command installs Intermission from its marketplace. The second command enables the mod for Claude Code.

  • Wait for the installation to finish.
  • Confirm that Claude Code shows Intermission as enabled.

That setup is complete. Intermission 0.1.5 is ready to react to your next main turn.

Installation did not finish?

  • Compare both slash commands with the block above.
  • Check that the marketplace owner and repository remain joined by the forward slash.

Help me troubleshoot the Intermission installation in Claude Code.

Play during a Claude turn

Intermission connects a main turn to a short game session. The turn lifecycle determines when play begins and when your attention returns to Claude Code.

Before you send the task, do you expect the game to remain open after Claude finishes?

  • Send Claude a small task that takes a few seconds.
  • Wait for the Intermission pane to appear after its delay.
  • Click the Intermission pane.
  • Play briefly with WASD or the arrow keys.
  • Continue until Claude finishes the task.

You'll see Doom open during the turn. When the main turn finishes, you'll see Intermission hand control back to Claude Code.

You have just watched a turn lifecycle control something visible. That sequence is the model for your own mod.

Pane does not fit?

  • Follow Intermission's on-screen offer to open the pane if Ghostty is too narrow.
  • Confirm that Intermission remained enabled if no pane appeared.

Help me troubleshoot an Intermission pane that does not appear.

Decode the lifecycle hooks

A function-hook mod listens for named events and runs shared JavaScript when they occur. Reading Intermission's source connects each event name to behavior you have already seen.

Before you ask Claude, which hooks do you expect to control play and which hooks do you expect to control the interface?

  • Ask Claude to inspect the canonical source by sending this prompt:
Inspect https://github.com/jarrodwatts/intermission/blob/main/hooks/register.js. Explain what turn.start, turn.complete, tool.check, tool.call, ui.close, ui.message, and ui.render do in this mod. Group them into starting play, returning attention, and drawing the interface.

How do the hooks fit together?

  • The turn.start event begins play for a main turn. The turn.complete event returns attention when that turn ends.
  • The tool.check and tool.call events track tool activity that may need the learner's attention.
  • The ui.close, ui.message, and ui.render events manage the interface.
  • Read Claude's explanation of the source.
  • Compare its three groups with the hook map above.
  • Confirm that all seven event names appear in the explanation.

You can now trace the experience from a main turn starting to the interface returning your attention. The event names describe the same sequence you just played.

Claude cannot inspect the source?

  • Check that the source URL exactly matches the prompt above.
  • Retry after confirming that your Mac has an internet connection.

Help me understand the Intermission hook source.

You have seen a finished mod connect hook events to visible behavior. Next, you will use the same event-driven pattern to create your first working while-you-wait mod.

Create Your First Working Mod

You have seen Intermission turn a Claude Code lifecycle event into a playable interruption.

Now you will build a smaller function-hook mod that opens Safari when a main turn starts. This first slice isolates turn.start so you can see the event-to-browser path clearly.

In this step, get ready to:
  • Scaffold the source folder for your while-you-wait mod.
  • Validate the generated plugin structure.
  • Trigger the start hook in a development session.
Scaffold the plugin with Claude

Claude Code can create a no-build mod directly from a precise request. The request keeps file access inside the Mods API boundary.

  • Switch back to Claude Code in the Ghostty session from earlier.
  • Review each requested file path if Claude Code asks for permission.
  • Approve each request that stays inside ~/projects/while-you-wait.
  • Submit this scaffold request:
Create a Claude Code mod named while-you-wait in this folder. Give it .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.js, and game/index.html. Use turn.start for main turns only. Read the HTML from $.plugin.root, write a rendered copy under $HOME/.claude/while-you-wait with $.fs.write, and launch it with $.process.run(['open', path]). Start with a static page that says Claude is working. Use only the Mods API, no Node.js imports. Validate the plugin when finished.

What is Claude building?

  • The plugin manifest in .claude-plugin/plugin.json gives the mod its identity.
  • The hooks/hooks.json file tells the hook loader to use ./register.js.
  • The turn.start hook reacts only when e.agentId is absent.
  • The hook returns next(e) so Claude's turn continues normally.
  • The game/index.html file provides the static Claude-is-working page.
  • The $.fs.write call creates the rendered page beneath ~/.claude/while-you-wait.
  • Wait for Claude to finish creating the plugin files.
  • Review Claude's completion summary for the generated paths.

You should see .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.js, and game/index.html listed beneath ~/projects/while-you-wait.

Were the files created elsewhere?

  • Check that Claude Code started from ~/projects.
  • Ask Claude to move the generated folder to ~/projects/while-you-wait if it used another location.

If the scaffold still does not match the requested structure, help me check where Claude created my while-you-wait plugin files.

Validate the generated plugin

A valid folder structure lets Claude Code discover the manifest. It also connects the hook declaration to the JavaScript module.

  • Review .claude-plugin/plugin.json in Claude's generated changes.

You should find the name while-you-wait with version 0.1.0. The remaining fields provide a description plus author metadata.

  • Review hooks/hooks.json in the generated changes.

You should find a module declaration pointing to ./register.js.

  • Review hooks/register.js in the generated changes.

What should the hook preserve?

  • The registration listens for turn.start.
  • The e.agentId === undefined check limits the browser launch to the main conversation.
  • The $.plugin.root path locates game/index.html inside the plugin.
  • The $.fs.read call reads the static page.
  • The $.fs.write call writes the rendered local copy.
  • The $.process.run call opens that copy through the macOS open utility.
  • The return next(e) line lets the turn continue.
  • Return to the Ghostty shell at ~/projects after you finish the review.

Before you run the validator, do you expect the generated structure to pass on the first attempt?

  • Validate the plugin by running this command:
claude plugin validate ./while-you-wait

What does this command check?

The validator checks whether Claude Code can recognize the plugin structure. A successful result proves the manifest plus hook declaration can be loaded.

You should see Validation passed. That result confirms your first mod has crossed Claude Code's plugin boundary.

Did validation fail?

  • Confirm the shell path still ends in ~/projects.
  • Check that .claude-plugin/plugin.json sits directly inside while-you-wait.
  • Check that hooks/hooks.json points to ./register.js.

For help interpreting the validator output, help me fix my while-you-wait plugin validation failure.

  • Start a development session with the local plugin by running this command:
claude --plugin-dir ./while-you-wait

What does this command do?

The --plugin-dir flag loads while-you-wait for this Claude Code session. This keeps development local while you test the hook.

You should see a fresh Claude Code session ready for a prompt.

Did the development session stop early?

  • Confirm the validation command passed before loading the plugin.
  • Confirm ./while-you-wait resolves from ~/projects.

If the session still closes, help me troubleshoot loading my local Claude Code plugin.

Trigger the start hook

The development session now watches main-turn lifecycle events through your local plugin. A short task gives the start hook enough time to produce a visible browser result.

Before you send the task, do you expect Safari to react at the beginning of the turn, at the end, or at both moments?

  • Send Claude a small task that takes a few seconds.

At the start of the main turn, you should see Safari open the rendered page. The page says Claude is working.

Did Safari stay closed?

  • Confirm the current Claude Code session loaded ./while-you-wait.
  • Confirm the hook listens for turn.start.
  • Confirm the process call passes open plus the rendered file path.

For help tracing the missing browser launch, help me debug why my turn.start hook does not open Safari.

  • Keep the Safari page visible until Claude's turn completes.
  • Return to the page after Claude finishes.

The page still says Claude is working after the turn ends. This shortfall is the intended result of the first version.

Why does the page stay unchanged?

Your mod currently observes only turn.start. No completion hook exists to render a finished state when Claude returns control.

The next step connects turn.complete to a second browser render. That missing lifecycle half becomes the completion signal.

✔️ My plugin opens the page

Your source folder has passed validation. Your start hook now turns a main Claude turn into visible browser behavior.

ⓧ I'd like to double check the generated files

  • Confirm .claude-plugin/plugin.json names the plugin while-you-wait.
  • Confirm hooks/hooks.json loads ./register.js.
  • Confirm hooks/register.js registers a main-turn-only turn.start hook.
  • Confirm the hook reads game/index.html through $.plugin.root.
  • Confirm the hook writes the rendered page beneath ~/.claude/while-you-wait.
  • Confirm the hook opens the rendered page with $.process.run.
  • Confirm the hook reports failures with $.ui.toast.
  • Confirm the hook returns next(e).
  • Confirm game/index.html contains the initial static Claude-is-working page.

Your start hook works from event to browser. Next, you will turn the static page into a typing game that reacts when Claude finishes.

Add the Completion-Aware Typing Game

Your start hook already proves that Claude Code can open Safari when a main turn begins. The static page stays frozen after Claude finishes.

This step adds the missing lifecycle hook for completed turns. It also replaces the placeholder page with a typing game that renders a different browser state for each stage of the turn.

In this step, get ready to:
  • Connect the start hook to a playable typing game.
  • Add a completion hook that renders the finished state.
  • Verify that the input locks when Claude finishes.
Connect the full turn lifecycle

One source page can support both stages by placing the __CLAUDE_STATUS__ token on its body. Each hook replaces that token before it writes a rendered page.

  • Switch back to the Claude Code development session from the previous step.
  • Ask Claude to build both game states by sending this prompt:
Upgrade the while-you-wait mod. Keep the game in one source file at game/index.html. Put a __CLAUDE_STATUS__ token on the body. Add a typing game with a phrase, text input, current score, and clear working styling. Update hooks/register.js so turn.start renders the token as working and opens index.html, while turn.complete renders it as done and opens done.html. Ignore subagent turns. In the done state, disable the input and show a prominent Claude is done banner. Keep all file and process access in the Mods API.

How do the two states work?

  • The turn.start hook replaces the token with working. It writes the result to index.html.
  • The turn.complete hook replaces the token with done. It writes the result to done.html.
  • The e.agentId === undefined check keeps both hooks focused on the main conversation.
  • The Mods API handles every file operation. It also launches each rendered page.
  • Wait for Claude to finish updating the plugin files.
  • Review Claude's summary of the changes.
  • Confirm the summary mentions updates to game/index.html and hooks/register.js.

Claude changed the wrong files?

Check that the request targeted the existing ~/projects/while-you-wait folder. The reference files in the next substep show the exact result you need.

Ask Claude to compare its changes with the expected file structure.

Double-check the generated files

The generated code must preserve the existing start behavior. The new completion path should reuse the same template and rendering helper.

  • Compare your plugin files with the references in the tabs below.
  • Ask Claude to correct any differences you find.
  • Save every corrected file.

✔️ Awesome, I've got everything!

Your plugin now has matching start and completion paths. Continue to the live lifecycle test.

ⓧ I'd like to double check the full code

Use these complete files as comparison references. This version intentionally tracks only the current score.

{
  "name": "while-you-wait",
  "version": "0.1.0",
  "description": "Opens a typing game while a main Claude turn runs and shows a completion banner when it ends",
  "author": {
    "name": "Your Name"
  }
}

What does the manifest define?

The manifest identifies the plugin as while-you-wait. Its description now covers the working game and the completion banner.

{
  "description": "Turn lifecycle hooks for the while-you-wait game",
  "modules": ["./register.js"]
}

What does the hook loader define?

The hook loader points Claude Code to register.js. That module contains both turn handlers.

async function gameDirectory($) {
  const home = await $.env.get('HOME')
  if (!home) throw new Error('HOME is unavailable')
  return home + '/.claude/while-you-wait'
}

async function openGameState($, state) {
  const directory = await gameDirectory($)
  const template = await $.fs.read($.plugin.root + '/game/index.html')
  const rendered = template.replace('__CLAUDE_STATUS__', state)
  const fileName = state === 'working' ? 'index.html' : 'done.html'
  const filePath = directory + '/' + fileName

  await $.fs.write(filePath, rendered)

  const opened = await $.process.run(['open', filePath])
  if (opened.exitCode !== 0) {
    throw new Error(opened.stderr.trim() || 'Safari could not open the game')
  }
}

function showFailure($, error) {
  const message = error instanceof Error ? error.message : String(error)
  $.ui.toast('While-you-wait game: ' + message)
}

export function register(on) {
  on('turn.start', async ($, e, next) => {
    if (e.agentId === undefined) {
      try {
        await openGameState($, 'working')
      } catch (error) {
        showFailure($, error)
      }
    }
    return next(e)
  })

  on('turn.complete', async ($, e, next) => {
    if (e.agentId === undefined) {
      try {
        await openGameState($, 'done')
      } catch (error) {
        showFailure($, error)
      }
    }
    return next(e)
  })
}

What does the hook module do?

  • The gameDirectory() helper builds the runtime path beneath the learner's home directory.
  • The openGameState() helper renders the requested status into the shared HTML template.
  • The showFailure() helper reports file or launch failures through a toast.
  • Both turn hooks return next(e). Claude's turn therefore continues normally.
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Claude Code Typing Sprint</title>
  <style>
    :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0d1020; color: #f7f7fb; }
    * { box-sizing: border-box; }
    body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top left, rgba(124, 92, 255, 0.32), transparent 38%), radial-gradient(circle at bottom right, rgba(61, 217, 180, 0.2), transparent 38%), #0d1020; }
    .card { width: min(680px, 100%); padding: 28px; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 22px; background: rgba(20, 24, 46, 0.92); box-shadow: 0 24px 70px rgba(0, 0, 0, 0.38); }
    .topbar, .scores { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
    .status { display: inline-flex; align-items: center; gap: 8px; padding: 7px 11px; border-radius: 999px; background: rgba(61, 217, 180, 0.12); color: #79f2d2; font-size: 14px; font-weight: 700; }
    .dot { width: 9px; height: 9px; border-radius: 50%; background: currentColor; box-shadow: 0 0 16px currentColor; }
    h1 { margin: 28px 0 8px; font-size: clamp(32px, 7vw, 58px); line-height: 0.95; letter-spacing: -0.05em; }
    .intro { margin: 0 0 26px; color: #aeb4cc; line-height: 1.6; }
    .scores { justify-content: flex-start; margin-bottom: 18px; }
    .score-box { min-width: 120px; padding: 12px 14px; border-radius: 14px; background: rgba(255, 255, 255, 0.06); }
    .score-label { display: block; color: #8f97b3; font-size: 12px; text-transform: uppercase; letter-spacing: 0.12em; }
    .score-value { display: block; margin-top: 3px; font-size: 26px; font-weight: 800; }
    .phrase { min-height: 92px; display: grid; place-items: center; padding: 20px; border-radius: 16px; background: #10152b; color: #ffffff; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: clamp(20px, 4vw, 30px); text-align: center; }
    label { display: block; margin: 18px 0 8px; color: #aeb4cc; font-size: 14px; }
    input { width: 100%; padding: 15px 16px; border: 2px solid transparent; border-radius: 14px; outline: none; background: #f7f7fb; color: #14172a; font: inherit; font-size: 18px; }
    input:focus { border-color: #7c5cff; box-shadow: 0 0 0 4px rgba(124, 92, 255, 0.18); }
    input.wrong { border-color: #ff6b88; background: #fff0f4; }
    input:disabled { cursor: not-allowed; opacity: 0.55; }
    .hint { margin: 12px 0 0; color: #7f88a6; font-size: 13px; }
    .done-banner { position: fixed; inset: auto 18px 18px 18px; max-width: 680px; margin: auto; padding: 18px 22px; border-radius: 16px; background: #79f2d2; color: #071914; box-shadow: 0 20px 55px rgba(0, 0, 0, 0.42); font-size: 20px; font-weight: 900; text-align: center; }
    .done-banner[hidden] { display: none; }
  </style>
</head>
<body data-claude-status="__CLAUDE_STATUS__">
  <main class="card">
    <div class="topbar">
      <span class="status"><span class="dot"></span><span id="status-text">Claude is working</span></span>
      <span>Typing Sprint</span>
    </div>
    <h1>Type while Claude thinks.</h1>
    <p class="intro">Finish each phrase exactly. Every completed phrase adds one point.</p>
    <section class="scores" aria-label="Scoreboard">
      <div class="score-box"><span class="score-label">Score</span><span class="score-value" id="score">0</span></div>
    </section>
    <div class="phrase" id="phrase" aria-live="polite"></div>
    <label for="typing-input">Type the phrase</label>
    <input id="typing-input" autocomplete="off" spellcheck="false">
    <p class="hint">A pink field means the text no longer matches. Delete back to the last correct character.</p>
  </main>
  <div class="done-banner" id="done-banner" role="status" hidden>Claude is done. Save your score and jump back in.</div>
  <script>
    const PHRASES = ['hooks turn events into actions', 'small feedback loops build confidence', 'ship the simplest useful version', 'validate before you share', 'the browser is my intermission']
    const status = document.body.dataset.claudeStatus
    const phrase = document.getElementById('phrase')
    const input = document.getElementById('typing-input')
    const scoreElement = document.getElementById('score')
    const statusText = document.getElementById('status-text')
    const doneBanner = document.getElementById('done-banner')
    let currentPhrase = ''
    let score = 0

    function choosePhrase() {
      let next = PHRASES[Math.floor(Math.random() * PHRASES.length)]
      while (PHRASES.length > 1 && next === currentPhrase) next = PHRASES[Math.floor(Math.random() * PHRASES.length)]
      currentPhrase = next
      phrase.textContent = currentPhrase
      input.value = ''
      input.classList.remove('wrong')
    }
    function updateScore() {
      scoreElement.textContent = String(score)
    }
    input.addEventListener('input', () => {
      const typed = input.value
      input.classList.toggle('wrong', !currentPhrase.startsWith(typed))
      if (typed === currentPhrase) {
        score += 1
        updateScore()
        choosePhrase()
      }
    })
    choosePhrase()
    if (status === 'done') {
      statusText.textContent = 'Claude is done'
      doneBanner.hidden = false
      input.disabled = true
    } else {
      input.focus()
    }
  </script>
</body>
</html>

What does the game page do?

  • The PHRASES array supplies a new typing target after each completed phrase.
  • The input listener compares the typed prefix with currentPhrase. A mismatch applies the wrong class.
  • The score value increases when the full phrase matches.
  • The done state reveals the banner. It also disables the typing input.
Test both turn states

The live test now covers an entire main turn. Before you test it, decide whether Safari should show the working state or the done state first.

  • Send Claude a task that takes several seconds.
  • Switch to Safari when the typing game opens.
  • Complete at least one displayed phrase.

You should see the current score increase by one. Typing a wrong character should turn the input pink.

  • Return to Claude Code.
  • Wait for the main turn to finish.
  • Switch back to Safari when the completed page opens.

You should see the prominent Claude is done banner. The typing input should be disabled.

That closes the lifecycle loop: your game now reacts when Claude starts working and when the main turn finishes.

The done page did not open?

  • Confirm that hooks/register.js registers turn.complete.
  • Check that game/index.html contains the exact __CLAUDE_STATUS__ token.
  • Confirm that openGameState() selects done.html when the state is done.

Help me diagnose why the completion hook is not opening the done page.

Your mod now turns a full Claude lifecycle into visible browser behavior. Next, you will preserve the best score across Safari reloads and future turns.

Persist the High Score and Validate

Your typing sprint now opens for a main turn. Its done page returns your attention when Claude finishes.

The current score disappears when the page closes. In this step, you will use browser storage to preserve the best score before validating the finished plugin.

In this step, get ready to:
  • Add persistent storage to the best-score display.
  • Test the saved score across later game renders.
  • Validate the finished plugin.
Add persistent best-score storage

A stable storage key lets every rendered page find the same score. Protected reads keep the game playable when storage access fails.

  • Switch back to the Claude Code session from earlier.
  • Ask Claude to update game/index.html by entering this prompt:
Update only game/index.html. Preserve the existing typing game and its working and done states. Add const BEST_KEY = 'while-you-wait-best-score'. Load the saved score with localStorage.getItem(BEST_KEY) inside loadBest(). Save improvements with localStorage.setItem(BEST_KEY, String(best)) inside saveBest(). Wrap both storage calls in try and catch so the game still works if browser storage is unavailable. Add a Best score field beside the current score.

How Does Persistence Work?

  • The BEST_KEY value gives every game render one shared storage address.
  • The loadBest() function retrieves the saved value when the page loads.
  • The saveBest() function stores a new value when the current score beats the previous best.
  • The guarded storage calls let gameplay continue if the browser rejects storage access.
  • Wait for Claude to update game/index.html.
  • Review Claude's summary to confirm that only game/index.html changed.

✔️ Awesome, I've got everything!

Your game source now loads a saved best score. It also stores every new high score.

ⓧ I'd like to double check the full code

  • Compare your generated game/index.html with this final reference.
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Claude Code Typing Sprint</title>
  <style>
    :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0d1020; color: #f7f7fb; }
    * { box-sizing: border-box; }
    body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top left, rgba(124, 92, 255, 0.32), transparent 38%), radial-gradient(circle at bottom right, rgba(61, 217, 180, 0.2), transparent 38%), #0d1020; }
    .card { width: min(680px, 100%); padding: 28px; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 22px; background: rgba(20, 24, 46, 0.92); box-shadow: 0 24px 70px rgba(0, 0, 0, 0.38); }
    .topbar, .scores { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
    .status { display: inline-flex; align-items: center; gap: 8px; padding: 7px 11px; border-radius: 999px; background: rgba(61, 217, 180, 0.12); color: #79f2d2; font-size: 14px; font-weight: 700; }
    .dot { width: 9px; height: 9px; border-radius: 50%; background: currentColor; box-shadow: 0 0 16px currentColor; }
    h1 { margin: 28px 0 8px; font-size: clamp(32px, 7vw, 58px); line-height: 0.95; letter-spacing: -0.05em; }
    .intro { margin: 0 0 26px; color: #aeb4cc; line-height: 1.6; }
    .scores { justify-content: flex-start; margin-bottom: 18px; }
    .score-box { min-width: 120px; padding: 12px 14px; border-radius: 14px; background: rgba(255, 255, 255, 0.06); }
    .score-label { display: block; color: #8f97b3; font-size: 12px; text-transform: uppercase; letter-spacing: 0.12em; }
    .score-value { display: block; margin-top: 3px; font-size: 26px; font-weight: 800; }
    .phrase { min-height: 92px; display: grid; place-items: center; padding: 20px; border-radius: 16px; background: #10152b; color: #ffffff; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: clamp(20px, 4vw, 30px); text-align: center; }
    label { display: block; margin: 18px 0 8px; color: #aeb4cc; font-size: 14px; }
    input { width: 100%; padding: 15px 16px; border: 2px solid transparent; border-radius: 14px; outline: none; background: #f7f7fb; color: #14172a; font: inherit; font-size: 18px; }
    input:focus { border-color: #7c5cff; box-shadow: 0 0 0 4px rgba(124, 92, 255, 0.18); }
    input.wrong { border-color: #ff6b88; background: #fff0f4; }
    input:disabled { cursor: not-allowed; opacity: 0.55; }
    .hint { margin: 12px 0 0; color: #7f88a6; font-size: 13px; }
    .done-banner { position: fixed; inset: auto 18px 18px 18px; max-width: 680px; margin: auto; padding: 18px 22px; border-radius: 16px; background: #79f2d2; color: #071914; box-shadow: 0 20px 55px rgba(0, 0, 0, 0.42); font-size: 20px; font-weight: 900; text-align: center; }
    .done-banner[hidden] { display: none; }
  </style>
</head>
<body data-claude-status="__CLAUDE_STATUS__">
  <main class="card">
    <div class="topbar">
      <span class="status"><span class="dot"></span><span id="status-text">Claude is working</span></span>
      <span>Typing Sprint</span>
    </div>
    <h1>Type while Claude thinks.</h1>
    <p class="intro">Finish each phrase exactly. Every completed phrase adds one point.</p>
    <section class="scores" aria-label="Scoreboard">
      <div class="score-box"><span class="score-label">Score</span><span class="score-value" id="score">0</span></div>
      <div class="score-box"><span class="score-label">Best</span><span class="score-value" id="best">0</span></div>
    </section>
    <div class="phrase" id="phrase" aria-live="polite"></div>
    <label for="typing-input">Type the phrase</label>
    <input id="typing-input" autocomplete="off" spellcheck="false">
    <p class="hint">A pink field means the text no longer matches. Delete back to the last correct character.</p>
  </main>
  <div class="done-banner" id="done-banner" role="status" hidden>Claude is done. Save your score and jump back in.</div>
  <script>
    const PHRASES = ['hooks turn events into actions', 'small feedback loops build confidence', 'ship the simplest useful version', 'validate before you share', 'the browser is my intermission']
    const BEST_KEY = 'while-you-wait-best-score'
    const status = document.body.dataset.claudeStatus
    const phrase = document.getElementById('phrase')
    const input = document.getElementById('typing-input')
    const scoreElement = document.getElementById('score')
    const bestElement = document.getElementById('best')
    const statusText = document.getElementById('status-text')
    const doneBanner = document.getElementById('done-banner')
    let currentPhrase = ''
    let score = 0
    let best = loadBest()

    function loadBest() {
      try { return Number.parseInt(localStorage.getItem(BEST_KEY) || '0', 10) || 0 } catch { return 0 }
    }
    function saveBest() {
      try { localStorage.setItem(BEST_KEY, String(best)) } catch { /* The game still works when storage is unavailable. */ }
    }
    function choosePhrase() {
      let next = PHRASES[Math.floor(Math.random() * PHRASES.length)]
      while (PHRASES.length > 1 && next === currentPhrase) next = PHRASES[Math.floor(Math.random() * PHRASES.length)]
      currentPhrase = next
      phrase.textContent = currentPhrase
      input.value = ''
      input.classList.remove('wrong')
    }
    function updateScore() {
      scoreElement.textContent = String(score)
      if (score > best) {
        best = score
        bestElement.textContent = String(best)
        saveBest()
      }
    }
    input.addEventListener('input', () => {
      const typed = input.value
      input.classList.toggle('wrong', !currentPhrase.startsWith(typed))
      if (typed === currentPhrase) {
        score += 1
        updateScore()
        choosePhrase()
      }
    })
    bestElement.textContent = String(best)
    choosePhrase()
    if (status === 'done') {
      statusText.textContent = 'Claude is done'
      doneBanner.hidden = false
      input.disabled = true
    } else {
      input.focus()
    }
  </script>
</body>
</html>
Prove the score survives

The localStorage value belongs to Safari's browser storage. Each new working render can retrieve the best score saved by the previous page.

  • Send a task that takes several seconds in the Claude Code development session from earlier.
  • Switch to Safari when the working game opens.
  • Complete phrases until Best shows a number greater than 0.
  • Record the number shown under Best: your best score.

Before you reload the page, do you expect Best to return to zero or stay at your best score?

  • Reload the working game page in Safari.

You should see Best still showing your best score. This proves the storage read works after a normal reload.

  • Close the working game page.
  • Return to the Claude Code session from earlier.
  • Send another main-turn task that takes several seconds.

Safari opens a fresh working render. You should see Best showing your best score again.

  • Wait for the main turn to complete.

Safari opens the done render with the Claude is done banner. The typing input is disabled.

The done page should also show your best score under Best.

Best Score Returning to Zero?

  • Confirm that BEST_KEY is set to while-you-wait-best-score.
  • Confirm that best starts with the value returned by loadBest().
  • Confirm that updateScore() calls saveBest() after a new best score.

help me debug a best score that resets between Safari pages.

Validate the finished plugin

Plugin validation checks the package structure before you share it. Its summary also confirms which hooks and Mods API calls Claude Code found.

Before you run the final validator, which two lifecycle hooks do you expect it to find?

  • Return to the Ghostty shell at ~/projects.
  • Validate the finished while-you-wait plugin by running this command:
claude plugin validate ./while-you-wait

What Should I See?

A successful run includes Validation passed.

The validation summary should account for these items:

  • The turn.start hook.
  • The turn.complete hook.
  • The $.env.get call.
  • The $.fs.read call.
  • The $.fs.write call.
  • The $.process.run call.
  • The $.ui.toast call.

Validation Did Not Pass?

  • Confirm that the validator is running from ~/projects.
  • Confirm that Claude saved the updated source inside ~/projects/while-you-wait/game/index.html.
  • Ask Claude to compare the plugin against the final reference above.

help me diagnose why my while-you-wait plugin does not pass validation.

Strong finish. Your persistent game now passes plugin validation.

Secret mission

Package, Friend-Test, and Capture the Proof

Turn your validated mod into a shareable zip that another Claude Code user can run. Prove the package works with a friend test and a screenshot of the game beside an active turn.

Clean Up Your Resources

Clean Up Your Resources

Nothing here creates a project-specific charge. Claude Code usage continues under your existing access.

You can keep every resource available. Your other options are pausing the active pieces or removing the project from your Mac.

Resources you used:

  • Intermission 0.1.5 installed in Claude Code.
  • Ghostty 1.4.0 in Applications.
  • The plugin source folder at ~/projects/while-you-wait.
  • The generated working page and completion page beneath ~/.claude/while-you-wait.
  • The friend-tested package at ~/projects/while-you-wait.zip.
  • The proof screenshot at ~/projects/while-you-wait-proof.png.

Keep everything running

No action is needed. Choose this option if you want to keep using both mods or improve the typing game.

  • Intermission remains installed in Claude Code.
  • Your source folder and generated pages remain available for future development sessions.
  • Your zip and proof screenshot remain ready for another demonstration.
  • Your best score remains available in Safari during normal reloads or window closes.

Pause - I'll come back to this later

Pause the mod activity while preserving every project file. You can resume from the same source folder later.

  • Disable Intermission in the current Claude Code session by entering:
/intermission off

What does this do?

This disables Intermission while leaving the plugin installed for later use.

  • End the current Claude Code session by pressing Ctrl+C.
  • Close the typing game pages in Safari.
  • When you return, run claude from ~/projects.

Delete - I don't want to use this again

Deletion is permanent. The commands below target only the exact project paths from this walkthrough.

  • Switch back to Claude Code in Ghostty.
  • Enter /plugin.
  • Select the Installed tab.
  • Remove Intermission.
  • Confirm Intermission no longer appears in the Installed tab.
  • Return to the shell in Ghostty by pressing Ctrl+C.
  • Remove the source folder, generated pages, package, and proof image by running these commands:
rm -rf ~/projects/while-you-wait
rm -rf ~/.claude/while-you-wait
rm -f ~/projects/while-you-wait.zip
rm -f ~/projects/while-you-wait-proof.png

What gets removed?

  • The first command removes the validated plugin source.
  • The second command removes the generated working page and completion page.
  • The final commands remove the friend-tested zip and proof screenshot.
  • Verify that the project paths are gone by running:
ls ~/projects
ls ~/.claude

What should I see?

The output should no longer include while-you-wait, while-you-wait.zip, or while-you-wait-proof.png.

Still see a project item?

  • Check that Ghostty shows a shell prompt instead of an active Claude Code session.
  • Compare the remaining item's full path with the cleanup commands.
  • Ask for help checking the remaining path.
  • Quit Ghostty after the cleanup check is complete.
  • Use Finder to remove Ghostty from Applications if you no longer need it.

Nice Work!

Nice Work!

You made it! Your friend-tested Claude Code mod turns waiting time into a typing sprint with a clear completion signal.

You've learned how to:

  • Connect main-turn lifecycle hooks to browser behavior. turn.start opens the working game. turn.complete renders the done state.
  • Build a one-file typing game with phrase scoring. Render a working state during the turn. Show a completion banner once the turn ends.
  • Preserve a best score with localStorage after closing its Safari window. Confirm the finished package with plugin validation. Load it directly in Claude Code for a local session.
  • Secret Mission: Produce a friend-tested package as while-you-wait.zip. Capture proof of the typing game beside an active Claude Code turn.

Ready to quiz yourself?