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 in engine tests with Claude Code 2.1.296, 2.1.299, 2.1.300 and 2.1.301, and in real terminal sessions with 2.1.296 and 2.1.301. 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 |
|---|---|
| Track click / Space / Up / Jump | Start and jump; no double jump |
| R | Restart the same course |
| P | Pause/resume |
| Q | Close the game |
| Esc | Return focus to the composer; another Esc at an empty idle prompt closes the pane |
Only the focused game receives keys. Ctrl/Meta/Shift combinations are ignored; no global game shortcuts. Click the track or Jump to give the Client keyboard focus. Escape is handled by the engine. Use P to pause before returning to work: the API does not expose Client blur, so leaving focus does not automatically pause. 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.
Switching between docked and inline layouts may remount the Client. An in-memory checkpoint restores the run; paused/restarted states are saved immediately, while a running game may roll back by about one simulated second. Click the track or a button to regain focus after this transition. Closing clears this checkpoint.
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.301 npm run test:adapters
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 /path/to/claude-2.1.301 plugin test token-run
/path/to/claude-2.1.301 plugin validate token-run
Typecheck needs matching local .claude-plugin/types declarations; these generated files are ignored like those of other mods. No runtime dependencies are added. 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, explicit pause, input with an unfocused parent Pane, resize suspension and checkpoint restoration, scores and lifecycle. These are render-tree tests, not terminal/Desktop pixel captures.
On macOS/Linux, run the native CLI in an isolated PTY with a temporary config, a dummy API key and a refused loopback API endpoint. This checks actual input/focus, animation, resize, failure entry and records across process restart. It does not change system network settings or use your account. The optional Python dependencies are only for QA.
python3 -m venv /tmp/token-run-qa
/tmp/token-run-qa/bin/pip install -r token-run/qa/requirements.txt
/tmp/token-run-qa/bin/python token-run/qa/pty_smoke.py --claude-bin /path/to/claude-2.1.301 --artifacts /tmp/token-run-smoke
# Add --combined to load all seven mods together.
Solo runs on 2.1.296/2.1.301 and a combined run on 2.1.301 passed 19 checkpoints each. Artifacts include ANSI output, decoded screen snapshots and result.json. Native Desktop GUI rendering has not been checked; Desktop coverage is engine render-tree coverage. See the verification report for evidence and limits.
hooks/register.tsx 116 lines1import type { EngineInterface, Register } from 'claude-code'
2import { decodeGame, score, type Game } from './core/game.ts'
3import { RequestState, Scores, validScore } from './core/state.ts'
4
5const PANE = 'token-run'
6const HELP = 'Token Run: /token-run | /token-run close | /token-run help\nSpace/Up: start or jump · R: restart · P: pause · Q: close; Esc: return focus (again: close)\nClick the track or Jump to start; Space/Up while Client is focused. P pauses explicitly. Fully local; no model calls.'
7function paint($: EngineInterface): void { try { $.ui.invalidate('ui.render') } catch { /* A failed redraw must not affect a turn. */ } }
8
9type AdapterState = { requests: RequestState; scores?: Scores; supported: boolean; open: boolean; runId: number; game?: Game }
10function records($: EngineInterface, state: AdapterState): Scores {
11 return state.scores ??= new Scores({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) })
12}
13function detach(state: AdapterState): void { state.open = false; state.runId++; state.game = undefined }
14async function close($: EngineInterface, state: AdapterState): Promise<void> {
15 detach(state); await $.ui.close({ id: PANE }); paint($)
16}
17async function start($: EngineInterface, state: AdapterState): Promise<string> {
18 const opening = state.runId
19 await records($, state).load()
20 if (opening !== state.runId || !state.supported) return 'Token Run · 열기 요청이 취소되었습니다.'
21 if (!state.open) { state.runId++; state.open = true }
22 const current = state.runId
23 try {
24 const result = await $.ui.open({ id: PANE, title: 'Token Run', focus: true, closeOnEscape: true, rows: 8, columns: 64 })
25 if (current !== state.runId || !state.open) return 'Token Run · 열기 요청이 취소되었습니다.'
26 paint($)
27 return result.isPlaced ? 'Token Run · 트랙 클릭 또는 Jump로 시작. P: 정지 · Q: 닫기.' : 'Token Run · 패널을 표시할 공간이 필요해요.'
28 } catch {
29 if (current === state.runId) detach(state)
30 return 'Token Run 패널을 열지 못했어요. /token-run으로 다시 시도하세요.'
31 }
32}
33export const register: Register = on => {
34 const state: AdapterState = { requests: new RequestState(), supported: false, open: false, runId: 0 }
35 on('session.start', async ($, e, next) => {
36 detach(state); state.requests.reset(); state.supported = e.isInteractive && (e.surface === 'terminal' || e.surface === 'desktop')
37 try { await $.ui.close({ id: PANE }) } catch { /* No old game should hold session startup. */ }
38 try { await $.command.register({ name: 'token-run', description: 'Play a local runner: [help|close]' }) } catch { /* Session startup still proceeds. */ }
39 await records($, state).load(); paint($)
40 return next(e)
41 })
42 on('session.end', async ($, e, next) => {
43 detach(state); state.requests.reset()
44 try { await $.ui.close({ id: PANE }) } catch { /* Lifecycle must proceed. */ }
45 paint($)
46 return next(e)
47 })
48 on('turn.start', async ($, e, next) => { state.requests.begin(e.turnId); paint($); return next(e) })
49 on('turn.complete', async ($, e, next) => {
50 state.requests.complete(e.turnId, e.reason, e.agentId); paint($)
51 return next(e)
52 })
53 on('turn.step', async function* ($, e, next) {
54 const generation = state.requests.snapshot().generation
55 const iterator = next(e)
56 let finished = false
57 try {
58 while (true) {
59 const result = await iterator.next()
60 if (result.done) { finished = true; return result.value }
61 const before = state.requests.snapshot().status
62 state.requests.observe(e.turnId, result.value.kind, e.agentId, generation)
63 if (before !== state.requests.snapshot().status) paint($)
64 yield result.value
65 }
66 } finally {
67 if (!finished) await iterator.return?.(undefined as never)
68 }
69 })
70 on('command.run', { command: 'token-run' }, async ($, e) => {
71 const args = (e.args ?? '').trim()
72 if (args === 'help') return { text: HELP }
73 if (args === 'close') { try { await close($, state) } catch { /* Can retry close via engine controls. */ }; return { text: 'Token Run · 작업으로 돌아갑니다.' } }
74 if (args !== '') return { text: 'Usage: /token-run [help|close]' }
75 if (!state.supported) return { text: 'Token Run requires an interactive terminal or desktop session. /token-run help' }
76 return { text: await start($, state) }
77 })
78 on('ui.close', { id: PANE }, async ($, e, next) => { detach(state); paint($); return next(e) })
79 on('ui.message', { element: 'runner' }, async ($, e) => {
80 const data = e.data
81 if (e.component !== 'Pane' || e.requestId !== PANE || !state.open || !data || typeof data !== 'object' || Array.isArray(data)) return {}
82 const message = data as Record<string, unknown>
83 if (message.runId !== state.runId || !validScore(message.score) || (message.type !== 'score' && message.type !== 'exit')) return {}
84 const checkpoint = message.game === undefined ? undefined : decodeGame(message.game)
85 if (message.game !== undefined && (!checkpoint || score(checkpoint) > message.score)) return {}
86 if (checkpoint) state.game = checkpoint
87 const current = state.runId
88 const saving = records($, state).save(message.score)
89 if (message.type === 'exit' && state.open && current === state.runId) {
90 try { await close($, state) } catch { /* Engine close controls remain available. */ }
91 }
92 await saving
93 paint($)
94 return {}
95 })
96 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
97 const existing = await next(e)
98 if (e.props.hasSurvey || e.props.bodyColumns < 24 || e.props.maxRows < 1 || !state.supported || state.requests.snapshot().status !== 'failed' || state.open) return existing
99 if (e.surface !== 'terminal' && e.surface !== 'desktop') return existing
100 const { Box, Button } = $.ui.resolve(e)
101 return <Box flexDirection="column">{existing}<Button key="run-invite" plain label="요청 실패 · 잠깐 달려볼까요?" onPress={async () => { await start($, state) }} /></Box>
102 })
103 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
104 const existing = await next(e)
105 if (e.requestId !== PANE || !state.open || (e.surface !== 'terminal' && e.surface !== 'desktop')) return existing
106 const { Box, Client } = $.ui.resolve(e)
107 const record = records($, state).snapshot()
108 const request = state.requests.snapshot()
109 const columns = Number.isFinite(e.props.bodyColumns) ? Math.max(0, Math.min(80, Math.floor(e.props.bodyColumns))) : 0
110 const rows = Number.isFinite(e.props.scroll.bodyRows) ? Math.max(0, Math.min(8, Math.floor(e.props.scroll.bodyRows))) : 0
111 return <Box flexDirection="column">{existing}<Client key="runner" module="./game.tsx"
112 width={Math.max(1, columns)} height={Math.max(1, rows)}
113 props={{ runId: state.runId, columns, rows, checkpoint: state.game ?? null, best: record.best, error: record.error ?? '', status: request.status, recovered: request.recovered }} /></Box>
114 })
115}
116hooks/core/game.ts 80 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}
55
56/** Validate in-memory Client checkpoints at the host boundary; never trust posted data. */
57export function decodeGame(value: unknown): Game | undefined {
58 if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
59 const g = value as Record<string, unknown>
60 const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
61 const uint = (n: unknown): n is number => finite(n) && Number.isSafeInteger(n) && n >= 0
62 if (typeof g.phase !== 'string' || !['ready', 'running', 'paused', 'over'].includes(g.phase)
63 || !uint(g.ticks) || !uint(g.seed) || g.seed > 0xffffffff
64 || !uint(g.initialSeed) || g.initialSeed < 1 || g.initialSeed > 0xffffffff
65 || !finite(g.y) || g.y < 0 || g.y > 4 || !finite(g.vy) || Math.abs(g.vy) > 1
66 || !finite(g.distance) || g.distance < 0 || g.distance > g.ticks * 1.2 + 1
67 || !finite(g.nextSpawn) || g.nextSpawn < g.distance || g.nextSpawn > g.distance + 56
68 || !Array.isArray(g.obstacles) || g.obstacles.length > 3) return undefined
69 const obstacles: Obstacle[] = []
70 for (const item of g.obstacles) {
71 if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined
72 const o = item as Record<string, unknown>
73 if (!finite(o.x) || o.x < -2 || o.x > WORLD || typeof o.label !== 'string' || !['BUG', '429', 'TIMEOUT'].includes(o.label)
74 || o.width !== (o.label === 'TIMEOUT' ? 2 : 1) || o.height !== 1) return undefined
75 obstacles.push({ x: o.x, width: o.label === 'TIMEOUT' ? 2 : 1, height: 1, label: o.label as Obstacle['label'] })
76 }
77 return { phase: g.phase as Game['phase'], ticks: g.ticks, y: g.y, vy: g.vy,
78 distance: g.distance, nextSpawn: g.nextSpawn, seed: g.seed, initialSeed: g.initialSeed, obstacles }
79}
80hooks/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 79 lines1import type { ClientModule } from 'claude-code'
2import { newGame, act, step, score, decodeGame, 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; checkpoint: Game | null; 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: Math.max(current.best, score(game)), game })
14 }
15 const apply = (action: Action | 'exit') => {
16 const current = surface.state
17 if (!current) return
18 if (action === 'exit') { send('exit', current.game); return }
19 if (!playable()) return
20 const game = act(current.game, action)
21 if (game === current.game) return
22 surface.setState({ ...current, game, best: Math.max(current.best, score(current.game)), lastScore: score(game) })
23 // Interaction checkpoints preserve pause/restart across a dock/inline remount.
24 send('score', game)
25 }
26 if (!surface.state) {
27 surface.setState({ game: decodeGame(props.checkpoint) ?? newGame(props.runId), props, lastScore: props.checkpoint ? score(props.checkpoint) : 0, best: props.best })
28 // The engine delivers these only while Client itself owns the keys.
29 // Pane.isFocused describes a different focus ring and becomes false on Client focus.
30 surface.onKey(event => {
31 if (event.ctrl || event.meta || event.shift) return
32 const key = event.key.toLowerCase()
33 if (key === ' ' || key === 'up') apply('jump')
34 else if (key === 'r') apply('restart')
35 else if (key === 'p') apply('pause')
36 else if (key === 'q') apply('exit')
37 })
38 // Subscribe to the track's pointer region as well as its child Buttons.
39 // This also lets the real terminal hand keyboard focus to the Client.
40 surface.onPointer(event => {
41 if (event.type === 'down' && event.button === 'left' && !event.ctrl && !event.alt && !event.shift
42 && event.y >= 2 && event.y < 7 && event.x >= 0 && event.x < surface.columns) apply('jump')
43 })
44 surface.every(FRAME_MS, () => {
45 const current = surface.state
46 if (!current || !playable() || current.game.phase !== 'running') return
47 const game = step(current.game)
48 const value = score(game)
49 const checkpoint = value > current.lastScore || game.phase === 'over'
50 surface.setState({ ...current, game, best: Math.max(current.best, value), lastScore: checkpoint ? value : current.lastScore })
51 if (checkpoint) send('score', game)
52 })
53 } else if (surface.state.props.runId !== props.runId) {
54 surface.setState({ game: decodeGame(props.checkpoint) ?? newGame(props.runId), props, lastScore: props.checkpoint ? score(props.checkpoint) : 0, best: props.best })
55 } else if (JSON.stringify(surface.state.props) !== JSON.stringify(props)) {
56 surface.setState({ ...surface.state, props, best: Math.max(surface.state.best, props.best) })
57 }
58 const game = surface.state?.game ?? decodeGame(props.checkpoint) ?? newGame(props.runId)
59 const view = gameView(game, Math.min(surface.columns, props.columns), Math.min(surface.rows, props.rows))
60 if (!view.playable) return <Box key="run-resize"><Text wrap="truncate-end">{view.lines[0] ?? ''}</Text></Box>
61 const status = props.error || (props.recovered ? '응답이 돌아왔어요 · Esc로 작업 복귀' : props.status === 'failed' ? '요청 실패 · 게임은 로컬에서 실행 중' : '트랙 클릭/Space: 점프 · P: 정지 · Q: 닫기')
62 const phase = { ready: 'CLICK + SPACE', running: 'TOKEN RUN', paused: 'PAUSED P', over: 'GAME OVER R' }[game.phase]
63 const detail = Math.min(surface.columns, props.columns) < 48
64 ? props.error ? 'SAVE!' : props.recovered ? 'BACK:Esc' : props.status === 'failed' ? 'LOCAL' : 'Q:exit'
65 : status
66 return <Box flexDirection="column" overflow="hidden">
67 <Box key="run-phase"><Box key="run-status"><Text bold color={game.phase === 'over' ? '#E9A09A' : '#62B8AD'} wrap="truncate-end">{`${phase} | ${detail}`}</Text></Box></Box>
68 <Box key="run-score"><Text wrap="truncate-end">{`SCORE ${score(game)} BEST ${Math.max(surface.state?.best ?? 0, props.best, score(game))}`}</Text></Box>
69 {view.lines.slice(2, 7).map((line, index) => <Box key={`track-${index + 2}`}><Text wrap="truncate-end">{line}</Text></Box>)}
70 <Box flexDirection="row">
71 <Button key="run-jump" plain label="Jump " onPress={() => apply('jump')} />
72 <Button key="run-restart" plain label="R " onPress={() => apply('restart')} />
73 <Button key="run-pause" plain label="P " onPress={() => apply('pause')} />
74 <Button key="run-close" plain label="Q " onPress={() => apply('exit')} />
75 </Box>
76 </Box>
77}
78export default Runner
79hooks/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