Games above the Claude Code prompt (snake, Tetris, 2048, Minesweeper, Flappy, Pong, typing test, Space Invaders, Doom) plus a pet that grows as Claude tests…

Vendored from sezaakgun/cc-arcade (MIT, by Seza Akgün) as a catalog example of a multi-file mod. Install with
npx claude-code-templates@latest --mod games/cc-arcade, or from the author's marketplace as described below. Report game issues upstream. Local change:$.clock.now()is awaited (it resolves to a number in the 2.1.271 declarations; upstream used it synchronously).
Games above the Claude Code prompt, for the minutes Claude spends working.
Run /arcade, pick a game, and play it right above the prompt while Claude works on your request. When Claude finishes the turn, the game pauses and its status line says so, so you never miss a reply. A pet grows alongside, fed by the tests Claude passes, the commits it makes and the files it edits. Playing costs no tokens: the plugin answers every click and command itself, without asking the model.


Nine games and a pet: snake, Tetris, and Doom — a first-person corridor shooter raycast into half blocks, with four floors, three monsters and three weapons — plus 2048, Minesweeper, Flappy, Pong against the computer, a typing test and Space Invaders. Each board has one status line underneath with the score, the controls that matter now and your best score.
The plugin is built on Claude Code function hooks: TypeScript that runs inside Claude Code's own process, instead of shell-command hooks. They are on by default from Claude Code 2.1.287.
claude -p, the desktop app or mobile. claude plugin marketplace add sezaakgun/cc-arcade
claude plugin install cc-arcade@cc-arcade
claude and run /arcade. A row of buttons appears above the prompt: one per game, plus pet, random, auto and close.
snake. The board appears in place of the buttons.
← games above the board to go back to the list, or close to close the arcade.If /arcade is an unknown command, see Troubleshooting.
To remove it:
claude plugin uninstall cc-arcade
claude plugin marketplace remove cc-arcade
To try it without installing, or to hack on it, clone and load it for one session:
git clone https://github.com/sezaakgun/cc-arcade
cd cc-arcade
claude --plugin-dir .
The repo's own .claude/settings.json sets the variable for sessions started inside the folder, which also loads any other installed plugin's hooks module there.
/arcade and click one, or name it directly: /arcade tetris./arcade random starts any game except the pet and the one you just played. /arcade auto picks one for the moment; see Auto./arcade list.← games above the board. The picker comes back and the game you left stops./arcade <game>. One game is open at a time.close above the board or in the picker, or run /arcade stop./arcade colorblind toggles a palette and pattern for doom that does not lean on red against green, and remembers the setting. See Doom.Every form of /arcade runs straight away, even while Claude is working.
WASD mirrors the arrows in every game that uses them, except doom, where A and D strafe and the arrows turn.
snake: arrows steer, Space or p pauses, r restarts. Eat the red food, keep off the walls and your own tail.tetris: ← → move, ↑ or x rotates, ↓ drops one row, Space drops to the bottom, p pauses, r restarts. The pieces fall faster every ten lines.doom: a first-person corridor shooter with four floors, three monsters and three weapons. It has its own section.2048: arrows slide every tile, r restarts. Equal tiles merge; the game ends when no slide changes anything.minesweeper: click a cell to open it, right-click to flag it. Without a mouse: arrows move the blue cursor, Space or Enter opens, f flags. r deals a new board. The first click never hits a mine.flappy: Space, ↑, w, Enter or a click flaps. p pauses, r restarts. Touching the ceiling only stops you; the floor and the pipes end the round.pong: ↑ ↓, or move the mouse over the board, to place your paddle on the left. Space pauses, r restarts. First to 5 wins.typing: type the line shown. Correct characters turn green; a wrong one shows on red until you backspace it. Tab skips to another line, Enter starts the next one after a finished line. Only keys the terminal delivers count, so pasting is no shortcut.invaders: ← → or the mouse move the ship, Space, ↑ or a click fires. p pauses, r restarts. One shot at a time, three lives, and each cleared wave starts a row lower, up to three rows, and steps faster.![]() | ![]() |
![]() | ![]() |
![]() | ![]() |
These are clones of the genre, not affiliated with or endorsed by Tetris Holding, Taito, Atari or id Software.
A limited copy, not a port. No WAD file, no id Software art or levels, no sectors, no sound, no saves — a small corridor shooter that borrows the shape of the thing. The floors are mine.

W S, or ↑ ↓ | walk forward and back |
A D | strafe |
← →, or q e | turn |
| mouse over the view | aim; a click fires |
| Space, Enter | fire |
1 2 3 | pistol, shotgun, chaingun — the ones you are carrying |
p, r | pause, start again |
Four floors, played in order and then round again from the first, so the fifth floor is the first one with whatever arsenal you kept. There is no ending: you play until you die, and the score is the kills.
Walk over a pickup to take it. Stand next to a locked door holding the red key and it opens for good. Walk into the exit switch — the bright green pillar — to carry everything but the red key to the next floor, plus ten bullets.

| monster | weapon | ||||
|---|---|---|---|---|---|
| imp | 60 hp | chases, claws, throws fireballs | pistol | 18 a shot | what you start with |
| demon | 110 hp | faster, tougher, bites only | shotgun | 3 × 22 | kills an imp in one, up close |
| zombie | 30 hp | shoots from a distance, closes slowly | chaingun | 12 a shot | a shot every other frame |
Bullets and shells are separate pools and both run out; a gun that clicks empty switches itself to one that has not.
/arcade colorblind swaps in a palette that does not lean on red against green, and marks the door and the exit switch with a pattern each. It is remembered between sessions.
The two pictures above are rendered from the board's own output rather than captured from a session, so the frame Claude Code draws around it is missing.
Send your prompt, then open a game. When the turn finishes, the open game pauses and its status line turns yellow and starts with ● Claude is done. Press a key in the game to carry on, or Esc to go and read the reply. Doom dims the view and says it across the middle as well, since a paused corridor and a running one are the same picture. The pet has no status line, so it shows no banner.
The board takes the rows above the prompt that Claude Code gives plugins, about half the terminal height. A taller terminal gives bigger snake, Tetris, Flappy, Pong and Invaders boards.
/arcade auto, or auto in the picker, looks at the moment and picks from a small group:
pet, to check on it.tetris, invaders, doom or snake, something to sink into.flappy, pong or typing, a quick round.2048 or minesweeper, games you can put down any time.Within the group it picks at random and avoids the game you just played. The reply says why, for example 2048 (auto: Claude is idle, a game you can put down any time).
/arcade pet shows a pet that grows with the work Claude does, in every session with the plugin loaded. You do not play it; it watches Claude's tool calls. Click it and it shows hearts.

