A fully local ASCII runner game for Claude Code request failures and short breaks

A small, fully local ASCII runner for Claude Code. Jump over BUG, 429 and TIMEOUT obstacles while a request is unavailable, or take a short break with /token-run. 한국어.
Install while connected; after loading, the game needs no network or model calls. The mod uses the function-hooks/Client API verified with Claude Code 2.1.292. Older builds without Mods support cannot load it.
claude plugin marketplace add YeonwooSung/my-claude-code-mods
claude plugin install token-run@my-claude-code-mods
# Or use this checkout with a compatible binary:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./token-run
/token-run
/token-run help
/token-run close
A main turn ending in an error adds a “request failed” game invitation above the composer. Click it or run /token-run. It opens a Pane beside the transcript in wide fullscreen terminals, or above the prompt otherwise. It requests focus only after your command/click; if the composer or another dialog retains focus, click the game or use ctrl+x tab. It never replaces conversation text or sends a game prompt to the model.
| Key / button | Action |
|---|---|
| Space / Up / Jump | Start and jump; no double jump |
| R | Restart the same course |
| P | Pause/resume |
| Q / Esc | Close the game and return to work |
Only the focused game receives keys. Ctrl/Meta/Shift combinations are ignored; no global game shortcuts. Escape is handled by the engine. Leaving focus pauses movement; returning resumes it unless P was used. Closing/reopening starts a fresh run. The game needs at least 24 columns and 8 rows; a smaller region shows a resize hint and suspends movement.
Narrow panes shorten status notices to LOCAL, SAVE! (record save warning) and BACK:Esc (response returned). Game-over/retry and pause controls remain visible. Q closes immediately while its accepted score save finishes.
Score counts survived seconds; collisions freeze it. The plugin-owned store saves the best score, shared across this plugin's sessions in the same local engine store. Scores are checkpointed each survived second, on collision and Q exit. Read/write failures show a warning and leave the game playable, with the best retained in memory. Engine close/Escape uses the latest checkpoint. There is no cross-process locking; simultaneous engine processes may race on records.
Terminal and Desktop use the same Client. VS Code chat, mobile and headless sessions do not run the game. Surveys retain priority over the failure invitation and may take focus away from the game.
The verified engine API exposes main-turn failure, not an OS network-status event or readable retry markers. The invitation can also follow server, authentication or context errors, and appears after the turn fails rather than during opaque retries. It does not prove the computer is offline. No health probes or retries are introduced. A later main response shows a return-to-work notice without interrupting play. Subagent outcomes do not affect the invitation.
npm test
npm run typecheck
CLAUDE_BIN=/path/to/claude-2.1.292 npm run test:adapters
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 /path/to/claude-2.1.292 plugin test token-run
/path/to/claude-2.1.292 plugin validate token-run
Typecheck needs matching local .claude-plugin/types declarations; these generated files are ignored like those of other mods. No new dependencies. Pure tests cover deterministic physics, storage races/failure, request generations and responsive ASCII output. Real engine tests mount the Client on terminal/Desktop and advance its frame clock to test keys, buttons, focus/resize suspension, scores and lifecycle. These are render-tree tests, not terminal/Desktop pixel captures.
hooks/register.tsx 107 lines1import type { EngineInterface, Register } from 'claude-code'
2import { RequestState, Scores, validScore } from './core/state.ts'
3
4const PANE = 'token-run'
5const HELP = 'Token Run: /token-run | /token-run close | /token-run help\nSpace/Up: start or jump · R: restart · P: pause · Q/Esc: return\nClick the game or use ctrl+x tab to focus. Fully local; no model calls.'
6function paint($: EngineInterface): void { try { $.ui.invalidate('ui.render') } catch { /* A failed redraw must not affect a turn. */ } }
7
8type AdapterState = { requests: RequestState; scores?: Scores; supported: boolean; open: boolean; runId: number }
9function records($: EngineInterface, state: AdapterState): Scores {
10 return state.scores ??= new Scores({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) })
11}
12function detach(state: AdapterState): void { state.open = false; state.runId++ }
13async function close($: EngineInterface, state: AdapterState): Promise<void> {
14 detach(state); await $.ui.close({ id: PANE }); paint($)
15}
16async function start($: EngineInterface, state: AdapterState): Promise<string> {
17 const opening = state.runId
18 await records($, state).load()
19 if (opening !== state.runId || !state.supported) return 'Token Run · 열기 요청이 취소되었습니다.'
20 if (!state.open) { state.runId++; state.open = true }
21 try {
22 const result = await $.ui.open({ id: PANE, title: 'Token Run', focus: true, closeOnEscape: true, rows: 8, columns: 64 })
23 paint($)
24 return result.isPlaced ? 'Token Run · Space로 시작하세요. Esc로 작업 복귀.' : 'Token Run · 패널을 표시할 공간이 필요해요.'
25 } catch { detach(state); return 'Token Run 패널을 열지 못했어요. /token-run으로 다시 시도하세요.' }
26}
27export const register: Register = on => {
28 const state: AdapterState = { requests: new RequestState(), supported: false, open: false, runId: 0 }
29 on('session.start', async ($, e, next) => {
30 detach(state); state.requests.reset(); state.supported = e.isInteractive && (e.surface === 'terminal' || e.surface === 'desktop')
31 try { await $.ui.close({ id: PANE }) } catch { /* No old game should hold session startup. */ }
32 try { await $.command.register({ name: 'token-run', description: 'Play a local runner: [help|close]' }) } catch { /* Session startup still proceeds. */ }
33 await records($, state).load(); paint($)
34 return next(e)
35 })
36 on('session.end', async ($, e, next) => {
37 detach(state); state.requests.reset()
38 try { await $.ui.close({ id: PANE }) } catch { /* Lifecycle must proceed. */ }
39 paint($)
40 return next(e)
41 })
42 on('turn.start', async ($, e, next) => { state.requests.begin(e.turnId); paint($); return next(e) })
43 on('turn.complete', async ($, e, next) => {
44 state.requests.complete(e.turnId, e.reason, e.agentId); paint($)
45 return next(e)
46 })
47 on('turn.step', async function* ($, e, next) {
48 const generation = state.requests.snapshot().generation
49 const iterator = next(e)
50 let finished = false
51 try {
52 while (true) {
53 const result = await iterator.next()
54 if (result.done) { finished = true; return result.value }
55 const before = state.requests.snapshot().status
56 state.requests.observe(e.turnId, result.value.kind, e.agentId, generation)
57 if (before !== state.requests.snapshot().status) paint($)
58 yield result.value
59 }
60 } finally {
61 if (!finished) await iterator.return?.(undefined as never)
62 }
63 })
64 on('command.run', { command: 'token-run' }, async ($, e) => {
65 const args = (e.args ?? '').trim()
66 if (args === 'help') return { text: HELP }
67 if (args === 'close') { try { await close($, state) } catch { /* Can retry close via engine controls. */ }; return { text: 'Token Run · 작업으로 돌아갑니다.' } }
68 if (args !== '') return { text: 'Usage: /token-run [help|close]' }
69 if (!state.supported) return { text: 'Token Run requires an interactive terminal or desktop session. /token-run help' }
70 return { text: await start($, state) }
71 })
72 on('ui.close', { id: PANE }, async ($, e, next) => { detach(state); paint($); return next(e) })
73 on('ui.message', { element: 'runner' }, async ($, e) => {
74 const data = e.data
75 if (e.component !== 'Pane' || e.requestId !== PANE || !state.open || !data || typeof data !== 'object' || Array.isArray(data)) return {}
76 const message = data as Record<string, unknown>
77 if (message.runId !== state.runId || !validScore(message.score) || (message.type !== 'score' && message.type !== 'exit')) return {}
78 const current = state.runId
79 const saving = records($, state).save(message.score)
80 if (message.type === 'exit' && state.open && current === state.runId) {
81 try { await close($, state) } catch { /* Engine close controls remain available. */ }
82 }
83 await saving
84 paint($)
85 return {}
86 })
87 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
88 const existing = await next(e)
89 if (e.props.hasSurvey || e.props.bodyColumns < 24 || e.props.maxRows < 1 || !state.supported || state.requests.snapshot().status !== 'failed' || state.open) return existing
90 if (e.surface !== 'terminal' && e.surface !== 'desktop') return existing
91 const { Box, Button } = $.ui.resolve(e)
92 return <Box flexDirection="column">{existing}<Button key="run-invite" plain label="요청 실패 · 잠깐 달려볼까요?" onPress={async () => { await start($, state) }} /></Box>
93 })
94 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
95 const existing = await next(e)
96 if (e.requestId !== PANE || !state.open || (e.surface !== 'terminal' && e.surface !== 'desktop')) return existing
97 const { Box, Client } = $.ui.resolve(e)
98 const record = records($, state).snapshot()
99 const request = state.requests.snapshot()
100 const columns = Number.isFinite(e.props.bodyColumns) ? Math.max(0, Math.min(80, Math.floor(e.props.bodyColumns))) : 0
101 const rows = Number.isFinite(e.props.scroll.bodyRows) ? Math.max(0, Math.min(8, Math.floor(e.props.scroll.bodyRows))) : 0
102 return <Box flexDirection="column">{existing}<Client key="runner" module="./game.tsx"
103 width={Math.max(1, columns)} height={Math.max(1, rows)}
104 props={{ runId: state.runId, columns, rows, focused: e.props.isFocused, best: record.best, error: record.error ?? '', status: request.status, recovered: request.recovered }} /></Box>
105 })
106}
107hooks/core/state.ts 67 lines1export type RequestSnapshot = { status: 'unknown' | 'failed' | 'available'; recovered: boolean; generation: number }
2export class RequestState {
3 private turn: string | undefined
4 private value: RequestSnapshot = { status: 'unknown', recovered: false, generation: 0 }
5 snapshot(): RequestSnapshot { return { ...this.value } }
6 reset(): void {
7 this.turn = undefined
8 this.value = { status: 'unknown', recovered: false, generation: this.value.generation + 1 }
9 }
10 begin(turnId: string): void { this.turn = turnId; this.value.generation++ }
11 private accepts(turnId: string, agentId: string | undefined, generation: number): boolean {
12 if (agentId !== undefined || generation !== this.value.generation) return false
13 return this.turn !== undefined && this.turn === turnId
14 }
15 observe(turnId: string, kind: string, agentId?: string, generation = this.value.generation): void {
16 if (!this.accepts(turnId, agentId, generation) || !['text', 'thinking', 'tool', 'input', 'stop'].includes(kind)) return
17 this.value.recovered ||= this.value.status === 'failed'
18 this.value.status = 'available'
19 }
20 complete(turnId: string, reason: string, agentId?: string, generation = this.value.generation): void {
21 if (!this.accepts(turnId, agentId, generation)) return
22 if (reason === 'error') { this.value.status = 'failed'; this.value.recovered = false }
23 else if (reason === 'answer') this.observe(turnId, 'stop', agentId, generation)
24 }
25}
26export type ScoreDocument = { version: 1; best: number }
27type Store = { get(key: string): Promise<unknown>; set(key: string, value: ScoreDocument): Promise<unknown> }
28export const SCORE_KEY = 'token-run.best.v1'
29export function validScore(value: unknown): value is number {
30 return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 999999
31}
32function decode(value: unknown): number {
33 if (!value || typeof value !== 'object' || Array.isArray(value)) return 0
34 const doc = value as Record<string, unknown>
35 return doc.version === 1 && validScore(doc.best) ? doc.best : 0
36}
37export class Scores {
38 private best = 0
39 private error: string | undefined
40 private queue: Promise<void> = Promise.resolve()
41 constructor(private readonly store: Store) {}
42 snapshot(): { best: number; error?: string } { return { best: this.best, error: this.error } }
43 private enqueue(fn: () => Promise<void>): Promise<void> {
44 const operation = this.queue.then(fn)
45 this.queue = operation.catch(() => {})
46 return operation
47 }
48 load(): Promise<void> {
49 return this.enqueue(async () => {
50 try { this.best = Math.max(this.best, decode(await this.store.get(SCORE_KEY))); this.error = undefined }
51 catch { this.error = '최고 기록을 불러오지 못했어요. 게임은 계속할 수 있어요.' }
52 })
53 }
54 save(value: number): Promise<void> {
55 if (!validScore(value)) return Promise.reject(new Error('Invalid score'))
56 return this.enqueue(async () => {
57 this.best = Math.max(this.best, value)
58 try {
59 const stored = decode(await this.store.get(SCORE_KEY))
60 this.best = Math.max(this.best, stored)
61 if (this.best > stored) await this.store.set(SCORE_KEY, { version: 1, best: this.best })
62 this.error = undefined
63 } catch { this.error = '최고 기록을 저장하지 못했어요. 이번 실행의 기록은 유지돼요.' }
64 })
65 }
66}
67hooks/game.tsx 69 lines1import type { ClientModule } from 'claude-code'
2import { newGame, act, step, score, FRAME_MS, type Game, type Action } from './core/game.ts'
3import { gameView } from './core/view.ts'
4
5export type GameProps = { runId: number; columns: number; rows: number; focused: boolean; best: number; error: string; status: string; recovered: boolean }
6type Local = { game: Game; props: GameProps; lastScore: number; best: number }
7
8const Runner: ClientModule<GameProps, Local> = (props, surface) => {
9 const { Box, Text, Button } = surface.elements
10 const playable = () => !!surface.state && gameView(surface.state.game, Math.min(surface.columns, surface.state.props.columns), Math.min(surface.rows, surface.state.props.rows)).playable
11 const send = (type: 'score' | 'exit', game: Game) => {
12 const current = surface.state
13 if (current) surface.post({ type, runId: current.props.runId, score: score(game) })
14 }
15 const apply = (action: Action | 'exit', keyboard = false) => {
16 const current = surface.state
17 if (!current || (keyboard && !current.props.focused)) return
18 if (action === 'exit') { send('exit', current.game); return }
19 if (!playable()) return
20 // Save the just-finished run even when restart is faster than a frame checkpoint.
21 if (action === 'restart' && score(current.game) > current.lastScore) send('score', current.game)
22 surface.setState({ ...current, game: act(current.game, action), best: Math.max(current.best, score(current.game)), lastScore: action === 'restart' ? 0 : current.lastScore })
23 }
24 if (!surface.state) {
25 surface.setState({ game: newGame(props.runId), props, lastScore: 0, best: props.best })
26 surface.onKey(event => {
27 if (event.ctrl || event.meta || event.shift) return
28 const key = event.key.toLowerCase()
29 if (key === ' ' || key === 'up') apply('jump', true)
30 else if (key === 'r') apply('restart', true)
31 else if (key === 'p') apply('pause', true)
32 else if (key === 'q') apply('exit', true)
33 })
34 surface.every(FRAME_MS, () => {
35 const current = surface.state
36 if (!current || !current.props.focused || !playable() || current.game.phase !== 'running') return
37 const game = step(current.game)
38 const value = score(game)
39 const checkpoint = value > current.lastScore || game.phase === 'over'
40 surface.setState({ ...current, game, best: Math.max(current.best, value), lastScore: checkpoint ? value : current.lastScore })
41 if (checkpoint) send('score', game)
42 })
43 } else if (surface.state.props.runId !== props.runId) {
44 surface.setState({ game: newGame(props.runId), props, lastScore: 0, best: props.best })
45 } else if (JSON.stringify(surface.state.props) !== JSON.stringify(props)) {
46 surface.setState({ ...surface.state, props, best: Math.max(surface.state.best, props.best) })
47 }
48 const game = surface.state?.game ?? newGame(props.runId)
49 const view = gameView(game, Math.min(surface.columns, props.columns), Math.min(surface.rows, props.rows))
50 if (!view.playable) return <Box key="run-resize"><Text wrap="truncate-end">{view.lines[0] ?? ''}</Text></Box>
51 const status = props.error || (props.recovered ? '응답이 돌아왔어요 · Esc로 작업 복귀' : props.status === 'failed' ? '요청 실패 · 게임은 로컬에서 실행 중' : '클릭 / ctrl+x tab: 조작 · Esc: 복귀')
52 const phase = { ready: 'SPACE:start', running: 'TOKEN RUN', paused: 'PAUSED P', over: 'GAME OVER R' }[game.phase]
53 const detail = Math.min(surface.columns, props.columns) < 48
54 ? props.error ? 'SAVE!' : props.recovered ? 'BACK:Esc' : props.status === 'failed' ? 'LOCAL' : 'Esc:exit'
55 : status
56 return <Box flexDirection="column" overflow="hidden">
57 <Box key="run-phase"><Box key="run-status"><Text bold color={game.phase === 'over' ? '#E9A09A' : '#62B8AD'} wrap="truncate-end">{`${phase} | ${detail}`}</Text></Box></Box>
58 <Box key="run-score"><Text wrap="truncate-end">{`SCORE ${score(game)} BEST ${Math.max(surface.state?.best ?? 0, props.best, score(game))}`}</Text></Box>
59 {view.lines.slice(2, 7).map((line, index) => <Box key={`track-${index + 2}`}><Text wrap="truncate-end">{line}</Text></Box>)}
60 <Box flexDirection="row">
61 <Button key="run-jump" plain label="Jump " onPress={() => apply('jump')} />
62 <Button key="run-restart" plain label="R " onPress={() => apply('restart')} />
63 <Button key="run-pause" plain label="P " onPress={() => apply('pause')} />
64 <Button key="run-close" plain label="Q " onPress={() => apply('exit')} />
65 </Box>
66 </Box>
67}
68export default Runner
69hooks/core/game.ts 55 lines1export type Obstacle = { x: number; width: number; height: number; label: 'BUG' | '429' | 'TIMEOUT' }
2export type Game = {
3 phase: 'ready' | 'running' | 'paused' | 'over'
4 ticks: number
5 y: number
6 vy: number
7 distance: number
8 nextSpawn: number
9 seed: number
10 initialSeed: number
11 obstacles: Obstacle[]
12}
13export type Action = 'jump' | 'restart' | 'pause'
14export const WORLD = 64
15export const PLAYER_X = 6
16export const FRAME_MS = 50
17export function score(game: Game): number { return Math.min(999999, Math.floor(game.ticks / 20)) }
18
19export function newGame(seed = 1): Game {
20 const normalized = Number.isFinite(seed) ? (Math.trunc(seed) >>> 0) || 1 : 1
21 return { phase: 'ready', ticks: 0, y: 0, vy: 0, distance: 0, nextSpawn: 12, seed: normalized, initialSeed: normalized, obstacles: [] }
22}
23export function act(game: Game, action: Action): Game {
24 if (action === 'restart') return newGame(game.initialSeed)
25 if (action === 'pause') {
26 if (game.phase === 'running') return { ...game, phase: 'paused' }
27 if (game.phase === 'paused') return { ...game, phase: 'running' }
28 return game
29 }
30 if ((game.phase === 'ready' || game.phase === 'running') && game.y === 0 && game.vy === 0) {
31 return { ...game, phase: 'running', vy: 0.8 }
32 }
33 return game
34}
35export function step(game: Game): Game {
36 if (game.phase !== 'running') return game
37 const speed = Math.min(1.2, 0.6 + game.ticks / 10000)
38 const y = Math.max(0, game.y + game.vy)
39 const vy = y === 0 ? 0 : game.vy - 0.12
40 let seed = game.seed
41 let nextSpawn = game.nextSpawn
42 const distance = game.distance + speed
43 // Swept horizontal overlap tests the entire movement, including skipped cells.
44 const hit = game.obstacles.some(o => o.x + o.width > PLAYER_X && o.x - speed < PLAYER_X + 1 && y < o.height)
45 const obstacles = game.obstacles.map(o => ({ ...o, x: o.x - speed })).filter(o => o.x + o.width > 0)
46 if (distance >= nextSpawn) {
47 seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
48 const labels = ['BUG', '429', 'TIMEOUT'] as const
49 const kind = seed % 3
50 obstacles.push({ x: WORLD, width: kind === 2 ? 2 : 1, height: 1, label: labels[kind]! })
51 nextSpawn = distance + 36 + ((seed >>> 8) % 19)
52 }
53 return { ...game, phase: hit ? 'over' : 'running', ticks: game.ticks + (hit ? 0 : 1), y, vy, distance, nextSpawn, seed, obstacles }
54}
55hooks/core/view.ts 30 lines1import { PLAYER_X, WORLD, score, type Game } from './game.ts'
2export type GameView = { playable: boolean; lines: string[] }
3export function gameView(game: Game, columns: number, rows: number): GameView {
4 const width = Number.isFinite(columns) ? Math.max(0, Math.min(80, Math.floor(columns))) : 0
5 const height = Number.isFinite(rows) ? Math.max(0, Math.floor(rows)) : 0
6 if (width < 24 || height < 8) {
7 return { playable: false, lines: width > 0 && height > 0 ? ['Resize to 24 cols x 8 rows'.slice(0, width)] : [] }
8 }
9 const trackWidth = Math.min(width, WORLD)
10 const scale = trackWidth < WORLD ? (trackWidth - 1) / (WORLD - 1) : 1
11 const track = Array.from({ length: 4 }, () => Array.from({ length: trackWidth }, () => ' '))
12 for (const o of game.obstacles) {
13 if (o.x >= WORLD || o.x + o.width <= 0) continue
14 const x = Math.floor(o.x * scale)
15 const end = Math.ceil((o.x + o.width) * scale)
16 for (let col = Math.max(0, x); col < Math.min(trackWidth, Math.max(x + 1, end)); col++) track[3]![col] = '#'
17 for (let i = 0; i < o.label.length; i++) {
18 const col = x + i
19 if (col >= 0 && col < trackWidth) track[2]![col] = o.label[i]!
20 }
21 }
22 const runnerRow = 3 - Math.min(3, Math.ceil(game.y))
23 track[runnerRow]![Math.floor(PLAYER_X * scale)] = '@'
24 const phase = { ready: 'SPACE to start', running: 'RUNNING', paused: 'PAUSED', over: 'GAME OVER - R to retry' }[game.phase]
25 return { playable: true, lines: [
26 `TOKEN RUN | ${phase}`, `SCORE ${score(game)}`,
27 ...track.map(row => row.join('')), '-'.repeat(trackWidth), 'SPACE/UP R P Q ESC',
28 ].map(line => line.slice(0, width)) }
29}
30