SLOPSHOPPER

cc-arcade

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…

newbandguardcommandtoast
★ 40v0.2.0MITupdated 2026-09-15sezaakgun/cc-arcade
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-arcade
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /arcade ⎿ cc-arcade: arcade: click a game above the prompt · /arcade list describes them arcade · click a game · /arcade <game> works too · /arcade stop closes [ snake ] [ tetris ] [ doom ] [ 2048 ] [ minesweeper ] [ flappy ] [ pong ] [ typing ] [ invaders ] [ pet ] [ random ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
arcade · click a game · /arcade <game> works too · /arcade stop closes [ snake ] [ tetris ] [ doom ] [ 2048 ] [ minesweeper ] [ flappy ] [ pong ] [ typing ] [ invaders ] [ ⟨Claude Code's own drawing⟩
README

cc-arcade

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.

Running /arcade, clicking Tetris and playing a few pieces, going back to the list with ← games, then switching to 2048

Tetris above the prompt mid-game: the back and close buttons above the board, the score line below

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 in early access, so they need the environment variable the quick start sets, and the API can change between Claude Code releases.

Requirements

  • Claude Code 2.1.269 or later, the first build whose function hooks draw above the prompt, with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 set. The quick start shows where.
  • An interactive terminal session. Nothing draws in claude -p, the desktop app or mobile.
  • A terminal that reports the mouse. One click is what gives a board the keyboard, and the picker buttons, doom, Minesweeper, Flappy, Pong and Space Invaders use the mouse too.
  • A terminal font with box-drawing and block characters. Modern terminals are fine; the old Windows console draws them as boxes.

Quick start

  1. Turn function hooks on. Add this to ~/.claude/settings.json (create the file if it does not exist, or merge the env key into what is there). Without it the plugin installs fine but does nothing.
   {
     "env": {
       "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
     }
   }

This also loads the hooks module of any other installed plugin that ships one. For a single session instead, prefix the command: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

  1. Install from GitHub. The repo is its own marketplace:
   claude plugin marketplace add sezaakgun/cc-arcade
   claude plugin install cc-arcade@cc-arcade
  1. Start claude and run /arcade. A row of buttons appears above the prompt: one per game, plus pet, random, auto and close.

The /arcade picker above the prompt: a button for every game, plus pet, random, auto and close

  1. Click snake. The board appears in place of the buttons.
  1. Click the board, then press an arrow key. The click gives the game the keyboard; until then your keys go to the prompt.

Snake above the prompt, a few moves in

  1. Press Esc to give the keyboard back to the prompt. Click ← 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.

Use

  • Pick a game: run /arcade and click one, or name it directly: /arcade tetris.
  • Let it choose: /arcade random starts any game except the pet and the one you just played. /arcade auto picks one for the moment; see Auto.
  • See the games and your best scores: /arcade list.
  • Back to the list: click ← games above the board. The picker comes back and the game you left stops.
  • Switch games: pick another from the list, or run /arcade <game>. One game is open at a time.
  • Close: click close above the board or in the picker, or run /arcade stop.
  • Colour-blind mode: /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.

Games

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.
2048, a few merges inMinesweeper after the first click opened an area
Flappy between two pipesPong at the start of a rally
Space Invaders with part of the first wave shot downThe typing test with the first word typed

These are clones of the genre, not affiliated with or endorsed by Tetris Holding, Taito, Atari or id Software.

Doom

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.

Walking down a corridor on E1M1, shooting an imp with the shotgun, then touching the exit switch and arriving on E1M2

W S, or ↑ ↓walk forward and back
A Dstrafe
← →, or q eturn
mouse over the viewaim; a click fires
Space, Enterfire
1 2 3pistol, shotgun, chaingun — the ones you are carrying
p, rpause, 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.

Standing in the doorway of E1M1's exit room: an imp on the left, the green exit switch ahead, the shotgun at the bottom of the view

monsterweapon
imp60 hpchases, claws, throws fireballspistol18 a shotwhat you start with
demon110 hpfaster, tougher, bites onlyshotgun3 × 22kills an imp in one, up close
zombie30 hpshoots from a distance, closes slowlychaingun12 a shota 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.

Playing while Claude works

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.

Auto

/arcade auto, or auto in the picker, looks at the moment and picks from a small group:

  • The last thing Claude did was fail a test run, under five minutes ago: pet, to check on it.
  • Claude has been on this turn for a minute or more: tetris, invaders, doom or snake, something to sink into.
  • Claude is working, under a minute so far: flappy, pong or typing, a quick round.
  • Claude is idle: 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).

The pet

/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.

The pet: a happy level-1 baby with its mood bar and what fed it last

  • A test run that passes gives +10 XP and lifts its mood by 8. Recognised runners: 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.
  • A test run that fails gives +2 XP but costs 12 mood.
  • A git commit that succeeds gives +15 XP and 10 mood. A --dry-run does not count.
  • A file edit through Claude's 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.

Best scores

Each game keeps its best score across sessions, and a new best shows a toast. /arcade list shows them all.

  • snake: most food eaten
  • tetris: highest score, with 100, 300, 500 or 800 for clearing 1 to 4 lines at once and 2 per row dropped with Space
  • 2048: highest score when no slide is left
  • minesweeper: fastest clear, in seconds
  • flappy: most pipes passed
  • pong: biggest margin you won by
  • typing: fastest finished line, in words per minute at five characters a word
  • invaders: highest score, 10 per alien
  • doom: most monsters killed before you died

How it works

  • 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.

Troubleshooting

  • /arcade is an unknown command. Function hooks are off. Check CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is set in ~/.claude/settings.json under env (see Quick start), and that the plugin is loaded with /plugins.
  • Keys go to the prompt instead of the game. Click the board first. Esc hands the keyboard back to the prompt.
  • Nothing happens when you click. Your terminal does not report the mouse. /arcade <game> still opens a game, but a board cannot get the keyboard without a click.
  • The board is small. Claude Code gives the area above the prompt about half the terminal. Make the terminal taller.
  • A board is replaced by one line naming the plugin and the module. The board threw an error or ran over its time budget and was unmounted. Run /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.
  • The pet does not grow. Only Claude's tool calls feed it, and only the commands listed in The pet.
  • Games stopped working after editing the plugin. A hot reload that fails partway leaves the old version loaded. Restart the session.

Limits

  • The animated boards redraw ten times a second, doom twenty, so fast games feel coarse next to a real console. 2048 and Minesweeper are turn-based and the pet redraws five times a second.
  • One game is open at a time, in about half the terminal height.
  • A game only gets the keyboard after a click, and never gets Esc, which always returns to the prompt.
  • A headless claude -p run, the desktop app and mobile never draw the arcade.
  • Best scores and the pet live in the plugin's store on this machine. Nothing is shared between machines.

Develop

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 early-access types: open a Claude Code session in this folder with function hooks on, 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.

License

MIT. See LICENSE.

Source 24 files
hooks/register.tsx 242 lines
1/* @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($.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($.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 = $.clock.now()
121    return next(e)
122  })
123
124  on('turn.complete', async ($, e, next) => {
125    const r = await next(e)
126    turnStartedAt = undefined
127    if (active && active !== PICKER) {
128      turnsDone++
129      $.ui.invalidate('ui.render')
130    }
131    return r
132  })
133
134  // the pet feeds on work: a test run passing or failing, a commit, an edit. It is re-read from the
135  // store first, so sessions running side by side feed the same pet instead of overwriting it.
136  on('tool.call', async ($, e, next) => {
137    const r = await next(e)
138    const command = (e as { command?: unknown }).command
139    const event = petEvent(e.tool, typeof command === 'string' ? command : undefined, 'deny' in r ? undefined : !r.isError)
140    if (!event) return r
141    const saved = await $.store.get('pet').catch(() => undefined)
142    const current = isPet(saved) ? saved : pet ?? newPet($.clock.now())
143    const fed = feed(current, event, $.clock.now())
144    pet = fed
145    await $.store.set('pet', fed).catch(err => $.ui.log(`cc-arcade: store write failed: ${err}`))
146    if (level(fed.xp) > level(current.xp)) {
147      const grew = stage(fed.xp) !== stage(current.xp) ? `, and is now ${stage(fed.xp) === 'adult' ? 'an adult' : `a ${stage(fed.xp)}`}` : ''
148      $.ui.toast(`pet: reached level ${level(fed.xp)}${grew}`)
149    }
150    if (active === 'pet') $.ui.invalidate('ui.render')
151    return r
152  })
153
154  // a finished round, posted by a board: keep the best score and hand it back with the next props
155  on('ui.message', async ($, e, next) => {
156    const data = e.data as { game?: unknown; score?: unknown } | null
157    const name = data?.game
158    const score = data?.score
159    if (!isGame(name) || typeof score !== 'number') return next(e)
160    const game = GAMES[name]
161    if (isBetter(score, best[name], game.lowerIsBetter)) {
162      best[name] = score
163      await $.store.set(`best:${name}`, score).catch(err => $.ui.log(`cc-arcade: store write failed: ${err}`))
164      $.ui.toast(`${name}: new best ${score}${game.unit ?? ''}`)
165    }
166    return { props: { best: best[name] ?? 0, done: turnsDone, safe } }
167  })
168
169  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
170    // boards need a terminal's keys and mouse; the desktop and mobile surfaces draw their own band
171    if (!active || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
172    const { Box, Button, Client, Text } = await $.ui.resolve(e)
173
174    if (active === PICKER) {
175      // no hotkeys: a band hotkey would press on a digit typed as the first character of a prompt
176      const choose = (name: string) => {
177        if (name === 'close') active = undefined
178        else if (name === 'random') active = pickRandom(PLAYABLE, lastGame)
179        else if (name === 'auto') {
180          const pick = pickAuto(autoContext($.clock.now()))
181          active = pick.game
182          $.ui.toast(`auto: ${pick.game}, ${pick.reason}`)
183        } else active = name
184        if (active) lastGame = active
185        $.ui.invalidate('ui.render')
186      }
187      return (
188        <Box flexDirection="column">
189          <Text dimColor>{'arcade · click a game · /arcade <game> works too · /arcade stop closes'}</Text>
190          <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
191            {[...NAMES, 'random', 'auto', 'close'].map(name => (
192              <Button key={`pick:${name}`} label={name} onPress={() => choose(name)} />
193            ))}
194          </Box>
195          {await next(e)}
196        </Box>
197      )
198    }
199
200    const cols = e.viewport?.columns ?? 80
201    // one row for the back and close buttons, one for whatever else draws in the band
202    const rows = Math.max(8, e.props.maxRows - 2)
203    const tall = (max: number) => Math.min(rows, max)
204    const props = { best: best[active] ?? 0, done: turnsDone, safe }
205    // the key names the board, so switching mounts a fresh one and a redraw keeps the running one;
206    // module paths must be string literals, since the engine reads them off this source
207    const key = `board:${active}`
208    const board =
209      active === 'snake' ? <Client key={key} module="./boards/snake.tsx" width={cols} height={rows} props={props} />
210      : active === 'tetris' ? <Client key={key} module="./boards/tetris.tsx" width={cols} height={tall(23)} props={props} />
211      : active === '2048' ? <Client key={key} module="./boards/twenty48.tsx" width={cols} props={props} />
212      : active === 'minesweeper' ? <Client key={key} module="./boards/minesweeper.tsx" width={cols} height={tall(12)} props={props} />
213      : active === 'flappy' ? <Client key={key} module="./boards/flappy.tsx" width={cols} height={tall(19)} props={props} />
214      : active === 'pong' ? <Client key={key} module="./boards/pong.tsx" width={cols} height={tall(18)} props={props} />
215      : active === 'invaders' ? <Client key={key} module="./boards/invaders.tsx" width={cols} height={tall(19)} props={props} />
216      // doom is a view, not a board: it takes every row and column the band gives it
217      : active === 'doom' ? <Client key={key} module="./boards/doom.tsx" width={cols} height={rows} props={props} />
218      : active === 'pet' ? <Client key={key} module="./boards/pet.tsx" width={cols} props={{ pet, now: $.clock.now() }} />
219      : <Client key={key} module="./boards/typing.tsx" width={cols} props={props} />
220    // back to the picker, or close; click-only like the picker (no hotkeys: a band hotkey would press
221    // on a digit typed as the first character of a prompt)
222    const toPicker = () => {
223      active = PICKER
224      $.ui.invalidate('ui.render')
225    }
226    const close = () => {
227      active = undefined
228      $.ui.invalidate('ui.render')
229    }
230    return (
231      <Box flexDirection="column">
232        <Box flexDirection="row" columnGap={1}>
233          <Button key="arcade:back" label="← games" onPress={toPicker} />
234          <Button key="arcade:close" label="close" onPress={close} />
235        </Box>
236        {board}
237        {await next(e)}
238      </Box>
239    )
240  })
241}
242
hooks/games/auto.ts 35 lines
1// 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}
35
hooks/games/best.ts 10 lines
1// 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}
10
hooks/games/pet.ts 65 lines
1// 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')
65
hooks/boards/snake.tsx 62 lines
1/* @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}
62
hooks/boards/tetris.tsx 75 lines
1/* @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}
75
hooks/boards/twenty48.tsx 57 lines
1/* @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}
57
hooks/boards/minesweeper.tsx 99 lines
1/* @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}
99
hooks/boards/flappy.tsx 76 lines
1/* @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}
76
hooks/boards/pong.tsx 83 lines
1/* @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}
83
hooks/boards/invaders.tsx 84 lines
1/* @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}
84
hooks/boards/doom.tsx 326 lines
1/* @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