bun test, npm test, pnpm test, yarn test, pytest, go test, cargo test, jest, vitest, mocha, rspec, phpunit, make test, and test tasks of mvn, gradle, gradlew and sbt, also through npx.git commit that succeeds gives +15 XP and 10 mood. A --dry-run does not count.Edit, Write or NotebookEdit gives +1 XP and 1 mood.Level n needs 10·*n*² XP. The pet hatches at level 1, becomes a kid at 3, an adult at 6 and a legend at 10, and a toast marks each level. Its mood is happy from 70, okay from 35 and sad below; with no work it sinks a point every ten minutes until it is bored at 30, and only failing tests take it lower.
The pet is kept in the plugin's store, so every session feeds the same one and it survives restarts. Commands you run yourself in a terminal do not count; only Claude's tool calls do.
What the pet sees. To feed it, the plugin hooks every tool call in sessions where it is loaded, which means it sees each Bash command's text and whether the call failed. It matches that text against the list above and keeps counters only: no command text, file path or prompt is stored or logged. The plugin makes no network calls of any kind; the pet and your best scores stay in Claude Code's plugin store on this machine.
Each game keeps its best score across sessions, and a new best shows a toast. /arcade list shows them all.
hooks/register.tsx is the hooks module. It registers /arcade and draws the picker or the open board above the prompt. It keeps best scores and the pet with $.store, times turns for auto on turn.start, pauses the open game on turn.complete, and feeds the pet on tool.call.hooks/boards/*.tsx are surface modules, one per board, mounted as Client elements. Each runs on the drawing thread with its own frame clock, keyboard and mouse, and posts a finished round's score back to the hooks module.hooks/games/*.ts hold each game's rules, the pet, the best-score comparison and the random and auto pickers as pure functions. The tests cover them./arcade is an unknown command. The plugin is not loaded. Check Claude Code is 2.1.287 or later and that the plugin shows in /plugins./arcade <game> still opens a game, but a board cannot get the keyboard without a click./arcade stop and open it again. If it repeats, start Claude with --debug-file /tmp/cc-arcade.log and search the log for cc-arcade.claude -p run, the desktop app and mobile never draw the arcade.From the repo root:
The first two work on a bare clone, and CI runs both on every push:
bun test # game rules, the pet, best scores, random and auto
bunx --bun oxlint@1.83.0 hooks tests --deny-warnings # dead code and correctness, no config file
claude plugin validate .claude-plugin/plugin.json # lists the hooked events, $ calls and surface modules
claude plugin validate . # checks the marketplace manifest
Type checking needs the types: open a Claude Code session in this folder run /plugin-types (it writes the git-ignored .claude/types/), then:
bunx -p typescript tsc -p .
Two rules for board files: never name a local variable h, because every JSX tag compiles to a call of h and a local h breaks the board at its first draw; and write Client module paths as string literals, because the engine reads them off the source.
Edits hot-reload into a running session. If a reload fails partway, the transcript says so; restart the session.
MIT. See LICENSE.
hooks/register.tsx 249 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3import { pickAuto, pickRandom, type AutoContext } from './games/auto.ts'
4import { isBetter } from './games/best.ts'
5import { feed, level, newPet, petEvent, stage, type Pet } from './games/pet.ts'
6
7// One command, /arcade. With no argument it shows a picker above the prompt; `/arcade <game>` starts
8// a game, `random` picks one, `auto` picks one for the moment, `list` prints them, `stop` closes.
9// Each board in ./boards is a surface module that runs on the drawing thread; this module mounts the
10// board, keeps each game's best score in $.store, pauses the open game when Claude finishes a turn,
11// and feeds the pet from Claude's tool calls.
12
13type Game = { title: string; blurb: string; lowerIsBetter?: boolean; unit?: string }
14
15const GAMES: Record<string, Game> = {
16 snake: { title: 'Snake', blurb: 'eat, grow, and keep off the walls and yourself' },
17 tetris: { title: 'Tetris', blurb: 'fit the falling pieces and clear lines' },
18 '2048': { title: '2048', blurb: 'slide and merge tiles up to 2048' },
19 minesweeper: { title: 'Minesweeper', blurb: 'open every cell that is not a mine', lowerIsBetter: true, unit: 's' },
20 flappy: { title: 'Flappy', blurb: 'flap through the gaps in the pipes' },
21 pong: { title: 'Pong', blurb: 'first to 5 against the computer' },
22 typing: { title: 'Typing test', blurb: 'retype a line of code as fast as you can', unit: ' wpm' },
23 invaders: { title: 'Space Invaders', blurb: 'shoot each wave before it lands' },
24 doom: { title: 'Doom', blurb: 'a corridor shooter: imps, a shotgun, and the floor below' },
25 pet: { title: 'Pet', blurb: 'grows as Claude tests, commits and edits' },
26}
27// spelled out: Object.keys would put the integer-like '2048' first
28const NAMES = ['snake', 'tetris', 'doom', '2048', 'minesweeper', 'flappy', 'pong', 'typing', 'invaders', 'pet']
29// random never lands on the pet: it is not a game you play
30const PLAYABLE = NAMES.filter(n => n !== 'pet')
31const PICKER = 'picker'
32const isGame = (name: unknown): name is string => typeof name === 'string' && Object.hasOwn(GAMES, name)
33
34// a game name, PICKER, or nothing open
35let active: string | undefined
36let lastGame: string | undefined
37let turnStartedAt: number | undefined
38// bumped at each finished turn; the open game pauses when it sees a new value
39let turnsDone = 0
40const best: Record<string, number> = {}
41// a palette and pattern that do not lean on red against green; doom is the game that uses it
42let safe = false
43let pet: Pet | undefined
44
45const isPet = (value: unknown): value is Pet => typeof value === 'object' && value !== null && typeof (value as Pet).xp === 'number'
46const autoContext = (now: number): AutoContext => ({
47 working: turnStartedAt !== undefined,
48 turnMs: turnStartedAt === undefined ? 0 : now - turnStartedAt,
49 lastEvent: pet?.last,
50 lastEventAgoMs: pet ? now - pet.at : undefined,
51 lastGame,
52})
53
54export const register: Register = on => {
55 on('session.start', async ($, e, next) => {
56 const r = await next(e)
57 // a store that cannot be read or written costs the pet and the scores, never the games:
58 // an unhandled rejection here would unmount the whole module
59 const stored = (key: string) => $.store.get(key).catch(err => { $.ui.log(`cc-arcade: store read failed: ${err}`); return undefined })
60 const saved = await stored('pet')
61 pet = isPet(saved) ? saved : newPet(await $.clock.now())
62 if (!isPet(saved)) await $.store.set('pet', pet).catch(err => $.ui.log(`cc-arcade: store write failed: ${err}`))
63 for (const name of NAMES) {
64 const score = Number(await stored(`best:${name}`))
65 if (score) best[name] = score
66 }
67 safe = (await stored('colorblind')) === true
68 await $.command.register({
69 name: 'arcade',
70 description: 'Games above the prompt: pick one, or list, random, auto, stop (cc-arcade)',
71 argumentHint: '[game | list | random | auto | colorblind | stop]',
72 immediate: true,
73 }).catch(err => $.ui.log(`cc-arcade: /arcade not registered: ${err}`))
74 return r
75 })
76
77 // every form of /arcade answers here; none of them passes the command on
78 on('command.run', { command: 'arcade' }, async ($, e) => {
79 const arg = e.args.trim().toLowerCase()
80 const open = (name: string, why?: string) => {
81 active = name
82 lastGame = name
83 $.ui.invalidate('ui.render')
84 const how = name === 'pet' ? 'click it to pet it' : 'click the board to play · Esc returns to the prompt'
85 return { text: `${GAMES[name].title}${why ? ` (${why})` : ''} · ${how} · /arcade stop closes` }
86 }
87 if (arg === '') {
88 active = PICKER
89 $.ui.invalidate('ui.render')
90 return { text: 'arcade: click a game above the prompt · /arcade list describes them' }
91 }
92 if (arg === 'stop') {
93 active = undefined
94 $.ui.invalidate('ui.render')
95 return { text: 'arcade closed' }
96 }
97 if (arg === 'colorblind' || arg === 'cb') {
98 safe = !safe
99 await $.store.set('colorblind', safe).catch(err => $.ui.log(`cc-arcade: store write failed: ${err}`))
100 $.ui.invalidate('ui.render')
101 return { text: `colour-blind mode ${safe ? 'on' : 'off'} · doom swaps in a palette that holds up without red against green, and marks the door and the exit with a pattern each` }
102 }
103 if (arg === 'list') {
104 const rows = NAMES.map(n => {
105 const score = best[n] ? ` · best ${best[n]}${GAMES[n].unit ?? ''}` : ''
106 return `${n.padEnd(12)}${GAMES[n].blurb}${score}`
107 })
108 return { text: [...rows, '', 'random a game you did not just play', 'auto a game for the moment', `colorblind doom without red against green (now ${safe ? 'on' : 'off'})`, 'stop close the arcade'].join('\n') }
109 }
110 if (arg === 'random') return open(pickRandom(PLAYABLE, lastGame))
111 if (arg === 'auto') {
112 const pick = pickAuto(autoContext(await $.clock.now()))
113 return open(pick.game, `auto: ${pick.reason}`)
114 }
115 if (isGame(arg)) return open(arg)
116 return { text: `no game called "${arg}" · /arcade list shows them` }
117 })
118
119 on('turn.start', async ($, e, next) => {
120 turnStartedAt = await $.clock.now()
121 return next(e)
122 })
123
124 on('turn.complete', async ($, e, next) => {
125 const r = await next(e)
126 if (e.agentId) return r
127 turnStartedAt = undefined
128 if (active && active !== PICKER) {
129 turnsDone++
130 $.ui.invalidate('ui.render')
131 }
132 return r
133 })
134
135 // the pet feeds on work: a test run passing or failing, a commit, an edit. It is re-read from the
136 // store first, so sessions running side by side feed the same pet instead of overwriting it.
137 on('tool.call', async ($, e, next) => {
138 const r = await next(e)
139 const command = (e as { command?: unknown }).command
140 const event = petEvent(e.tool, typeof command === 'string' ? command : undefined, 'deny' in r ? undefined : !r.isError)
141 if (!event) return r
142 const saved = await $.store.get('pet').catch(() => undefined)
143 const now = await $.clock.now()
144 const current = isPet(saved) ? saved : pet ?? newPet(now)
145 const fed = feed(current, event, now)
146 pet = fed
147 await $.store.set('pet', fed).catch(err => $.ui.log(`cc-arcade: store write failed: ${err}`))
148 if (level(fed.xp) > level(current.xp)) {
149 const grew = stage(fed.xp) !== stage(current.xp) ? `, and is now ${stage(fed.xp) === 'adult' ? 'an adult' : `a ${stage(fed.xp)}`}` : ''
150 $.ui.toast(`pet: reached level ${level(fed.xp)}${grew}`)
151 }
152 if (active === 'pet') $.ui.invalidate('ui.render')
153 return r
154 })
155
156 // a finished round, posted by a board: keep the best score and hand it back with the next props
157 on('ui.message', async ($, e, next) => {
158 const data = e.data as { game?: unknown; score?: unknown } | null
159 const name = data?.game
160 const score = data?.score
161 if (!isGame(name) || typeof score !== 'number') return next(e)
162 const game = GAMES[name]
163 if (isBetter(score, best[name], game.lowerIsBetter)) {
164 best[name] = score
165 await $.store.set(`best:${name}`, score).catch(err => $.ui.log(`cc-arcade: store write failed: ${err}`))
166 $.ui.toast(`${name}: new best ${score}${game.unit ?? ''}`)
167 }
168 return { props: { best: best[name] ?? 0, done: turnsDone, safe } }
169 })
170
171 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
172 // boards need a terminal's keys and mouse; the desktop and mobile surfaces draw their own band
173 if (!active || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
174 const { Box, Button, Client, Text } = await $.ui.resolve(e)
175
176 if (active === PICKER) {
177 // no hotkeys: a band hotkey would press on a digit typed as the first character of a prompt
178 // $.clock.now() is a dispatch (a Promise); read it once per draw for the picker's closures
179 const now = await $.clock.now()
180 const choose = (name: string) => {
181 if (name === 'close') active = undefined
182 else if (name === 'random') active = pickRandom(PLAYABLE, lastGame)
183 else if (name === 'auto') {
184 const pick = pickAuto(autoContext(now))
185 active = pick.game
186 $.ui.toast(`auto: ${pick.game}, ${pick.reason}`)
187 } else active = name
188 if (active) lastGame = active
189 $.ui.invalidate('ui.render')
190 }
191 return (
192 <Box flexDirection="column">
193 <Text dimColor>{'arcade · click a game · /arcade <game> works too · /arcade stop closes'}</Text>
194 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
195 {[...NAMES, 'random', 'auto', 'close'].map(name => (
196 <Button key={`pick:${name}`} label={name} onPress={() => choose(name)} />
197 ))}
198 </Box>
199 {await next(e)}
200 </Box>
201 )
202 }
203
204 // the band's own width: the transcript column's while a Pane is docked beside it
205 const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
206 // $.clock.now() is a dispatch (a Promise): resolve it before the pet board reads it as a number
207 const now = await $.clock.now()
208 // one row for the back and close buttons, one for whatever else draws in the band
209 const rows = Math.max(8, e.props.maxRows - 2)
210 const tall = (max: number) => Math.min(rows, max)
211 const props = { best: best[active] ?? 0, done: turnsDone, safe }
212 // the key names the board, so switching mounts a fresh one and a redraw keeps the running one;
213 // module paths must be string literals, since the engine reads them off this source
214 const key = `board:${active}`
215 const board =
216 active === 'snake' ? <Client key={key} module="./boards/snake.tsx" width={cols} height={rows} props={props} />
217 : active === 'tetris' ? <Client key={key} module="./boards/tetris.tsx" width={cols} height={tall(23)} props={props} />
218 : active === '2048' ? <Client key={key} module="./boards/twenty48.tsx" width={cols} props={props} />
219 : active === 'minesweeper' ? <Client key={key} module="./boards/minesweeper.tsx" width={cols} height={tall(12)} props={props} />
220 : active === 'flappy' ? <Client key={key} module="./boards/flappy.tsx" width={cols} height={tall(19)} props={props} />
221 : active === 'pong' ? <Client key={key} module="./boards/pong.tsx" width={cols} height={tall(18)} props={props} />
222 : active === 'invaders' ? <Client key={key} module="./boards/invaders.tsx" width={cols} height={tall(19)} props={props} />
223 // doom is a view, not a board: it takes every row and column the band gives it
224 : active === 'doom' ? <Client key={key} module="./boards/doom.tsx" width={cols} height={rows} props={props} />
225 : active === 'pet' ? <Client key={key} module="./boards/pet.tsx" width={cols} props={{ pet, now }} />
226 : <Client key={key} module="./boards/typing.tsx" width={cols} props={props} />
227 // back to the picker, or close; click-only like the picker (no hotkeys: a band hotkey would press
228 // on a digit typed as the first character of a prompt)
229 const toPicker = () => {
230 active = PICKER
231 $.ui.invalidate('ui.render')
232 }
233 const close = () => {
234 active = undefined
235 $.ui.invalidate('ui.render')
236 }
237 return (
238 <Box flexDirection="column">
239 <Box flexDirection="row" columnGap={1}>
240 <Button key="arcade:back" label="← games" onPress={toPicker} />
241 <Button key="arcade:close" label="close" onPress={close} />
242 </Box>
243 {board}
244 {await next(e)}
245 </Box>
246 )
247 })
248}
249hooks/games/auto.ts 35 lines1// How /arcade random and /arcade auto choose a game. Pure; the hooks module supplies the context.
2
3export type AutoContext = {
4 // a model turn is running, and for how long
5 working: boolean
6 turnMs: number
7 // the pet's last event (test-pass, test-fail, commit, edit) and how long ago it was
8 lastEvent?: string
9 lastEventAgoMs?: number
10 lastGame?: string
11}
12
13// a random game from the pool, not the one just played when there is another
14export function pickRandom(pool: string[], last: string | undefined, rand = Math.random): string {
15 const fresh = pool.filter(g => g !== last)
16 const from = fresh.length ? fresh : pool
17 return from[Math.floor(rand() * from.length)]
18}
19
20// a game for the moment: tests that just failed send you to the pet; a long turn gets a game you
21// can sink into; a short one a quick round; an idle Claude something you can put down any time
22export function pickAuto(ctx: AutoContext, rand = Math.random): { game: string; reason: string } {
23 if (ctx.lastEvent === 'test-fail' && (ctx.lastEventAgoMs ?? Infinity) < 5 * 60_000) {
24 return { game: 'pet', reason: 'tests just failed, your pet could use a visit' }
25 }
26 if (ctx.working && ctx.turnMs >= 60_000) {
27 const min = Math.floor(ctx.turnMs / 60_000)
28 return { game: pickRandom(['tetris', 'invaders', 'doom', 'snake'], ctx.lastGame, rand), reason: `Claude has been working ${min} min, time for a longer game` }
29 }
30 if (ctx.working) {
31 return { game: pickRandom(['flappy', 'pong', 'typing'], ctx.lastGame, rand), reason: 'Claude is working, a quick round' }
32 }
33 return { game: pickRandom(['2048', 'minesweeper'], ctx.lastGame, rand), reason: 'Claude is idle, a game you can put down any time' }
34}
35hooks/games/best.ts 10 lines1// Which score counts as a new best. Minesweeper's best is its fastest clear, so lower wins there;
2// everywhere else higher wins. A score of zero or less never counts: a Minesweeper board whose
3// first click clears it takes 0 s and would otherwise lock the best forever, and a Pong match you
4// lost posts a negative margin.
5export function isBetter(score: number, prev: number | undefined, lowerIsBetter = false): boolean {
6 if (!Number.isFinite(score) || score <= 0) return false
7 if (prev === undefined || prev <= 0) return true
8 return lowerIsBetter ? score < prev : score > prev
9}
10hooks/games/pet.ts 65 lines1// The pet: pure logic. It grows with the work Claude does in any session: tests passing, commits
2// and edits give it xp and lift its mood; failing tests hurt its mood; idle time slowly bores it.
3// The hooks module feeds it from tool calls and keeps it in $.store; ../boards/pet.tsx draws it.
4
5export type PetEvent = 'test-pass' | 'test-fail' | 'commit' | 'edit'
6export type Pet = {
7 xp: number
8 mood: number
9 // when it was last fed, and when it hatched (ms since the epoch)
10 at: number
11 born: number
12 tests: number; fails: number; commits: number; edits: number
13 last?: PetEvent
14}
15
16const XP: Record<PetEvent, number> = { 'test-pass': 10, 'test-fail': 2, commit: 15, edit: 1 }
17const MOOD: Record<PetEvent, number> = { 'test-pass': 8, 'test-fail': -12, commit: 10, edit: 1 }
18const BORED = 30
19
20export const newPet = (now: number): Pet => ({ xp: 0, mood: 70, at: now, born: now, tests: 0, fails: 0, commits: 0, edits: 0 })
21
22// test runners across common stacks; a failing run is a tool call that ended in error
23const TEST_COMMAND = /\b(bun|npm|pnpm|yarn)\s+(run\s+)?test\b|\bnpx\s+(jest|vitest|mocha)\b|\b(pytest|jest|vitest|rspec|phpunit|mocha)\b|\bgo\s+test\b|\bcargo\s+test\b|\bmake\s+test\b|\b(mvn|gradle|gradlew|sbt)\b.*\btest\b/
24
25// what a finished tool call means for the pet; `ok` is undefined when the call was denied
26export function petEvent(tool: string, command: string | undefined, ok: boolean | undefined): PetEvent | undefined {
27 if (ok === undefined) return undefined
28 if (tool === 'Bash' && command) {
29 if (TEST_COMMAND.test(command)) return ok ? 'test-pass' : 'test-fail'
30 if (ok && /\bgit\s+commit\b/.test(command) && !/--dry-run\b/.test(command)) return 'commit'
31 return undefined
32 }
33 if (ok && (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit')) return 'edit'
34 return undefined
35}
36
37// idle, mood sinks a point every ten minutes, down to bored; below that only failures take it
38export function moodAt(p: Pet, now: number): number {
39 if (p.mood <= BORED) return p.mood
40 return Math.max(BORED, p.mood - Math.floor(Math.max(0, now - p.at) / 600_000))
41}
42
43export function feed(p: Pet, event: PetEvent, now: number): Pet {
44 return {
45 ...p,
46 xp: p.xp + XP[event],
47 mood: Math.max(0, Math.min(100, moodAt(p, now) + MOOD[event])),
48 at: now,
49 last: event,
50 tests: p.tests + (event === 'test-pass' ? 1 : 0),
51 fails: p.fails + (event === 'test-fail' ? 1 : 0),
52 commits: p.commits + (event === 'commit' ? 1 : 0),
53 edits: p.edits + (event === 'edit' ? 1 : 0),
54 }
55}
56
57// level n needs 10·n² xp: level 1 at 10, 2 at 40, 3 at 90, 6 at 360, 10 at 1000
58export const level = (xp: number) => Math.floor(Math.sqrt(xp / 10))
59export type Stage = 'egg' | 'baby' | 'kid' | 'adult' | 'legend'
60export const stage = (xp: number): Stage => {
61 const l = level(xp)
62 return l >= 10 ? 'legend' : l >= 6 ? 'adult' : l >= 3 ? 'kid' : l >= 1 ? 'baby' : 'egg'
63}
64export const feeling = (mood: number) => (mood >= 70 ? 'happy' : mood >= 35 ? 'okay' : 'sad')
65hooks/boards/snake.tsx 62 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { newSnake, step, turn, type SnakeGame } from '../games/snake.ts'
5
6type State = Base & { game?: SnakeGame }
7
8export default function Snake(props: Props, surface: ClientSurface<State>) {
9 const { Box, Text } = surface.elements
10 const size = () => ({ bw: Math.floor((surface.columns - 2) / 2), bh: surface.rows - 3 })
11
12 if (surface.state === undefined) {
13 surface.setState(base(props))
14 surface.every(100, () => {
15 const s = surface.state
16 if (!s?.game || !s.playing || s.game.over) return
17 const game = step(s.game)
18 if (game.over) surface.post({ game: 'snake', score: game.score })
19 surface.setState({ ...s, game })
20 })
21 surface.onKey(({ key }) => {
22 const s = surface.state
23 if (!s) return
24 // the space bar may arrive as the character or by name
25 if (key === ' ' || key === 'space' || key === 'p') {
26 if (s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing })
27 return
28 }
29 // no round yet, or the last one ended: an arrow (or r) starts a new one
30 if (!s.game || s.game.over || key === 'r') {
31 const { bw, bh } = size()
32 if (bw < 4 || bh < 3) return
33 surface.setState({ ...s, banner: false, playing: true, game: turn(newSnake(bw, bh), key) })
34 return
35 }
36 surface.setState({ ...s, banner: false, playing: true, game: turn(s.game, key) })
37 })
38 }
39 claudeDone(props, surface)
40
41 const s = surface.state
42 const g = s?.game
43 const { bw, bh } = g ? { bw: g.w, bh: g.h } : size()
44 const head = g?.snake[0]
45 const body = new Set(g?.snake.slice(1).map(p => `${p[0]},${p[1]}`))
46 const rows = grid(Text, bw, bh, (x, y) =>
47 head && head[0] === x && head[1] === y ? ['██', 'greenBright']
48 : body.has(`${x},${y}`) ? ['▓▓', 'green']
49 : g && g.food[0] === x && g.food[1] === y ? ['██', 'red']
50 : [' '])
51 const text = !g ? 'snake · click here, then an arrow key · wasd works too'
52 : g.over ? `snake · game over · score ${g.score} · an arrow or r plays again`
53 : !s?.playing ? `snake · paused · score ${g.score} · space resumes`
54 : `snake · score ${g.score} · space pauses`
55 return (
56 <Box flexDirection="column">
57 <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : 'green'} width={Math.max(0, bw) * 2 + 2}>{rows}</Box>
58 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
59 </Box>
60 )
61}
62hooks/boards/tetris.tsx 75 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { cells, drop, dropEvery, fall, NAMES, newTetris, rotate, shift, type TetrisGame } from '../games/tetris.ts'
5
6const COLORS = ['cyan', 'yellow', 'magenta', 'green', 'red', 'blue', '#ff8800']
7type State = Base & { game?: TetrisGame }
8
9export default function Tetris(props: Props, surface: ClientSurface<State>) {
10 const { Box, Text } = surface.elements
11 const boardRows = () => Math.min(20, surface.rows - 3)
12 // ten columns wide, less on a terminal too narrow for the board
13 const boardCols = () => Math.max(6, Math.min(10, Math.floor((surface.columns - 2) / 2)))
14
15 if (surface.state === undefined) {
16 surface.setState(base(props))
17 let ticks = 0
18 surface.every(100, () => {
19 const s = surface.state
20 if (!s?.game || !s.playing || s.game.over) return
21 if (++ticks < dropEvery(s.game.lines)) return
22 ticks = 0
23 const game = fall(s.game)
24 if (game.over) surface.post({ game: 'tetris', score: game.score })
25 surface.setState({ ...s, game })
26 })
27 surface.onKey(({ key }) => {
28 const s = surface.state
29 if (!s) return
30 // the space bar may arrive as the character or by name
31 const k = key === 'space' ? ' ' : key.toLowerCase()
32 if (!s.game || s.game.over || k === 'r') {
33 if (boardRows() < 6) return
34 surface.setState({ ...s, banner: false, playing: true, game: newTetris(boardCols(), boardRows()) })
35 return
36 }
37 if (k === 'p') {
38 surface.setState({ ...s, banner: false, playing: !s.playing })
39 return
40 }
41 const g = s.game
42 const game = k === 'left' || k === 'a' ? shift(g, -1)
43 : k === 'right' || k === 'd' ? shift(g, 1)
44 : k === 'up' || k === 'w' || k === 'x' ? rotate(g)
45 : k === 'down' || k === 's' ? fall(g)
46 : k === ' ' ? drop(g)
47 : undefined
48 if (!game) return
49 if (game.over) surface.post({ game: 'tetris', score: game.score })
50 surface.setState({ ...s, banner: false, playing: true, game })
51 })
52 }
53 claudeDone(props, surface)
54
55 const s = surface.state
56 const g = s?.game
57 const bw = g?.w ?? boardCols()
58 const bh = g?.h ?? boardRows()
59 const falling = new Map((g && !g.over ? cells(g.piece) : []).map(([x, y]) => [`${x},${y}`, g!.piece.kind]))
60 const rows = grid(Text, bw, bh, (x, y) => {
61 const kind = falling.get(`${x},${y}`) ?? (g?.board[y]?.[x] ?? 0) - 1
62 return kind >= 0 ? ['██', COLORS[kind]] : [' ·', 'gray']
63 })
64 const text = !g ? 'tetris · click here, then any key · ←→ move · ↑ rotate · ↓ soft drop · space drop · p pause'
65 : g.over ? `tetris · game over · score ${g.score} · lines ${g.lines} · r plays again`
66 : !s?.playing ? `tetris · paused · score ${g.score} · p resumes`
67 : `tetris · score ${g.score} · lines ${g.lines} · next ${NAMES[g.next]} · p pause`
68 return (
69 <Box flexDirection="column">
70 <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : 'cyan'} width={bw * 2 + 2}>{rows}</Box>
71 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
72 </Box>
73 )
74}
75hooks/boards/twenty48.tsx 57 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, statusLine, type Base, type Props } from './common.tsx'
4import { move, new2048, SIZE, type G2048 } from '../games/twenty48.ts'
5
6const TILE_COLORS: Record<number, string> = { 2: 'white', 4: 'whiteBright', 8: 'yellow', 16: 'yellowBright', 32: '#ff8800', 64: 'red', 128: 'magenta', 256: 'magentaBright', 512: 'blue', 1024: 'cyan', 2048: 'green' }
7const SLIDE_KEYS: Record<string, string> = { left: 'left', right: 'right', up: 'up', down: 'down', a: 'left', d: 'right', w: 'up', s: 'down' }
8type State = Base & { game: G2048 }
9
10export default function Twenty48(props: Props, surface: ClientSurface<State>) {
11 const { Box, Text } = surface.elements
12
13 if (surface.state === undefined) {
14 surface.setState({ ...base(props), game: new2048() })
15 surface.onKey(({ key }) => {
16 const s = surface.state
17 if (!s) return
18 const k = key.toLowerCase()
19 if (k === 'r') {
20 surface.setState({ ...s, banner: false, game: new2048() })
21 return
22 }
23 if (!Object.hasOwn(SLIDE_KEYS, k)) {
24 if (s.banner) surface.setState({ ...s, banner: false })
25 return
26 }
27 const game = move(s.game, SLIDE_KEYS[k])
28 if (game.over && !s.game.over) surface.post({ game: '2048', score: game.score })
29 surface.setState({ ...s, banner: false, game })
30 })
31 }
32 claudeDone(props, surface)
33
34 const s = surface.state
35 const g = s?.game
36 const rows = Array.from({ length: SIZE }, (_, y) => (
37 <Text>
38 {Array.from({ length: SIZE }, (_, x) => {
39 const v = g?.cells[y * SIZE + x] ?? 0
40 const label = `${v ? String(v) : '·'}`.padStart(5) + ' '
41 return v >= 128
42 ? <Text color={TILE_COLORS[v] ?? 'greenBright'} bold>{label}</Text>
43 : <Text color={v ? TILE_COLORS[v] : 'gray'}>{label}</Text>
44 })}
45 </Text>
46 ))
47 const text = !g ? '2048'
48 : g.over ? `2048 · no moves left · score ${g.score} · r plays again`
49 : `2048 · score ${g.score}${g.won ? ' · 2048 reached, keep going' : ''} · click here, then arrows or wasd · r restarts`
50 return (
51 <Box flexDirection="column">
52 <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : 'yellow'} width={SIZE * 6 + 3}>{rows}</Box>
53 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
54 </Box>
55 )
56}
57hooks/boards/minesweeper.tsx 99 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { flag, newMines, reveal, type Mines } from '../games/mines.ts'
5
6const NUMBER_COLORS = ['', 'blue', 'green', 'red', 'magenta', 'yellow', 'cyan', 'white', 'gray']
7const CURSOR_KEYS: Record<string, readonly [number, number]> = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1], a: [-1, 0], d: [1, 0], w: [0, -1], s: [0, 1] }
8type State = Base & { game?: Mines; cursor: readonly [number, number]; seconds: number }
9
10export default function Minesweeper(props: Props, surface: ClientSurface<State>) {
11 const { Box, Text } = surface.elements
12 const size = () => {
13 const bw = Math.max(4, Math.min(16, Math.floor((surface.columns - 2) / 2)))
14 const bh = Math.max(4, Math.min(9, surface.rows - 3))
15 return { bw, bh, count: Math.max(1, Math.round(bw * bh * 0.15)) }
16 }
17 // a click or space reveals, a right-click or f flags; the first action after a finished round
18 // only starts a new board, so a stray click cannot open a cell on it
19 const act = (x: number, y: number, kind: 'reveal' | 'flag') => {
20 const s = surface.state
21 if (!s) return
22 let g = s.game
23 if (!g || g.over || g.won) {
24 const { bw, bh, count } = size()
25 const fresh = newMines(bw, bh, count)
26 if (g) {
27 surface.setState({ ...s, banner: false, playing: false, seconds: 0, game: fresh })
28 return
29 }
30 g = fresh
31 }
32 const game = kind === 'flag' ? flag(g, x, y) : reveal(g, x, y)
33 // a board whose first click clears it takes 0 s; count it as 1 s so it cannot lock the best
34 if (game.won && !g.won) surface.post({ game: 'minesweeper', score: Math.max(1, s.seconds) })
35 const cursor = x >= 0 && y >= 0 && x < game.w && y < game.h ? ([x, y] as const) : s.cursor
36 surface.setState({ ...s, banner: false, playing: game.placed && !game.over && !game.won, game, cursor })
37 }
38
39 if (surface.state === undefined) {
40 surface.setState({ ...base(props), cursor: [0, 0], seconds: 0 })
41 surface.every(1000, () => {
42 const s = surface.state
43 if (s?.game && s.playing && !s.banner) surface.setState({ ...s, seconds: s.seconds + 1 })
44 })
45 surface.onPointer(ev => {
46 if (ev.type !== 'down') return
47 // the border takes the first row and column; each cell is two columns wide
48 act(Math.floor((ev.x - 1) / 2), ev.y - 1, ev.button === 'right' ? 'flag' : 'reveal')
49 })
50 surface.onKey(({ key }) => {
51 const s = surface.state
52 if (!s) return
53 // the space bar may arrive as the character or by name
54 const k = key === 'space' ? ' ' : key.toLowerCase()
55 const bw = s.game?.w ?? size().bw
56 const bh = s.game?.h ?? size().bh
57 if (Object.hasOwn(CURSOR_KEYS, k)) {
58 const [dx, dy] = CURSOR_KEYS[k]
59 const [cx, cy] = s.cursor
60 surface.setState({ ...s, banner: false, cursor: [Math.max(0, Math.min(bw - 1, cx + dx)), Math.max(0, Math.min(bh - 1, cy + dy))] })
61 } else if (k === ' ' || k === 'return') {
62 act(s.cursor[0], s.cursor[1], 'reveal')
63 } else if (k === 'f') {
64 act(s.cursor[0], s.cursor[1], 'flag')
65 } else if (k === 'r') {
66 const { bw: nw, bh: nh, count } = size()
67 surface.setState({ ...s, banner: false, playing: false, seconds: 0, game: newMines(nw, nh, count) })
68 }
69 })
70 }
71 claudeDone(props, surface)
72
73 const s = surface.state
74 const g = s?.game
75 const { bw, bh, count } = g ? { bw: g.w, bh: g.h, count: g.count } : size()
76 const [cx, cy] = s?.cursor ?? [0, 0]
77 const rows = grid(Text, bw, bh, (x, y) => {
78 const t = g?.tiles[y * bw + x]
79 const bg = x === cx && y === cy ? 'blue' : undefined
80 if (!t || (!t.open && !t.flag)) return ['░░', 'gray', bg]
81 if (t.flag && !t.open) return [' F', 'redBright', bg]
82 if (t.mine) return [' *', 'red', bg]
83 return t.n ? [` ${t.n}`, NUMBER_COLORS[t.n], bg] : [' ', undefined, bg]
84 })
85 const flags = g?.tiles.filter(t => t.flag).length ?? 0
86 const seconds = s?.seconds ?? 0
87 const text = !g ? 'minesweeper · click a cell, or click here then arrows + space · right-click or f flags'
88 : g.over ? 'minesweeper · boom · r or a click plays again'
89 : g.won ? `minesweeper · cleared in ${seconds}s · r or a click plays again`
90 : `minesweeper · ${count - flags} mines left · ${seconds}s · click reveals · right-click or f flags`
91 const best = props?.best ? `${props.best}s` : '—'
92 return (
93 <Box flexDirection="column">
94 <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : g?.won ? 'green' : 'gray'} width={bw * 2 + 2}>{rows}</Box>
95 {statusLine(Text, !!s?.banner, `${text} · best ${best}`)}
96 </Box>
97 )
98}
99hooks/boards/flappy.tsx 76 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { BIRD_X, birdRow, flap, gapSize, newFlappy, tick, type FlappyGame } from '../games/flappy.ts'
5
6type State = Base & { game?: FlappyGame }
7
8export default function Flappy(props: Props, surface: ClientSurface<State>) {
9 const { Box, Text } = surface.elements
10 const size = () => ({
11 bw: Math.max(12, Math.min(40, Math.floor((surface.columns - 2) / 2))),
12 bh: Math.max(6, Math.min(16, surface.rows - 3)),
13 })
14 const start = () => {
15 const s = surface.state
16 if (!s) return
17 const { bw, bh } = size()
18 surface.setState({ ...s, banner: false, playing: true, game: flap(newFlappy(bw, bh)) })
19 }
20 // space, up, Enter or a click: flap, or start a new round when there is none
21 const press = () => {
22 const s = surface.state
23 if (!s) return
24 if (!s.game || s.game.over) return start()
25 surface.setState({ ...s, banner: false, playing: true, game: flap(s.game) })
26 }
27
28 if (surface.state === undefined) {
29 surface.setState(base(props))
30 surface.every(100, () => {
31 const s = surface.state
32 if (!s?.game || !s.playing || s.game.over) return
33 const game = tick(s.game)
34 if (game.over) surface.post({ game: 'flappy', score: game.score })
35 surface.setState({ ...s, game })
36 })
37 surface.onKey(({ key }) => {
38 const s = surface.state
39 if (!s) return
40 // the space bar may arrive as the character or by name
41 const k = key === 'space' ? ' ' : key.toLowerCase()
42 if (k === 'p') {
43 if (s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing })
44 } else if (k === 'r') {
45 start()
46 } else if (k === ' ' || k === 'up' || k === 'w' || k === 'return') {
47 press()
48 }
49 })
50 surface.onPointer(ev => { if (ev.type === 'down') press() })
51 }
52 claudeDone(props, surface)
53
54 const s = surface.state
55 const g = s?.game
56 const { bw, bh } = g ? { bw: g.w, bh: g.h } : size()
57 const row = g ? birdRow(g) : Math.floor(bh / 2)
58 const opening = gapSize(bh)
59 const rows = grid(Text, bw, bh, (x, y) => {
60 if (x === BIRD_X && y === row) return ['▓▓', g?.over ? 'red' : 'yellowBright']
61 const pipe = g?.pipes.find(p => p.x === x)
62 if (pipe && (y < pipe.gap || y >= pipe.gap + opening)) return ['██', 'green']
63 return [' ']
64 })
65 const text = !g ? 'flappy · click here, then space or click to flap · p pause'
66 : g.over ? `flappy · crashed · score ${g.score} · space or click flies again`
67 : !s?.playing ? `flappy · paused · score ${g.score} · p resumes`
68 : `flappy · score ${g.score} · space or click flaps`
69 return (
70 <Box flexDirection="column">
71 <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : 'blue'} width={bw * 2 + 2}>{rows}</Box>
72 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
73 </Box>
74 )
75}
76hooks/boards/pong.tsx 83 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { newPong, nudge, place, tick, WIN, type PongGame } from '../games/pong.ts'
5
6type State = Base & { game?: PongGame }
7
8export default function Pong(props: Props, surface: ClientSurface<State>) {
9 const { Box, Text } = surface.elements
10 const size = () => ({
11 bw: Math.max(16, Math.min(40, Math.floor((surface.columns - 2) / 2))),
12 bh: Math.max(8, Math.min(15, surface.rows - 3)),
13 })
14 // the first key or click of a round starts it
15 const ensure = (s: State): State => {
16 if (s.game && !s.game.over) return s
17 const { bw, bh } = size()
18 return { ...s, game: newPong(bw, bh) }
19 }
20
21 if (surface.state === undefined) {
22 surface.setState(base(props))
23 surface.every(100, () => {
24 const s = surface.state
25 if (!s?.game || !s.playing || s.game.over) return
26 const game = tick(s.game)
27 // best is the winning margin
28 if (game.over) surface.post({ game: 'pong', score: game.scoreYou - game.scoreCpu })
29 surface.setState({ ...s, game })
30 })
31 surface.onKey(({ key }) => {
32 const s = surface.state
33 if (!s) return
34 // the space bar may arrive as the character or by name
35 const k = key === 'space' ? ' ' : key.toLowerCase()
36 if (k === 'p' || k === ' ') {
37 if (s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing })
38 return
39 }
40 if (k === 'r') {
41 const { bw, bh } = size()
42 surface.setState({ ...s, banner: false, playing: true, game: newPong(bw, bh) })
43 return
44 }
45 const rows = k === 'up' || k === 'w' ? -2 : k === 'down' || k === 's' ? 2 : 0
46 if (!rows) return
47 const next = ensure(s)
48 surface.setState({ ...next, banner: false, playing: true, game: nudge(next.game!, rows) })
49 })
50 // the paddle follows the pointer over the board (the border takes the first row)
51 surface.onPointer(ev => {
52 const s = surface.state
53 if (!s || (ev.type !== 'move' && ev.type !== 'down')) return
54 if (ev.type === 'move' && (!s.game || s.game.over || !s.playing)) return
55 const next = ensure(s)
56 surface.setState({ ...next, banner: false, playing: true, game: place(next.game!, ev.y - 1) })
57 })
58 }
59 claudeDone(props, surface)
60
61 const s = surface.state
62 const g = s?.game
63 const { bw, bh } = g ? { bw: g.w, bh: g.h } : size()
64 const rows = grid(Text, bw, bh, (x, y) => {
65 if (!g) return x === Math.floor(bw / 2) && y % 2 === 0 ? [' ·', 'gray'] : [' ']
66 if (x === 0 && y >= g.you && y < g.you + g.pad) return ['██', 'cyan']
67 const cpu = Math.round(g.cpu)
68 if (x === bw - 1 && y >= cpu && y < cpu + g.pad) return ['██', 'magenta']
69 if (x === g.ball.x && y === Math.round(g.ball.y)) return ['██', 'whiteBright']
70 return x === Math.floor(bw / 2) && y % 2 === 0 ? [' ·', 'gray'] : [' ']
71 })
72 const text = !g ? `pong · click here, then ↑↓ or move the mouse · first to ${WIN}`
73 : g.over ? `pong · ${g.scoreYou > g.scoreCpu ? 'you win' : 'cpu wins'} ${g.scoreYou}:${g.scoreCpu} · r plays again`
74 : !s?.playing ? `pong · paused · you ${g.scoreYou} : ${g.scoreCpu} cpu · space resumes`
75 : `pong · you ${g.scoreYou} : ${g.scoreCpu} cpu · first to ${WIN} · ↑↓ or the mouse`
76 return (
77 <Box flexDirection="column">
78 <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? (g.scoreYou > g.scoreCpu ? 'green' : 'red') : 'white'} width={bw * 2 + 2}>{rows}</Box>
79 {statusLine(Text, !!s?.banner, `${text} · best margin ${props?.best ?? 0}`)}
80 </Box>
81 )
82}
83hooks/boards/invaders.tsx 84 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { fire, newInvaders, shift, tick, type InvadersGame, type Pt } from '../games/invaders.ts'
5
6const ROW_COLORS = ['magentaBright', 'cyan', 'green', 'yellow']
7type State = Base & { game?: InvadersGame }
8
9export default function Invaders(props: Props, surface: ClientSurface<State>) {
10 const { Box, Text } = surface.elements
11 const size = () => ({
12 bw: Math.max(20, Math.min(30, Math.floor((surface.columns - 2) / 2))),
13 bh: Math.max(10, Math.min(16, surface.rows - 3)),
14 })
15 // the running round, or a new one when there is none or the last one ended
16 const ensure = (s: State): InvadersGame => (s.game && !s.game.over ? s.game : newInvaders(size().bw, size().bh))
17
18 if (surface.state === undefined) {
19 surface.setState(base(props))
20 surface.every(100, () => {
21 const s = surface.state
22 if (!s?.game || !s.playing || s.game.over) return
23 const game = tick(s.game)
24 if (game.over) surface.post({ game: 'invaders', score: game.score })
25 surface.setState({ ...s, game })
26 })
27 surface.onKey(({ key }) => {
28 const s = surface.state
29 if (!s) return
30 // the space bar may arrive as the character or by name
31 const k = key === 'space' ? ' ' : key.toLowerCase()
32 if (k === 'p') {
33 if (s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing })
34 return
35 }
36 if (k === 'r') {
37 surface.setState({ ...s, banner: false, playing: true, game: newInvaders(size().bw, size().bh) })
38 return
39 }
40 const g = ensure(s)
41 const game = k === 'left' || k === 'a' ? shift(g, -1)
42 : k === 'right' || k === 'd' ? shift(g, 1)
43 : k === ' ' || k === 'up' || k === 'w' ? fire(g)
44 : undefined
45 if (game) surface.setState({ ...s, banner: false, playing: true, game })
46 })
47 // the ship follows the pointer over the board and a click fires (the border takes the first column)
48 surface.onPointer(ev => {
49 const s = surface.state
50 if (!s || (ev.type !== 'move' && ev.type !== 'down')) return
51 if (ev.type === 'move' && (!s.game || s.game.over || !s.playing)) return
52 const g = ensure(s)
53 const moved = { ...g, ship: Math.max(0, Math.min(g.w - 1, Math.floor((ev.x - 1) / 2))) }
54 surface.setState({ ...s, banner: false, playing: true, game: ev.type === 'down' ? fire(moved) : moved })
55 })
56 }
57 claudeDone(props, surface)
58
59 const s = surface.state
60 const g = s?.game
61 const { bw, bh } = g ? { bw: g.w, bh: g.h } : size()
62 const top = g ? Math.min(...g.aliens.map(a => a[1])) : 0
63 const flap = (g?.ticks ?? 0) % 10 < 5
64 const has = (list: Pt[] | undefined, x: number, y: number) => !!list?.some(p => p[0] === x && p[1] === y)
65 const rows = grid(Text, bw, bh, (x, y) => {
66 if (!g) return [' ']
67 if (y === g.h - 1 && x === g.ship) return ['▟▙', 'greenBright']
68 if (has(g.aliens, x, y)) return [flap ? '▚▞' : '▞▚', ROW_COLORS[(y - top) % ROW_COLORS.length]]
69 if (has(g.shots, x, y)) return [' |', 'yellowBright']
70 if (has(g.bombs, x, y)) return [' :', 'red']
71 return [' ']
72 })
73 const text = !g ? 'invaders · click here, then ←→ move · space fires · or the mouse'
74 : g.over ? `invaders · game over · score ${g.score} · wave ${g.wave} · r plays again`
75 : !s?.playing ? `invaders · paused · score ${g.score} · p resumes`
76 : `invaders · score ${g.score} · wave ${g.wave} · lives ${'▲'.repeat(g.lives)} · space fires · p pause`
77 return (
78 <Box flexDirection="column">
79 <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : 'magenta'} width={bw * 2 + 2}>{rows}</Box>
80 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
81 </Box>
82 )
83}
84hooks/boards/doom.tsx 326 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Paint, type Props } from './common.tsx'
4import { cast, fire, move, newDoom, select, STEP, SWING, tick, turn, wrap, WEAPON, type DoomGame, type ItemKind, type Kind, type Weapon } from '../games/doom.ts'
5
6// The view is a pixel buffer twice as tall as the band: each cell is drawn as an upper half block,
7// so its foreground is the top pixel and its background the bottom one. Columns are cast one ray
8// each; monsters, pickups and fireballs are billboards tested against the wall depth of their
9// column. Every sprite is an 8 × 8 mask, sampled at whatever size the distance calls for.
10
11type Rgb = readonly [number, number, number]
12type State = Base & { game?: DoomGame }
13type Sprite = { art: string[]; rgb: Rgb; scale: number; float?: boolean }
14
15const FOV = (70 * Math.PI) / 180
16export const WALL: Record<string, Rgb> = {
17 '1': [165, 95, 45], '2': [130, 132, 145], '3': [70, 150, 85],
18 D: [175, 55, 55], X: [60, 220, 110],
19}
20// Colour-blind mode. Red and green carry meaning all over this game — a red door against a green
21// exit switch is the textbook case — so the mode swaps in colours that stay apart on the axes
22// dichromats keep, spreads them by brightness too, and gives the two wall markers a pattern each,
23// because no palette is a substitute for a cue that is not colour at all.
24// the walls go dark, because they are the background: everything that matters stands in front of
25// them, and a dichromat reads a bright thing on a dark wall whatever the two hues do
26const SAFE_WALL: Record<string, Rgb> = {
27 '1': [78, 78, 78], '2': [96, 102, 108], '3': [52, 66, 82],
28 D: [230, 159, 0], X: [86, 180, 233],
29}
30// spread along brightness as well as hue: hue alone is what a dichromat is short of
31const SAFE_TINT: Record<Drawn, Rgb> = {
32 imp: [255, 109, 31], demon: [170, 90, 200], zombie: [130, 205, 245],
33 health: [255, 255, 255], key: [240, 228, 66], shells: [230, 159, 0],
34 bullets: [48, 150, 224], shotgun: [0, 158, 115], chaingun: [154, 160, 166],
35 ball: [255, 225, 90],
36}
37// What a player has to tell apart in a hurry, and what it costs to get wrong. Three kinds of colour
38// blindness leave about one hue axis and brightness between them, which is not room for nine
39// separate colours, so the promise is made where a mix-up costs something:
40// the door from the exit — walk into the wrong one and you are lost, and both are flat wall
41// one monster from another — how fast it closes and how much it takes decides what you do
42// anything from the wall — seeing that something is there at all
43// Pickups are not on the list against each other. You walk over what you step on; there is no
44// choice to get wrong, and their shapes differ anyway. Where colour runs out, pattern takes over.
45export const CRITICAL: [string, string][] = [
46 ['wall:D', 'wall:X'],
47 ['imp', 'demon'], ['imp', 'zombie'], ['demon', 'zombie'],
48 ['imp', 'wall:2'], ['demon', 'wall:2'], ['zombie', 'wall:2'],
49 ['health', 'wall:2'], ['key', 'wall:2'], ['shells', 'wall:2'], ['bullets', 'wall:2'],
50]
51export const safeRgb = (name: string): Rgb =>
52 name.startsWith('wall:') ? SAFE_WALL[name.slice(5)] : SAFE_TINT[name as Drawn]
53
54const CEILING: Rgb = [46, 46, 58]
55const GROUND: Rgb = [78, 62, 48]
56const METAL: Rgb = [96, 98, 108]
57const BARREL: Rgb = [168, 150, 112]
58
59const art = (...rows: string[]) => rows
60type Drawn = Kind | ItemKind | 'ball'
61export const SPRITE: Record<Drawn, Sprite> = {
62 imp: { rgb: [200, 72, 40], scale: 0.8, art: art(
63 '..#..#..', '.#.##.#.', '.######.', '.#.##.#.', '..####..', '.##..##.', '.#.##.#.', '##....##') },
64 demon: { rgb: [225, 120, 140], scale: 0.95, art: art(
65 '#......#', '#.#..#.#', '.######.', '.#.##.#.', '.######.', '.##..##.', '.#....#.', '##....##') },
66 zombie: { rgb: [150, 145, 110], scale: 0.75, art: art(
67 '...##...', '...##...', '..####..', '.#####..', '..###.##', '..####..', '..#..#..', '..#..#..') },
68 health: { rgb: [60, 200, 90], scale: 0.34, art: art(
69 '........', '...##...', '...##...', '.######.', '.######.', '...##...', '...##...', '........') },
70 shells: { rgb: [215, 180, 40], scale: 0.3, art: art(
71 '........', '........', '.######.', '.#.##.#.', '.#.##.#.', '.######.', '........', '........') },
72 bullets: { rgb: [190, 140, 60], scale: 0.26, art: art(
73 '........', '........', '..####..', '..#..#..', '..#..#..', '..####..', '........', '........') },
74 shotgun: { rgb: [175, 140, 95], scale: 0.34, art: art(
75 '........', '........', '..#####.', '.######.', '.##.....', '........', '........', '........') },
76 chaingun: { rgb: [150, 150, 165], scale: 0.36, art: art(
77 '........', '..####..', '.#####..', '.####...', '.###....', '.##.....', '........', '........') },
78 key: { rgb: [230, 50, 50], scale: 0.3, art: art(
79 '..###...', '..#.#...', '..###...', '...#....', '...#....', '...##...', '...#....', '...##...') },
80 ball: { rgb: [255, 190, 60], scale: 0.22, float: true, art: art(
81 '..####..', '.######.', '########', '########', '########', '########', '.######.', '..####..') },
82}
83// the weapon in your hands, bottom centre of the view
84const HANDS: Record<Weapon, string[]> = {
85 pistol: art('...##...', '...##...', '..####..', '..####..', '.######.', '########'),
86 shotgun: art('....##..', '...####.', '..#====#', '.##====#', '######==', '########'),
87 chaingun: art('..#..#..', '.##==##.', '.##==##.', '.######.', '########', '########'),
88}
89
90// the engine counts the nodes in a frame, and every change of colour along a row is one more span,
91// so the shades are quantised: neighbouring columns of the same wall come out equal and merge
92export const SHADES = 24
93const hex = (rgb: Rgb, k: number) =>
94 `#${rgb.map(c => Math.max(0, Math.min(255, Math.round((c * k) / SHADES) * SHADES)).toString(16).padStart(2, '0')).join('')}`
95// further is darker, and never quite black
96const fog = (d: number) => Math.max(0.16, 1 / (1 + d * 0.14))
97// a still frame of a corridor looks exactly like a running one, so a stopped game is dimmed: it is
98// the only way to see at a glance that this view is not playing
99const dimmed = new Map<string, string>()
100const dim = (color: string) => {
101 // paused is when the person is reading Claude's answer, and it is the frame that dims every
102 // pixel. The palette is quantised, so the colours coming through here are a small fixed set.
103 const known = dimmed.get(color)
104 if (known !== undefined) return known
105 const out = `#${[1, 3, 5].map(i => Math.round(parseInt(color.slice(i, i + 2), 16) * 0.42).toString(16).padStart(2, '0')).join('')}`
106 dimmed.set(color, out)
107 return out
108}
109
110export default function Doom(props: Props, surface: ClientSurface<State>) {
111 const { Box, Text } = surface.elements
112 const ensure = (s: State): DoomGame => (s.game && !s.game.over ? s.game : newDoom())
113 // the view, in pixels: one per column, two per row
114 const view = () => ({ w: Math.max(8, surface.columns - 2), ph: Math.max(6, (surface.rows - 3) * 2) })
115
116 const act = (fn: (g: DoomGame) => DoomGame) => {
117 const s = surface.state
118 if (!s) return
119 surface.setState({ ...s, banner: false, playing: true, game: fn(ensure(s)) })
120 }
121
122 if (surface.state === undefined) {
123 // the floor is there from the first frame; nothing moves until you press or click
124 surface.setState({ ...base(props), game: newDoom() })
125 // 20 frames a second: the movement is driven by the frame, not by how fast a key repeats
126 surface.every(50, () => {
127 const s = surface.state
128 if (!s?.game || !s.playing || s.game.over) return
129 const game = tick(s.game)
130 if (game.over) surface.post({ game: 'doom', score: game.kills })
131 surface.setState({ ...s, game })
132 })
133 surface.onKey(({ key }) => {
134 const s = surface.state
135 if (!s) return
136 const k = key === 'space' ? ' ' : key.toLowerCase()
137 if (k === 'p') {
138 if (s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing })
139 return
140 }
141 if (k === 'r') {
142 surface.setState({ ...s, banner: false, playing: true, game: newDoom() })
143 return
144 }
145 const weapon: Weapon | undefined = k === '1' ? 'pistol' : k === '2' ? 'shotgun' : k === '3' ? 'chaingun' : undefined
146 // one press, one step: what the terminal repeats while you hold a key is what walks you
147 const step =
148 weapon ? (g: DoomGame) => select(g, weapon)
149 : k === 'w' || k === 'up' ? (g: DoomGame) => move(g, STEP)
150 : k === 's' || k === 'down' ? (g: DoomGame) => move(g, -STEP)
151 : k === 'a' ? (g: DoomGame) => move(g, 0, -STEP)
152 : k === 'd' ? (g: DoomGame) => move(g, 0, STEP)
153 : k === 'left' || k === 'q' ? (g: DoomGame) => turn(g, -SWING)
154 : k === 'right' || k === 'e' ? (g: DoomGame) => turn(g, SWING)
155 : k === ' ' || k === 'return' ? fire
156 : undefined
157 if (step) act(step)
158 })
159 // the pointer aims: moving over the view turns toward it, a click fires
160 surface.onPointer(ev => {
161 const s = surface.state
162 if (!s) return
163 if (ev.type !== 'move' && ev.type !== 'down') return
164 if (ev.type === 'move' && (!s.playing || s.game?.over)) return
165 const w = view().w
166 const dpp = w / 2 / Math.tan(FOV / 2)
167 const aim = (g: DoomGame) => turn(g, Math.atan2(ev.x - 1 - w / 2, dpp) * 0.5)
168 act(ev.type === 'down' ? g => fire(aim(g)) : aim)
169 })
170 }
171 claudeDone(props, surface)
172
173 const s = surface.state
174 const g = s?.game
175 const { w, ph } = view()
176 const rows = ph / 2
177 const dpp = w / 2 / Math.tan(FOV / 2)
178 // one colour per pixel, filled by the wall pass and overdrawn by the sprites and the weapon
179 const buf: string[] = Array.from({ length: w * ph })
180 const depth = new Float64Array(w)
181
182 const safe = props?.safe ?? false
183 const walls = safe ? SAFE_WALL : WALL
184 const tint = (name: Drawn, fallback: Rgb) => (safe ? SAFE_TINT[name] : fallback)
185
186 if (g) {
187 // one colour per row for the floor and the ceiling: they fade with the row's own distance and
188 // nothing else, so building them per pixel rebuilt the same string tens of thousands of times.
189 // Every column then shares the identical string, which makes the run merge in grid() a pointer
190 // compare rather than a character one.
191 const sky = Array.from({ length: ph }, (_, y) =>
192 hex(y < ph / 2 ? CEILING : GROUND, fog(ph / 2 / Math.max(0.5, Math.abs(y - ph / 2)))))
193
194 // Wide bars, not every other column: a door filling a wide view would otherwise emit one run
195 // per two columns, and the run cap in grid() folds pairs by keeping the left one's colour —
196 // which collapses an alternating pattern back to a single flat colour. A dozen bands across
197 // the view stays well under the cap at any width, so the cue survives on a big terminal.
198 const barW = Math.max(2, Math.round(w / 12))
199
200 for (let x = 0; x < w; x++) {
201 const angle = g.dir + Math.atan2(x - w / 2 + 0.5, dpp)
202 const ray = cast(g.map, g.px, g.py, angle)
203 // the cosine takes the fisheye out: what matters is the distance to the wall's plane
204 const d = Math.max(0.05, ray.dist * Math.cos(angle - g.dir))
205 depth[x] = d
206 const h = dpp / d
207 const top = Math.round(ph / 2 - h / 2)
208 const bottom = Math.round(ph / 2 + h / 2)
209 // the two faces of a corner catch the light differently, which is what makes corners read
210 const lit = fog(d) * (ray.side === 1 ? 0.68 : 1)
211 const base = walls[ray.kind] ?? walls['2']
212 // in colour-blind mode the door is barred the short way and the exit switch the long way, so
213 // the two of them read apart with the colour taken away entirely
214 const bars = safe && ray.kind === 'D' ? (Math.floor(x / barW) & 1 ? 1.35 : 0.6) : 1
215 const wall = hex(base, lit * bars)
216 const banded = safe && ray.kind === 'X'
217 for (let y = 0; y < ph; y++) {
218 buf[y * w + x] = y >= top && y < bottom
219 ? (banded ? hex(base, lit * ((y >> 1) & 1 ? 1.35 : 0.6)) : wall)
220 : sky[y]
221 }
222 }
223
224 // billboards, furthest first so a near one covers what stands behind it
225 const billboard = (name: Drawn, x: number, y: number, hurt = false) =>
226 ({ x, y, art: SPRITE[name].art, scale: SPRITE[name].scale, float: SPRITE[name].float, rgb: tint(name, SPRITE[name].rgb), hurt })
227 const board = [
228 ...g.enemies.map(e => billboard(e.kind, e.x, e.y, e.hurt > 0)),
229 ...g.items.map(i => billboard(i.kind, i.x, i.y)),
230 ...g.balls.map(b => billboard('ball', b.x, b.y)),
231 ]
232 .map(sp => ({ ...sp, d: Math.hypot(sp.x - g.px, sp.y - g.py) }))
233 .sort((a, b) => b.d - a.d)
234 for (const sp of board) {
235 const a = wrap(Math.atan2(sp.y - g.py, sp.x - g.px) - g.dir)
236 if (Math.abs(a) > FOV) continue
237 // the depth buffer holds perpendicular distances, so the sprite has to be measured the same
238 // way or it is culled by a wall it stands in front of, and drawn at a different scale from
239 // the walls beside it. Shading still wants the true distance: fog is about how far away it is.
240 const perp = Math.max(0.1, sp.d * Math.cos(a))
241 const full = dpp / perp
242 const size = Math.max(1, Math.round(full * sp.scale))
243 const left = w / 2 + Math.tan(a) * dpp - size / 2
244 // a monster or a pickup stands on the floor; a fireball flies at eye level
245 const top = sp.float ? ph / 2 - size / 2 : ph / 2 + full / 2 - size
246 const color = hex(sp.rgb, sp.hurt ? 1.6 : Math.max(0.5, fog(sp.d) * 1.4))
247 // walk the part of the screen the sprite covers, not the sprite: something almost in your
248 // face is scaled by 1/distance and would otherwise be a loop the size of the square of it
249 for (let x = Math.max(0, Math.ceil(left)); x < Math.min(w, left + size); x++) {
250 if (depth[x] <= perp) continue
251 const col = Math.floor(((x - left) / size) * 8)
252 for (let y = Math.max(0, Math.ceil(top)); y < Math.min(ph, top + size); y++) {
253 if (sp.art[Math.floor(((y - top) / size) * 8)][col] === '.') continue
254 buf[y * w + x] = color
255 }
256 }
257 }
258
259 // the crosshair, then the weapon across the bottom, then the muzzle flash over both
260 const cx = Math.floor(w / 2)
261 const cy = Math.floor(ph / 2)
262 for (const [dx, dy] of [[0, -2], [0, 2], [-2, 0], [2, 0]] as const) {
263 const x = cx + dx
264 const y = cy + dy
265 if (x >= 0 && x < w && y >= 0 && y < ph) buf[y * w + x] = '#d8d8d8'
266 }
267 const hands = HANDS[g.weapon]
268 const gh = Math.max(4, Math.round(ph * 0.3))
269 const gw = gh * 2
270 for (let iy = 0; iy < gh; iy++) for (let ix = 0; ix < gw; ix++) {
271 const mark = hands[Math.floor((iy / gh) * hands.length)][Math.floor((ix / gw) * hands[0].length)]
272 if (mark === '.') continue
273 const x = Math.round(cx - gw / 2 + ix)
274 const y = ph - gh + iy
275 if (x < 0 || x >= w || y < 0 || y >= ph) continue
276 buf[y * w + x] = hex(mark === '=' ? BARREL : METAL, g.flash > 0 ? 1.5 : 1)
277 }
278 if (g.flash > 0) {
279 const blast = Math.max(2, Math.round(gh * 0.7))
280 for (let iy = -blast; iy <= blast; iy++) for (let ix = -blast; ix <= blast; ix++) {
281 if (Math.hypot(ix, iy) > blast) continue
282 const x = cx + ix
283 const y = ph - gh + iy
284 if (x < 0 || x >= w || y < 0 || y >= ph) continue
285 buf[y * w + x] = Math.hypot(ix, iy) > blast * 0.6 ? '#ffb020' : '#fff0c0'
286 }
287 }
288 }
289
290 const stopped = !s?.playing || !!g?.over
291 const shade = (color: string) => (stopped ? dim(color) : color)
292 const paint = (x: number, y: number): Paint =>
293 ['▀', shade(buf[y * 2 * w + x] ?? '#101014'), shade(buf[(y * 2 + 1) * w + x] ?? '#101014')]
294 // what is left of the engine's node budget for this frame, shared out over the rows
295 const maxRuns = Math.max(6, Math.floor(1500 / Math.max(1, rows)) - 1)
296 const border = g?.over ? 'red' : (g?.pain ?? 0) > 0 ? 'redBright' : g?.flash ? 'yellow' : 'gray'
297 // a pickup, a door or a new floor says so for a couple of seconds
298 const said = g && g.said && g.ticks - g.saidAt < 50 ? ` · ${g.said}` : ''
299 // once a floor is under way the line keeps showing it, paused or not: what you want to read when
300 // you come back is your health, not the controls again
301 const text = g?.over ? `doom · you died on E1M${g.level} · ${g.kills} killed · r starts again`
302 : (g?.ticks ?? 0) === 0 && !s?.playing && !s?.banner ? 'doom · click the view, then WASD moves · ←→ turn · space fires · 1 2 3 weapons'
303 : `doom · E1M${g?.level ?? 1} · health ${g?.health ?? 0} · ${g?.weapon ?? 'pistol'} ${g?.[WEAPON[g?.weapon ?? 'pistol'].ammo] ?? 0} · killed ${g?.kills ?? 0}${g?.key ? ' · red key' : ''}${said}`
304 // a stopped game says so across the middle of the view, where the click has to land anyway: the
305 // keyboard goes to the prompt until a click gives it to the board, and a status line is easy to miss
306 const label = !stopped ? undefined
307 : g?.over ? 'you died · press r to start again'
308 : s?.banner ? '● Claude is done · click here or press a key to carry on'
309 : (g?.ticks ?? 0) === 0 ? 'CLICK HERE TO PLAY · then WASD moves, space fires'
310 : 'paused · click here or press p'
311 const view3d = grid(Text, w, rows, paint, maxRuns)
312 if (label) {
313 const pad = Math.max(0, Math.floor((w - label.length) / 2))
314 const line = (' '.repeat(pad) + label).padEnd(w).slice(0, w)
315 view3d[Math.floor(rows / 2)] = <Text backgroundColor="#101014" color={s?.banner ? '#ffd24a' : '#ffffff'} bold>{line}</Text>
316 }
317 return (
318 <Box flexDirection="column">
319 <Box flexDirection="column" borderStyle="round" borderColor={border} width={w + 2}>
320 {view3d}
321 </Box>
322 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
323 </Box>
324 )
325}
326