Build a TypeScript/Node.js CLI that wraps claude as a subprocess, rendering a 4-lane rhythm mini-game in the terminal while Claude works. The game parses Clone Hero .chart files, syncs notes to audio playback, and exits cleanly when Claude finishes. The tool is distributed as an npm package and can also be packaged as a Claude Code plugin (using SessionStart/Stop hooks to launch/kill a side-process). The wrapper approach is the primary integration — Claude Code's hook system cannot take over the terminal for rendering, so a wrapper is architecturally required for the game UI.
- Initialize a TypeScript + Node.js project at the workspace root with
package.json(name:claude-hero),tsconfig.json, and ansrc/directory. - Set up the
binentry inpackage.jsonpointing to a compileddist/cli.jswith a#!/usr/bin/env nodeshebang. - Add dev dependencies:
typescript,@types/node, a bundler/build script (plaintscis fine for MVP). - Add
.gitignore,README.mdskeleton.
- Create
src/cli.ts— parse CLI arguments using a lightweight parser (e.g.,minimistor hand-rolled). Accept:claude-hero -- <claude command and args>,--songs <path>,--no-game,--difficulty expert. - Create
src/claude-runner.ts— spawn theclaudecommand as a child process usingchild_process.spawn(). Capture stdout/stderr into buffers. Track state:idle → thinking → done. Expose anEventEmitterwith eventsstart,output,done,error. Useprocess.hrtime()/performance.now()for elapsed time tracking. - Emit
donewhen the child process exits (code 0 or otherwise). Store exit code and full output for display after game ends.
- Create
src/chart/parser.ts— parse.chartfiles (text-based format). Handle sections:[Song](metadata —Resolution,Offset,Name,Artist),[SyncTrack](BPM events astick = B bpm_value),[ExpertSingle](note events astick = N fret duration). - Create
src/chart/types.ts— define types:ChartFile,SongMetadata,BPMEvent,NoteEvent,TimedNote(post-conversion withtimeMs,lane,durationMs). - Create
src/chart/timing.ts— convert tick-based chart events to millisecond timestamps. Walk BPM changes chronologically: for each note tick, find the active BPM, computemsPerBeat = 60000 / bpm, andtimeMs = accumulatedMs + ((tick - lastBPMTick) / resolution) * msPerBeat + offset. Output aTimedNote[]sorted bytimeMs. - Create
src/chart/loader.ts— given a songs directory, find all valid song folders (must contain a.chartfile + audio file). Pick one at random. Parse.chartandsong.ini(if present, extract title/artist). Return aSongobject containing metadata, timed notes, and audio file path.
- Create
src/renderer/screen.ts— use raw ANSI escape codes (not blessed — too heavy for a fast game loop). Enter alternate screen buffer (\x1b[?1049h), hide cursor, enable raw mode on stdin. On exit, restore terminal. This gives us full terminal control and zero dependencies. - Create
src/renderer/game-view.ts— render the game frame at ~60fps usingsetIntervalorsetTimeoutwith drift correction. Layout:- Row 0 (header):
Claude: THINKING | Time 8.2s | Score 4200 | Combo x3 | q quit - Rows 1–N (lanes): 4 vertical lanes (A, S, D, F). Notes scroll downward. The "hit zone" is near the bottom. Notes are rendered as colored blocks/characters: lane 0 = green, 1 = red, 2 = yellow, 3 = blue (Clone Hero colors).
- Bottom row: Lane labels
[A] [S] [D] [F]with hit zone indicator.
- Row 0 (header):
- Create
src/renderer/note-renderer.ts— givencurrentTimeMs, compute which notes are visible in the viewport (e.g., notes withincurrentTime - 200mstocurrentTime + 2000ms). Map their time position to a Y row on screen. Render each note as a colored character at its (lane, row) position. - Create
src/input/keyboard.ts— listen for raw keypress events onprocess.stdinin raw mode. Map keys:a/A→ lane 0,s/S→ lane 1,d/D→ lane 2,f/F→ lane 3,q/Q→ quit. Emit keypresses withperformance.now()timestamp for hit detection.
-
Create
src/game/scoring.ts— implement hit detection: when a key is pressed, find the nearest unhit note in that lane. Compare|pressTime - noteTime|:≤ 40ms→ Perfect (+100 pts)≤ 90ms→ Good (+50 pts)> 90ms→ Miss (0 pts)
Track combo counter (reset on miss), multiplier (increases every 10 consecutive hits), total score, hits/misses/perfects counters.
-
Create
src/game/state.ts— central game state:currentTimeMs,score,combo,multiplier,activeNotes[],hitNotes: Set<number>,isRunning,claudeState. Updated each frame.
- Create
src/audio/player.ts— platform-aware audio player. Detect OS: macOS → spawnafplay <file>, Linux → spawnffplay -nodisp -autoexit <file>. RecordaudioStartTimestamp = performance.now()at spawn time. Exposekill()to stop playback immediately. - In the game loop (
src/game/loop.ts), computecurrentTimeMs = performance.now() - audioStartTimestamp. Use this as the single source of truth for note positions, hit windows, and rendering. This ensures notes sync to audio. - Handle audio offset from chart metadata: add
song.offsetto the time calculation.
- Create
src/game/loop.ts— the main game loop:startAudio() while (claudeRunner.state !== 'done' && !quit) { currentTime = now() - audioStartTime processInput(pendingKeys) updateState(currentTime) render(state) await nextFrame() // ~16ms interval } stopAudio() showResults() showClaudeOutput() - On
claudeRunner.doneevent: set a flag, let the current frame finish, then transition to results screen. - Create
src/game/results.ts— render final score, accuracy %, perfect/good/miss counts, max combo, song name. Then clear screen and print Claude's captured stdout/stderr.
- Create
.claude-plugin/plugin.jsonwith metadata for the plugin. - Create
hooks/hooks.jsonwith aSessionStarthook that prompts the user "Play a game while waiting? (y/n)" — but since hooks can't take over the terminal for interactive rendering, this hook would instead write a marker file or set an env var. - The practical plugin approach: create a
commands/play.mdslash command (/play) that tells the user to useclaude-heroas a wrapper instead, or creates a skill that provides context about the game. - Alternative tmux integration: A
SessionStarthook could spawnclaude-hero --standalonein a tmux split pane if tmux is detected, providing a side-by-side experience. TheStophook sends a kill signal. This is the most realistic "plugin" integration since the game needs its own terminal.
- Create
songs/directory structure. Source 5–10 simple, legally redistributable songs for MVP (original compositions, CC0-licensed, or public domain). Full 50-song pack is a stretch goal. - Add
song.inifiles with metadata for each bundled song. - Handle edge cases: terminal resize (re-render), missing audio binary (graceful error), no songs found (error message), Claude crashes (show error, exit cleanly), song ends before Claude finishes (loop song or show "waiting..." screen).
- Add
--no-gameflag: passes through toclaudedirectly with no game UI (just a wrapper).
- Unit tests for chart parser: parse a known
.chartfile and assert correctTimedNote[]output, especially BPM change handling. - Unit tests for timing conversion: verify tick→ms math with known BPM/resolution values.
- Unit tests for scoring: simulate keypresses at known offsets and verify Perfect/Good/Miss classification.
- Integration test: run
claude-hero -- echo "hello"(wrappingechoinstead ofclaude), verify game starts,echocompletes instantly, game exits, and "hello" is printed. - Manual test: run
claude-hero -- claude "explain this file"end-to-end, verify audio sync, note rendering, input responsiveness, and clean exit. - Platform test: verify macOS audio playback with
afplay, Linux withffplay.
| Decision | Chose | Over | Why |
|---|---|---|---|
| Integration model | CLI wrapper (claude-hero -- claude ...) |
Claude Code plugin/hook | Hooks run as subprocesses and cannot take over terminal for interactive rendering. A wrapper owns the terminal. |
| Rendering | Raw ANSI escape codes via process.stdout.write |
blessed library |
blessed is unmaintained (last publish 10 years ago) and heavyweight. Raw ANSI gives full control, zero deps, max frame rate. |
| Language | TypeScript / Node.js | Python, Go, Rust | Matches Claude Code ecosystem (npm-distributable), excellent cross-platform subprocess management, raw terminal I/O support. |
| Timing clock | performance.now() (monotonic) |
Date.now() |
Monotonic high-resolution clock avoids drift. All game time is relative to audioStartTimestamp. |
| Lane count | 4 lanes (A/S/D/F) | 5 lanes (Clone Hero default) | Simplified to keep it "tiny and delightful," not a full game product. |
| Chart difficulty | ExpertSingle only | Multiple difficulties | Keeps parser simple per PRD scope. |
| Plugin fallback | tmux split pane via SessionStart hook |
Direct terminal takeover | Only realistic way to run an interactive game alongside Claude Code's own terminal UI. |
claude-hero/ ├── package.json ├── tsconfig.json ├── .gitignore ├── README.md ├── PRD.md ├── implementation.md ├── src/ │ ├── cli.ts # Entry point, arg parsing │ ├── claude-runner.ts # Claude subprocess wrapper │ ├── chart/ │ │ ├── types.ts # Chart data types │ │ ├── parser.ts # .chart file parser │ │ ├── timing.ts # Tick → ms conversion │ │ └── loader.ts # Song folder discovery & loading │ ├── renderer/ │ │ ├── screen.ts # Terminal setup/teardown, ANSI helpers │ │ ├── game-view.ts # Frame composition & rendering │ │ └── note-renderer.ts # Note positioning & drawing │ ├── input/ │ │ └── keyboard.ts # Raw keypress handling │ ├── audio/ │ │ └── player.ts # Cross-platform audio playback │ └── game/ │ ├── state.ts # Central game state │ ├── scoring.ts # Hit detection & scoring │ ├── loop.ts # Main game loop │ └── results.ts # End-of-game results screen ├── songs/ │ └── starter-pack/ # Bundled songs (legally clear) │ └── / │ ├── notes.chart │ ├── song.ini │ └── song.ogg ├── .claude-plugin/ # Optional Claude Code plugin │ └── plugin.json ├── hooks/ │ └── hooks.json # SessionStart/Stop hooks (tmux approach) └── commands/ └── play.md # /play slash command