Arcade games above the Claude Code prompt (a dodge race, Breakout, a dino runner and a side-scrolling shooter) that pause when Claude finishes a turn and react…

Arcade games above the Claude Code prompt, for the minutes Claude spends working.
Run /racer, /breakout, /dino or /shooter and play right above the prompt while Claude works on your request. The games don't just sit next to Claude, they react to what it does: a passing test clears the road ahead, a failing test throws a hazard at you, a commit hands you a shield or a bomb. When Claude finishes the turn, the game pauses and its status line says so, so you never miss a reply. Playing costs no tokens: the plugin answers every key and command itself, without asking the model.
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.
<img src="assets/dino.gif" width="49%" alt="/dino demo"> <img src="assets/shooter.gif" width="49%" alt="/shooter demo">
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 set. The quick start shows where.claude -p, the desktop app or mobile.~/.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.
claude plugin marketplace add mohi-devhub/claude-games
claude plugin install claude-games@claude-games
claude and run /dino (or any other game). The board appears above the prompt./dino stop or the close button closes the game.If a command is unknown, see Troubleshooting.
To remove it:
claude plugin uninstall claude-games
claude plugin marketplace remove claude-games
To try it without installing, or to hack on it, clone and load it for one session:
git clone https://github.com/mohi-devhub/claude-games
cd claude-games
claude --plugin-dir .
The repo's own .claude/settings.json sets the variable for sessions started inside the folder.
Every game uses p to pause and r to restart after the run ends. One game is open at a time; opening one closes the other. Every command runs straight away, even while Claude is working.
/racer — Terminal RacerThree lanes of oncoming traffic. ↑/↓ or w/s switch lanes (space also pauses). Speed climbs the longer you survive; the road never spawns a wall across every lane, so there is always a way through.
/breakout — Breakout←/→ or a/d move the paddle, space launches the ball. Break every brick for the next level: each adds a row and speeds the ball up, and from level 3 the top row takes two hits. Three lives.
/dino — Dino Runnerspace, ↑ or w jumps; ↓ or s ducks (in the air it dives back down). Jump the cacti, duck the birds; the run speeds up the whole time. Obstacles never spawn closer than a full hop apart, so every gap can be cleared.
/shooter — Side-scrolling Shooter↑/↓ or w/s move the ship; space or f fires (hold it to keep firing). Red fighters go down in one hit, magenta armored ships take two and shoot back from level 2, green spinners weave around their lane. A level every 250 points brings faster, denser waves. Three lives, with a moment of invulnerability after each hit.
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 game key to carry on, or Esc to go and read the reply.
The boards take the rows above the prompt that Claude Code gives plugins; a taller terminal gives the pixel games (Breakout, Dino, Shooter) more room.
Only Claude's own tool calls count, never commands you run yourself:
Edit, Write or NotebookEdit.bun test, npm test, pnpm test, yarn test, pytest, go test, cargo test, jest, vitest, mocha, rspec, phpunit, make test, or the test task of mvn, gradle, gradlew or sbt (also through npx). It passes or fails with the tool call.git commit (a --dry-run doesn't count).To see these, 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 nothing: no command text, file path or prompt is stored or logged. The plugin makes no network calls; 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: distance for the racer and the dino, points for Breakout and the shooter.
hooks/register.tsx is the hooks module. It registers the four commands, mounts the open game's board above the prompt, pauses it on turn.complete, turns each classified tool.call into a "pulse" the board applies once, and keeps best scores with $.store.hooks/boards/*.tsx are surface modules, one per game, mounted as Client elements. Each runs on the drawing thread with its own frame clock and keyboard, and posts a finished run's score back to the hooks module. The pixel games draw with half-block characters on a shared canvas in hooks/boards/common.tsx.hooks/games/*.ts hold each game's rules, the tool-call classifier and the best-score comparison as pure functions. The tests cover them.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is set in ~/.claude/settings.json under env (see Quick start), and that the plugin is loaded with /plugins.--debug-file /tmp/claude-games.log and search the log for claude-games.claude -p run, the desktop app and mobile never draw the games.From the repo root:
bun test # game rules, the classifier and best scores
claude plugin validate .claude-plugin/plugin.json # lists the hooked events, $ calls and surface modules
claude plugin validate . # checks the marketplace manifest
bun test is the only check that works on a bare clone, and CI runs it on every push. 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.
Built on cc-arcade by Seza Akgün (MIT): its function-hooks plugin structure, shared board helpers and tool-call classifier are the starting point for this repo.
These are clones of their genres, not affiliated with or endorsed by Atari, Google or anyone else.
MIT. See LICENSE.
hooks/register.tsx 148 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3import { classify } from './games/signals.ts'
4import { isBetter } from './games/best.ts'
5
6// Four games above the prompt — /racer, /breakout, /dino, /shooter — one open at a time. A game's state
7// lives entirely in its board (./boards/*.tsx); this module registers the commands, bumps the pause banner
8// when Claude finishes a turn, turns a classified tool call into a "pulse" the open board applies once,
9// and keeps each game's best score.
10
11type Game = 'racer' | 'breakout' | 'dino' | 'shooter'
12const GAMES: Game[] = ['racer', 'breakout', 'dino', 'shooter']
13const isGame = (v: unknown): v is Game => GAMES.includes(v as Game)
14const HINT: Record<Game, string> = {
15 racer: 'click the board, then ↑ ↓ or w/s switch lanes',
16 breakout: 'click the board, then ← → or a/d move, space launches',
17 dino: 'click the board, then space/↑ jump, ↓ duck',
18 shooter: 'click the board, then ↑ ↓ move, space fires',
19}
20const DESCRIPTION: Record<Game, string> = {
21 racer: 'A scrolling dodge race above the prompt — speed climbs as you go, and Claude\'s real work clears lanes, drops hazards, or hands you a shield (claude-games)',
22 breakout: 'Breakout above the prompt — paddle, ball and bricks, harder every level, and Claude\'s real work breaks bricks, armors them, or hands you lives (claude-games)',
23 dino: 'The dinosaur runner above the prompt — jump cacti, duck birds, faster every second, and Claude\'s real work clears the way, drops cacti, or shields you (claude-games)',
24 shooter: 'A side-scrolling shooter above the prompt — waves fly in from the right, and Claude\'s real work powers your gun, sends extra enemies, or drops a bomb (claude-games)',
25}
26
27let active: Game | undefined
28let turnsDone = 0
29let pulseSeq = 0
30// null, not undefined, until the first real tool call: Client props must be plain data, and the engine
31// refuses a props object holding an undefined value
32let lastPulse: { type: 'edit' | 'test-pass' | 'test-fail' | 'commit'; seq: number } | null = null
33const best: Record<Game, number> = { racer: 0, breakout: 0, dino: 0, shooter: 0 }
34
35// /<game> opens it (closing whichever was open), /<game> stop closes it
36function command(game: Game, args: string) {
37 if (args.trim().toLowerCase() === 'stop') {
38 if (active === game) active = undefined
39 return `${game} closed`
40 }
41 active = game
42 return `${game} · ${HINT[game]} · best ${best[game]} · /${game} stop closes`
43}
44
45export const register: Register = on => {
46 on('session.start', async ($, e, next) => {
47 const r = await next(e)
48 for (const game of GAMES) {
49 const saved = await $.store.get(`best:${game}`).catch(err => { $.ui.log(`claude-games: store read failed: ${err}`); return undefined })
50 const n = Number(saved)
51 if (n) best[game] = n
52 await $.command.register({ name: game, description: DESCRIPTION[game], argumentHint: '[stop]', immediate: true })
53 .catch(err => $.ui.log(`claude-games: /${game} not registered: ${err}`))
54 }
55 return r
56 })
57
58 on('command.run', { command: 'racer' }, async ($, e) => {
59 const text = command('racer', e.args)
60 $.ui.invalidate('ui.render')
61 return { text }
62 })
63
64 on('command.run', { command: 'breakout' }, async ($, e) => {
65 const text = command('breakout', e.args)
66 $.ui.invalidate('ui.render')
67 return { text }
68 })
69
70 on('command.run', { command: 'dino' }, async ($, e) => {
71 const text = command('dino', e.args)
72 $.ui.invalidate('ui.render')
73 return { text }
74 })
75
76 on('command.run', { command: 'shooter' }, async ($, e) => {
77 const text = command('shooter', e.args)
78 $.ui.invalidate('ui.render')
79 return { text }
80 })
81
82 on('turn.complete', async ($, e, next) => {
83 const r = await next(e)
84 if (active) {
85 turnsDone++
86 $.ui.invalidate('ui.render')
87 }
88 return r
89 })
90
91 // every finished tool call is a possible pulse: classified, then handed to the open board as a bumped
92 // sequence number so it applies it exactly once
93 on('tool.call', async ($, e, next) => {
94 const r = await next(e)
95 const cmd = (e as { command?: unknown }).command
96 const event = classify(e.tool, typeof cmd === 'string' ? cmd : undefined, 'deny' in r ? undefined : !r.isError)
97 if (!event) return r
98 pulseSeq++
99 lastPulse = { type: event, seq: pulseSeq }
100 if (active) $.ui.invalidate('ui.render')
101 return r
102 })
103
104 // a finished run or game, posted by its board
105 on('ui.message', async ($, e, next) => {
106 const data = e.data as { game?: unknown; score?: unknown } | null
107 const game = data?.game
108 const score = data?.score
109 if (!isGame(game) || typeof score !== 'number') return next(e)
110 if (isBetter(score, best[game])) {
111 best[game] = score
112 await $.store.set(`best:${game}`, score).catch(err => $.ui.log(`claude-games: store write failed: ${err}`))
113 $.ui.toast(`${game}: new best ${score}`)
114 }
115 return { props: { pulse: lastPulse, best: best[game], done: turnsDone } }
116 })
117
118 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
119 // the boards need a terminal; the desktop and mobile surfaces draw their own band
120 if (!active || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
121 const { Box, Button, Client } = await $.ui.resolve(e)
122 const cols = e.viewport?.columns ?? 80
123 const tall = Math.max(8, e.props.maxRows - 2)
124 const props = { pulse: lastPulse, best: best[active], done: turnsDone }
125 const close = () => {
126 active = undefined
127 $.ui.invalidate('ui.render')
128 }
129 // module paths must be string literals, since the engine reads them off this source. The racer
130 // draws exactly 8 rows (5 of road, its border, its status line) so it asks for exactly that; the
131 // pixel games size themselves to whatever height they're given.
132 const board =
133 active === 'racer' ? <Client key="board:racer" module="./boards/racer.tsx" width={cols} height={8} props={props} />
134 : active === 'breakout' ? <Client key="board:breakout" module="./boards/breakout.tsx" width={cols} height={tall} props={props} />
135 : active === 'dino' ? <Client key="board:dino" module="./boards/dino.tsx" width={cols} height={tall} props={props} />
136 : <Client key="board:shooter" module="./boards/shooter.tsx" width={cols} height={tall} props={props} />
137 return (
138 <Box flexDirection="column">
139 <Box flexDirection="row" columnGap={1}>
140 <Button key="arcade:close" label="close" onPress={close} />
141 </Box>
142 {board}
143 {await next(e)}
144 </Box>
145 )
146 })
147}
148hooks/games/signals.ts 21 lines1// Classifies a finished tool call as one of Claude's real, observable build events. Pure; the hooks
2// module supplies the finished call. Lifted from cc-arcade's pet-feeding classifier — the boss fight
3// is driven by the same four real signals, nothing fabricated.
4
5export type BuildEvent = 'test-pass' | 'test-fail' | 'commit' | 'edit'
6
7// test runners across common stacks; a failing run is a tool call that ended in error
8const 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/
9
10// what a finished tool call means for the fight; `ok` is undefined when the call was denied
11export function classify(tool: string, command: string | undefined, ok: boolean | undefined): BuildEvent | undefined {
12 if (ok === undefined) return undefined
13 if (tool === 'Bash' && command) {
14 if (TEST_COMMAND.test(command)) return ok ? 'test-pass' : 'test-fail'
15 if (ok && /\bgit\s+commit\b/.test(command) && !/--dry-run\b/.test(command)) return 'commit'
16 return undefined
17 }
18 if (ok && (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit')) return 'edit'
19 return undefined
20}
21hooks/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/boards/racer.tsx 125 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Paint } from './common.tsx'
4import { newRacer, pulse, steer, tick, type Pulse, type Racer } from '../games/racer.ts'
5
6// Terminal Racer's board. Your car sits fixed near the left edge, facing right; oncoming cars enter at
7// the right edge and scroll toward you, in one of three lanes stacked top to bottom. A run's state lives
8// entirely here (like the original cc-arcade pong/snake boards did) — only the best distance is posted
9// up and persisted. register.tsx pushes Claude's real events down as a "pulse" (a bumped sequence
10// number, same idiom claudeDone uses for props.done); this board applies each one once.
11
12type Props = { pulse: { type: Pulse; seq: number } | null; best?: number; done?: number } | undefined
13type State = Base & { g: Racer; seenPulseSeq: number }
14
15// Cars are pixel art drawn with half blocks: each character is one pixel wide and two pixels tall
16// (▀ paints the top pixel in the text color and the bottom one in the background), so a car one row
17// tall is two pixels tall and CAR_W track cells (two characters each) wide. b = body, w = window,
18// k = wheel, space = road. Yours faces right; oncoming ones face left and each rolls its own shape and
19// color from its `look` at spawn.
20const CAR = [
21 ' bw ',
22 'kbbk',
23]
24const ONCOMING = [
25 [' wb ', 'kbbk'], // sedan
26 ['wbbb', 'kbbk'], // van
27 ['wb ', 'kbbk'], // pickup
28 ['bwwb', 'kbbk'], // bus
29 [' w ', 'kbbk'], // low sports car
30]
31const ONCOMING_COLORS = ['red', 'magenta', 'green', 'blue']
32const lookOf = (look = 0) => ({
33 sprite: ONCOMING[Math.floor(look * ONCOMING.length)],
34 color: ONCOMING_COLORS[Math.floor(look * ONCOMING.length * ONCOMING_COLORS.length) % ONCOMING_COLORS.length],
35})
36const LANE_ROWS = CAR.length / 2
37// every lane is LANE_ROWS tall with a dashed divider row between lanes: 3*1 + 2 = 5 rows. With the border
38// and the status line that's 8, the least the band above the prompt ever gives a board, so no row can
39// get squeezed out.
40const rowsFor = (lanes: number) => lanes * LANE_ROWS + lanes - 1
41const laneTop = (lane: number) => lane * (LANE_ROWS + 1)
42const trackLenFor = (columns: number) => Math.max(20, Math.min(60, Math.floor((columns - 4) / 2)))
43
44function paint(buf: Paint[][], sprite: string[], left: number, top: number, body: string) {
45 const color = (p: string | undefined) => (p === 'b' ? body : p === 'w' ? 'white' : p === 'k' ? 'gray' : undefined)
46 for (let r = 0; r < LANE_ROWS; r++) {
47 for (let c = 0; c < sprite[0].length; c++) {
48 const x = left + c
49 const y = top + r
50 if (y < 0 || y >= buf.length || x < 0 || x >= buf[0].length) continue
51 const up = color(sprite[r * 2][c])
52 const down = color(sprite[r * 2 + 1][c])
53 if (!up && !down) continue
54 buf[y][x] = !up ? ['▄', down] : !down ? ['▀', up] : up === down ? ['█', up] : ['▀', up, down]
55 }
56 }
57}
58
59export default function RacerBoard(props: Props, surface: ClientSurface<State>) {
60 const { Box, Text } = surface.elements
61
62 if (surface.state === undefined) {
63 surface.setState({ ...base(props), playing: true, g: newRacer(trackLenFor(surface.columns)), seenPulseSeq: props?.pulse?.seq ?? 0 })
64 surface.every(100, () => {
65 const s = surface.state
66 if (!s || !s.playing || s.banner || s.g.over) return
67 const g = tick(s.g)
68 if (g.over) surface.post({ game: 'racer', score: g.distance })
69 surface.setState({ ...s, g })
70 })
71 // click the board to give it the keyboard: up/down switch lanes, space/p pauses, r restarts
72 surface.onKey(({ key }) => {
73 const s = surface.state
74 if (!s) return
75 const k = key === 'space' ? ' ' : key.toLowerCase()
76 if (k === 'p' || k === ' ') {
77 if (!s.g.over) surface.setState({ ...s, banner: false, playing: !s.playing })
78 return
79 }
80 if (k === 'r') {
81 surface.setState({ ...s, banner: false, playing: true, g: newRacer(trackLenFor(surface.columns)) })
82 return
83 }
84 const dir = k === 'up' || k === 'w' ? -1 : k === 'down' || k === 's' ? 1 : 0
85 if (!dir) return
86 surface.setState({ ...s, banner: false, playing: true, g: steer(s.g, dir as -1 | 1) })
87 })
88 }
89 claudeDone(props, surface)
90
91 const s = surface.state
92 if (!s) return <Text dimColor>racer · waiting for the plugin to load it</Text>
93
94 // a real event landed since the last render: apply it once, same idiom claudeDone uses for props.done
95 const seq = props?.pulse?.seq
96 if (seq !== undefined && seq !== s.seenPulseSeq) {
97 surface.setState({ ...s, seenPulseSeq: seq, g: pulse(s.g, props!.pulse!.type) })
98 }
99
100 const g = s.g
101 const width = g.trackLen * 2
102 const height = rowsFor(g.lanes)
103 // the road: lane dividers as dashes that scroll left with the traffic, two characters per tick
104 const buf: Paint[][] = Array.from({ length: height }, (_, y) =>
105 Array.from({ length: width }, (_, x): Paint =>
106 (y + 1) % (LANE_ROWS + 1) === 0 && (x + g.ticks * 2) % 8 < 4 ? ['─', 'gray'] : [' ']))
107 for (const o of g.obstacles) {
108 const { sprite, color } = lookOf(o.look)
109 paint(buf, sprite, o.x * 2, laneTop(o.lane), color)
110 }
111 paint(buf, CAR, g.carX * 2, laneTop(g.car), g.shield > 0 ? 'yellow' : 'cyan')
112 const rows = grid(Text, width, height, (x, y) => buf[y][x])
113
114 const text = g.over ? `racer · crashed at ${g.distance} · r restarts`
115 : !s.playing ? `racer · paused · ${g.distance} · space resumes`
116 : `racer · ↑ ↓ or w/s switch lanes · speed ${g.speed.toFixed(1)}${g.shield > 0 ? ' · shielded' : ''} · ${g.distance}`
117
118 return (
119 <Box flexDirection="column">
120 <Box flexDirection="column" borderStyle="round" borderColor={g.over ? 'red' : 'gray'} width={width + 2}>{rows}</Box>
121 {statusLine(Text, !!s.banner, `${text} · best ${props?.best ?? 0}`)}
122 </Box>
123 )
124}
125hooks/boards/breakout.tsx 106 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, halfBlock, statusLine, type Base } from './common.tsx'
4import { BRICK_W, launch, movePaddle, newBreakout, pulse, tick, type Breakout, type Brick } from '../games/breakout.ts'
5import type { BuildEvent } from '../games/signals.ts'
6
7// Breakout's board. A game's state lives entirely here, like the racer's; only the final score is
8// posted up. It sizes itself from the height the band actually gives it (border and status line take
9// three rows), so no row is ever squeezed out, and draws in half-block pixels — each character is one
10// pixel wide and two tall (▀ in the text color over the background color) — which doubles the vertical
11// resolution the ball has to move in.
12
13type Props = { pulse: { type: BuildEvent; seq: number } | null; best?: number; done?: number } | undefined
14type State = Base & { g: Breakout; seenPulseSeq: number }
15
16const ROW_COLORS = ['red', 'yellow', 'green', 'cyan', 'blue', 'magenta']
17const sizeFor = (columns: number, rows: number) => ({
18 w: Math.max(30, Math.min(64, columns - 4)),
19 h: Math.max(8, (rows - 3) * 2),
20})
21// a row's color, alternating with its bright variant brick by brick so neighbors don't blur together;
22// an armored brick is white until it's down to its last hit
23const brickColor = (b: Brick) => {
24 if (b.hp > 1) return b.hp > 2 ? 'whiteBright' : 'white'
25 const color = ROW_COLORS[(b.y - 1) % ROW_COLORS.length]
26 return (Math.floor(b.x / BRICK_W) + b.y) % 2 === 0 ? color : `${color}Bright`
27}
28
29const fresh = (surface: ClientSurface<State>) => {
30 const { w, h } = sizeFor(surface.columns, surface.rows)
31 return newBreakout(w, h)
32}
33
34export default function BreakoutBoard(props: Props, surface: ClientSurface<State>) {
35 const { Box, Text } = surface.elements
36
37 if (surface.state === undefined) {
38 surface.setState({ ...base(props), playing: true, g: fresh(surface), seenPulseSeq: props?.pulse?.seq ?? 0 })
39 surface.every(100, () => {
40 const s = surface.state
41 if (!s || !s.playing || s.banner || s.g.over) return
42 const g = tick(s.g)
43 if (g.over) surface.post({ game: 'breakout', score: g.score })
44 surface.setState({ ...s, g })
45 })
46 // click the board to give it the keyboard: left/right move, space launches, p pauses, r restarts
47 surface.onKey(({ key }) => {
48 const s = surface.state
49 if (!s) return
50 const k = key === 'space' ? ' ' : key.toLowerCase()
51 if (k === 'p') {
52 if (!s.g.over) surface.setState({ ...s, banner: false, playing: !s.playing })
53 return
54 }
55 if (k === 'r') {
56 surface.setState({ ...s, banner: false, playing: true, g: fresh(surface) })
57 return
58 }
59 if (k === ' ') {
60 surface.setState({ ...s, banner: false, playing: true, g: launch(s.g) })
61 return
62 }
63 const dir = k === 'left' || k === 'a' ? -1 : k === 'right' || k === 'd' ? 1 : 0
64 if (!dir) return
65 surface.setState({ ...s, banner: false, playing: true, g: movePaddle(s.g, dir as -1 | 1) })
66 })
67 }
68 claudeDone(props, surface)
69
70 const s = surface.state
71 if (!s) return <Text dimColor>breakout · waiting for the plugin to load it</Text>
72
73 // a real event landed since the last render: apply it once, same idiom claudeDone uses for props.done
74 const seq = props?.pulse?.seq
75 if (seq !== undefined && seq !== s.seenPulseSeq) {
76 surface.setState({ ...s, seenPulseSeq: seq, g: pulse(s.g, props!.pulse!.type) })
77 }
78
79 const g = s.g
80 const bricks = new Map<number, string>()
81 for (const b of g.bricks) {
82 const color = brickColor(b)
83 for (let i = 0; i < BRICK_W; i++) bricks.set(b.y * g.w + b.x + i, color)
84 }
85 const ballX = Math.floor(g.ball.x)
86 const ballY = Math.floor(g.ball.y)
87 const pixel = (x: number, y: number): string | undefined =>
88 x === ballX && y === ballY ? 'whiteBright'
89 : y === g.h - 1 && x >= g.paddle && x < g.paddle + g.paddleW ? 'cyan'
90 : bricks.get(y * g.w + x)
91 const rows = grid(Text, g.w, g.h / 2, (x, r) => halfBlock(pixel(x, r * 2), pixel(x, r * 2 + 1)))
92
93 const hud = `level ${g.level} · ${'♥'.repeat(g.lives)} · ${g.score}`
94 const text = g.over ? `breakout · game over at ${g.score} · r restarts`
95 : !s.playing ? `breakout · paused · ${hud} · p resumes`
96 : !g.launched ? `breakout · ${hud} · ← → or a/d move · space launches`
97 : `breakout · ${hud}`
98
99 return (
100 <Box flexDirection="column">
101 <Box flexDirection="column" borderStyle="round" borderColor={g.over ? 'red' : 'gray'} width={g.w + 2}>{rows}</Box>
102 {statusLine(Text, !!s.banner, `${text} · best ${props?.best ?? 0}`)}
103 </Box>
104 )
105}
106hooks/boards/dino.tsx 96 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, canvas, claudeDone, statusLine, type Base } from './common.tsx'
4import { box, DINO_X, DUCKED, duck, jump, newDino, pulse, tick, type Dino } from '../games/dino.ts'
5import type { BuildEvent } from '../games/signals.ts'
6
7// Dino Runner's board. A run's state lives entirely here; only the final score is posted up. It sizes
8// itself from the height it's given and draws in half-block pixels on the shared canvas.
9
10type Props = { pulse: { type: BuildEvent; seq: number } | null; best?: number; done?: number } | undefined
11type State = Base & { g: Dino; seenPulseSeq: number }
12
13const sizeFor = (columns: number, rows: number) => ({
14 w: Math.max(30, Math.min(72, columns - 4)),
15 h: Math.max(8, (rows - 3) * 2),
16})
17
18// sprites, top row first, facing right: d = dino, c = cactus, b = bird
19const RUN = [
20 [' dd', 'd ddd', ' ddd ', ' d d '],
21 [' dd', 'd ddd', ' ddd ', ' dd '],
22]
23const DUCKING = [' ddddd', 'd d d ']
24const BIRD = [['b b', ' b '], [' b ', 'b b']]
25const cactus = (w: number, h: number) => Array.from({ length: h }, (_, r) => (w === 2 ? 'cc' : r === 1 ? 'ccc' : ' c '))
26
27const fresh = (surface: ClientSurface<State>) => {
28 const { w, h } = sizeFor(surface.columns, surface.rows)
29 return newDino(w, h)
30}
31
32export default function DinoBoard(props: Props, surface: ClientSurface<State>) {
33 const { Box, Text } = surface.elements
34
35 if (surface.state === undefined) {
36 surface.setState({ ...base(props), playing: true, g: fresh(surface), seenPulseSeq: props?.pulse?.seq ?? 0 })
37 surface.every(100, () => {
38 const s = surface.state
39 if (!s || !s.playing || s.banner || s.g.over) return
40 const g = tick(s.g)
41 if (g.over) surface.post({ game: 'dino', score: Math.floor(g.distance) })
42 surface.setState({ ...s, g })
43 })
44 // click the board to give it the keyboard: space/up jumps, down ducks, p pauses, r restarts
45 surface.onKey(({ key }) => {
46 const s = surface.state
47 if (!s) return
48 const k = key === 'space' ? ' ' : key.toLowerCase()
49 if (k === 'p') {
50 if (!s.g.over) surface.setState({ ...s, banner: false, playing: !s.playing })
51 return
52 }
53 if (k === 'r') {
54 surface.setState({ ...s, banner: false, playing: true, g: fresh(surface) })
55 return
56 }
57 const act = k === ' ' || k === 'up' || k === 'w' ? jump : k === 'down' || k === 's' ? duck : undefined
58 if (act) surface.setState({ ...s, banner: false, playing: true, g: act(s.g) })
59 })
60 }
61 claudeDone(props, surface)
62
63 const s = surface.state
64 if (!s) return <Text dimColor>dino · waiting for the plugin to load it</Text>
65
66 // a real event landed since the last render: apply it once, same idiom claudeDone uses for props.done
67 const seq = props?.pulse?.seq
68 if (seq !== undefined && seq !== s.seenPulseSeq) {
69 surface.setState({ ...s, seenPulseSeq: seq, g: pulse(s.g, props!.pulse!.type) })
70 }
71
72 const g = s.g
73 const ground = g.h - 1
74 const c = canvas(g.w, g.h)
75 // the ground, with pebbles that scroll left as you run
76 for (let x = 0; x < g.w; x++) c.set(x, ground, (x + Math.floor(g.distance)) % 7 === 0 ? 'white' : 'gray')
77 for (const o of g.obstacles) {
78 const rows = o.kind === 'bird' ? BIRD[g.ticks % 4 < 2 ? 0 : 1] : cactus(o.w, o.h)
79 c.sprite(rows, o.x, ground - o.alt - o.h, { c: 'green', b: 'magenta' })
80 }
81 const dino = box(g) === DUCKED ? DUCKING : RUN[g.alt > 0 ? 0 : Math.floor(g.ticks / 2) % 2]
82 c.sprite(dino, DINO_X, ground - g.alt - dino.length, { d: g.over ? 'red' : g.shield > 0 ? 'yellow' : 'white' })
83
84 const score = Math.floor(g.distance)
85 const text = g.over ? `dino · crashed at ${score} · r restarts`
86 : !s.playing ? `dino · paused · ${score} · p resumes`
87 : `dino · space/↑ jump · ↓ duck · speed ${g.speed.toFixed(1)}${g.shield > 0 ? ' · shielded' : ''} · ${score}`
88
89 return (
90 <Box flexDirection="column">
91 <Box flexDirection="column" borderStyle="round" borderColor={g.over ? 'red' : 'gray'} width={g.w + 2}>{c.draw(Text)}</Box>
92 {statusLine(Text, !!s.banner, `${text} · best ${props?.best ?? 0}`)}
93 </Box>
94 )
95}
96hooks/boards/shooter.tsx 110 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, canvas, claudeDone, statusLine, type Base } from './common.tsx'
4import { fire, move, newShooter, pulse, SHIP_X, tick, type Shooter } from '../games/shooter.ts'
5import type { BuildEvent } from '../games/signals.ts'
6
7// The shooter's board. A game's state lives entirely here; only the final score is posted up. It sizes
8// itself from the height it's given and draws in half-block pixels on the shared canvas.
9
10type Props = { pulse: { type: BuildEvent; seq: number } | null; best?: number; done?: number } | undefined
11type State = Base & { g: Shooter; seenPulseSeq: number }
12
13const sizeFor = (columns: number, rows: number) => ({
14 w: Math.max(30, Math.min(72, columns - 4)),
15 h: Math.max(8, (rows - 3) * 2),
16})
17
18// sprites, top row first: s = hull, w = cockpit; each enemy kind has its own shape and color
19const SHIP_ROWS = ['ss ', ' sssw', 'ss ']
20const ENEMY_ROWS = [
21 ['eee', 'e e'],
22 [' ee ', 'eeee', 'e e'],
23 ['e e', ' e ', 'e e'],
24]
25const SPINNING = [' e ', 'eee', ' e ']
26// a sparse starfield drifting left at half the enemies' pace
27const star = (x: number, y: number) => ((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) >>> 0) % 53 === 0
28
29const fresh = (surface: ClientSurface<State>) => {
30 const { w, h } = sizeFor(surface.columns, surface.rows)
31 return newShooter(w, h)
32}
33
34export default function ShooterBoard(props: Props, surface: ClientSurface<State>) {
35 const { Box, Text } = surface.elements
36
37 if (surface.state === undefined) {
38 surface.setState({ ...base(props), playing: true, g: fresh(surface), seenPulseSeq: props?.pulse?.seq ?? 0 })
39 surface.every(100, () => {
40 const s = surface.state
41 if (!s || !s.playing || s.banner || s.g.over) return
42 const g = tick(s.g)
43 if (g.over) surface.post({ game: 'shooter', score: g.score })
44 surface.setState({ ...s, g })
45 })
46 // click the board to give it the keyboard: up/down move, space/f fires, p pauses, r restarts
47 surface.onKey(({ key }) => {
48 const s = surface.state
49 if (!s) return
50 const k = key === 'space' ? ' ' : key.toLowerCase()
51 if (k === 'p') {
52 if (!s.g.over) surface.setState({ ...s, banner: false, playing: !s.playing })
53 return
54 }
55 if (k === 'r') {
56 surface.setState({ ...s, banner: false, playing: true, g: fresh(surface) })
57 return
58 }
59 const next = k === ' ' || k === 'f' ? fire(s.g)
60 : k === 'up' || k === 'w' ? move(s.g, -1)
61 : k === 'down' || k === 's' ? move(s.g, 1)
62 : undefined
63 if (next) surface.setState({ ...s, banner: false, playing: true, g: next })
64 })
65 }
66 claudeDone(props, surface)
67
68 const s = surface.state
69 if (!s) return <Text dimColor>shooter · waiting for the plugin to load it</Text>
70
71 // a real event landed since the last render: apply it once, same idiom claudeDone uses for props.done
72 const seq = props?.pulse?.seq
73 if (seq !== undefined && seq !== s.seenPulseSeq) {
74 surface.setState({ ...s, seenPulseSeq: seq, g: pulse(s.g, props!.pulse!.type) })
75 }
76
77 const g = s.g
78 const c = canvas(g.w, g.h)
79 const drift = Math.floor(g.ticks / 2)
80 for (let y = 0; y < g.h; y++) for (let x = 0; x < g.w; x++) if (star(x + drift, y)) c.set(x, y, 'gray')
81 for (const e of g.enemies) {
82 const rows = e.kind === 2 && g.ticks % 4 < 2 ? SPINNING : ENEMY_ROWS[e.kind]
83 const color = e.kind === 0 ? 'red' : e.kind === 1 ? (e.hp > 1 ? 'magenta' : 'magentaBright') : 'green'
84 c.sprite(rows, e.x, e.y, { e: color })
85 }
86 for (const shot of g.shots) {
87 if (shot.dx > 0) {
88 c.set(shot.x - 1, shot.y, 'yellow')
89 c.set(shot.x, shot.y, 'yellowBright')
90 } else c.set(shot.x, shot.y, 'redBright')
91 }
92 // the ship blinks while invulnerable; its engine flame flickers
93 if (!(g.invuln > 0 && g.ticks % 2 === 1)) {
94 c.sprite(SHIP_ROWS, SHIP_X, g.shipY, { s: g.over ? 'red' : 'cyan', w: 'white' })
95 if (!g.over) c.set(SHIP_X - 1, g.shipY + 1, g.ticks % 2 === 0 ? 'yellow' : 'red')
96 }
97
98 const hud = `level ${g.level} · ${'♥'.repeat(Math.max(0, g.lives))} · ${g.score}${g.power > 0 ? ' · triple shot' : ''}`
99 const text = g.over ? `shooter · game over at ${g.score} · r restarts`
100 : !s.playing ? `shooter · paused · ${hud} · p resumes`
101 : `shooter · ↑ ↓ move · space fires · ${hud}`
102
103 return (
104 <Box flexDirection="column">
105 <Box flexDirection="column" borderStyle="round" borderColor={g.over ? 'red' : 'gray'} width={g.w + 2}>{c.draw(Text)}</Box>
106 {statusLine(Text, !!s.banner, `${text} · best ${props?.best ?? 0}`)}
107 </Box>
108 )
109}
110hooks/boards/common.tsx 64 lines1/* @jsx h */
2import type { ClientElements, ClientSurface } from 'claude-code'
3
4// Shared by every board. A board is a surface module: it runs on the drawing thread with its own
5// frame clock, keys (after a click gives it focus; Esc gives the prompt back) and mouse.
6//
7// Never name a local `h` in a board file: every JSX tag compiles to a call of `h`.
8
9export type Props = { best?: number; done?: number } | undefined
10export type Base = { playing: boolean; banner: boolean; seenDone: number }
11export type Paint = readonly [glyph: string, color?: string, bg?: string]
12type TextTag = ClientElements['Text']
13
14export const base = (props: Props): Base => ({ playing: false, banner: false, seenDone: props?.done ?? 0 })
15
16// the hooks module bumps props.done when Claude finishes a turn: pause and say so, once per turn
17export function claudeDone<S extends Base>(props: Props, surface: ClientSurface<S>) {
18 const s = surface.state
19 const done = props?.done ?? 0
20 if (s && done !== s.seenDone) surface.setState({ ...s, seenDone: done, banner: true, playing: false })
21}
22
23export function statusLine(Text: TextTag, banner: boolean, text: string) {
24 return banner
25 ? <Text color="yellow" bold wrap="truncate-end">{`● Claude is done · ${text}`}</Text>
26 : <Text dimColor wrap="truncate-end">{text}</Text>
27}
28
29// a board as rows of two-character cells (a terminal cell is about twice as tall as wide, so two
30// make a square), one styled span per run of the same colors
31export function grid(Text: TextTag, cols: number, rowCount: number, at: (x: number, y: number) => Paint) {
32 return Array.from({ length: Math.max(0, rowCount) }, (_, y) => {
33 const runs: [string, string | undefined, string | undefined][] = []
34 for (let x = 0; x < cols; x++) {
35 const [glyph, color, bg] = at(x, y)
36 const last = runs[runs.length - 1]
37 if (last && last[1] === color && last[2] === bg) last[0] += glyph
38 else runs.push([glyph, color, bg])
39 }
40 return <Text>{runs.map(([t, c, b]) => (b ? <Text color={c} backgroundColor={b}>{t}</Text> : <Text color={c}>{t}</Text>))}</Text>
41 })
42}
43
44// half-block pixels: a character is one pixel wide and two tall — ▀ paints the top pixel in the text
45// color and the bottom one in the background color
46export const halfBlock = (up?: string, down?: string): Paint =>
47 !up && !down ? [' '] : !up ? ['▄', down] : !down ? ['▀', up] : up === down ? ['█', up] : ['▀', up, down]
48
49// a w×h pixel canvas: set pixels or stamp sprites (later draws win), then draw it as half-block rows
50export function canvas(w: number, h: number) {
51 const px: (string | undefined)[] = []
52 const set = (x: number, y: number, color: string | undefined) => {
53 const cx = Math.floor(x)
54 const cy = Math.floor(y)
55 if (color && cx >= 0 && cx < w && cy >= 0 && cy < h) px[cy * w + cx] = color
56 }
57 // a sprite is rows of one-letter color codes; a code missing from `colors` (a space) is see-through
58 const sprite = (rows: readonly string[], x: number, y: number, colors: Record<string, string>) =>
59 rows.forEach((row, r) => [...row].forEach((code, c) => set(x + c, y + r, colors[code])))
60 const draw = (Text: TextTag) =>
61 grid(Text, w, Math.ceil(h / 2), (x, r) => halfBlock(px[r * 2 * w + x], px[(r * 2 + 1) * w + x]))
62 return { set, sprite, draw }
63}
64hooks/games/racer.ts 89 lines1// Terminal Racer: pure game logic. Your car sits fixed near the left edge of a horizontal track,
2// facing right; oncoming cars enter from the right edge and scroll left toward you across one of three
3// lanes. Speed climbs continuously and drives how often a new car spawns, so the road gets busier over a
4// run. ../boards/racer.tsx runs it on its own frame clock and draws it.
5
6// look: a random number in [0, 1) rolled at spawn, so the board can give each oncoming car its own
7// shape and color and keep it for the whole trip; the rules never read it
8export type Obstacle = { lane: number; x: number; look?: number }
9export type Racer = {
10 lanes: number; trackLen: number; carX: number; car: number
11 obstacles: Obstacle[]; speed: number; ticks: number; distance: number; shield: number
12 over: boolean
13}
14
15// every car, yours and theirs, is CAR_W track cells wide; collision is a box overlap on that width so
16// what you see touching is exactly what crashes
17export const CAR_W = 2
18const LANES = 3
19const CAR_X = 2
20const MAX_SPEED = 4
21// room between two cars in the same lane, so their sprites never overlap
22const MIN_GAP = CAR_W + 1
23// two cars in different lanes block your column at the same time when their x is within 2*CAR_W-1;
24// never letting all lanes have a car inside this window guarantees a free lane at every tick (with a
25// little slack on top, so it's humanly dodgeable, not just theoretically)
26const WALL = 2 * CAR_W + 4
27
28export function newRacer(trackLen = 30): Racer {
29 return { lanes: LANES, trackLen, carX: CAR_X, car: Math.floor(LANES / 2), obstacles: [], speed: 1, ticks: 0, distance: 0, shield: 0, over: false }
30}
31
32export const steer = (g: Racer, dir: -1 | 1): Racer =>
33 g.over ? g : { ...g, car: Math.max(0, Math.min(g.lanes - 1, g.car + dir)) }
34
35const spawnChance = (speed: number) => Math.min(0.45, 0.12 + speed * 0.08)
36
37// a new car may enter `lane` at the right edge only if that lane has room and it wouldn't complete a
38// wall across every lane. Cars never change speed relative to each other, so a rule checked at spawn
39// holds for their whole trip.
40export function canSpawn(obstacles: Obstacle[], lane: number, g: Pick<Racer, 'lanes' | 'trackLen'>): boolean {
41 const edge = g.trackLen - 1
42 if (obstacles.some(o => o.lane === lane && o.x > edge - MIN_GAP)) return false
43 const blocked = new Set(obstacles.filter(o => o.lane !== lane && o.x > edge - WALL).map(o => o.lane))
44 return blocked.size < g.lanes - 1
45}
46
47const hits = (g: Racer, o: Obstacle) => o.lane === g.car && Math.abs(o.x - g.carX) < CAR_W
48
49// one grid step: scroll every car one cell left, maybe spawn one at the right edge, check for an overlap
50// with yours, and let a shield decay whether or not it was used
51export function tick(g: Racer, rand = Math.random): Racer {
52 if (g.over) return g
53 const speed = Math.min(MAX_SPEED, g.speed + 0.01)
54 let obstacles = g.obstacles.map(o => ({ ...o, x: o.x - 1 })).filter(o => o.x > -CAR_W)
55 if (rand() < spawnChance(speed)) {
56 const lane = Math.floor(rand() * g.lanes)
57 if (canSpawn(obstacles, lane, g)) obstacles = [...obstacles, { lane, x: g.trackLen - 1, look: rand() }]
58 }
59 const hit = obstacles.some(o => hits(g, o))
60 const shielded = hit && g.shield > 0
61 return {
62 ...g,
63 obstacles: shielded ? obstacles.filter(o => !hits(g, o)) : obstacles,
64 speed, ticks: g.ticks + 1, distance: g.distance + 1,
65 shield: Math.max(0, g.shield - 1),
66 over: hit && !shielded,
67 }
68}
69
70export type Pulse = 'edit' | 'test-pass' | 'test-fail' | 'commit'
71
72// one real build event, landed on the run in progress
73export function pulse(g: Racer, event: Pulse): Racer {
74 if (g.over) return g
75 if (event === 'edit') return { ...g, distance: g.distance + 1 }
76 if (event === 'test-pass') {
77 // clears the nearest oncoming car in your lane
78 const nearest = g.obstacles.filter(o => o.lane === g.car).sort((a, b) => a.x - b.x)[0]
79 const obstacles = nearest ? g.obstacles.filter(o => o !== nearest) : g.obstacles
80 return { ...g, obstacles, distance: g.distance + 5 }
81 }
82 if (event === 'test-fail') {
83 // a hazard in your lane — unless that would crowd it or wall off the road
84 if (!canSpawn(g.obstacles, g.car, g)) return g
85 return { ...g, obstacles: [...g.obstacles, { lane: g.car, x: g.trackLen - 1, look: 0 }] }
86 }
87 return { ...g, shield: g.shield + 20, distance: g.distance + 15 } // commit
88}
89hooks/games/breakout.ts 140 lines1// Breakout: pure game logic. Everything is in pixels — the board draws each pixel as half of a
2// character cell, so a pixel is one character wide and half a row tall. The paddle sits on the bottom
3// pixel row, bricks fill rows from the top, and a level is cleared when the last brick breaks.
4// ../boards/breakout.tsx runs it on its own frame clock and draws it.
5
6import type { BuildEvent } from './signals.ts'
7
8export type Ball = { x: number; y: number; dx: number; dy: number }
9export type Brick = { x: number; y: number; hp: number }
10export type Breakout = {
11 w: number; h: number
12 paddle: number; paddleW: number
13 ball: Ball; launched: boolean
14 bricks: Brick[]; level: number; score: number; lives: number
15 over: boolean
16}
17
18export const BRICK_W = 4
19const TOP = 1 // one empty pixel row above the bricks
20const STEP = 3 // paddle pixels per key press
21const MAX_LIVES = 5
22
23// the ball speeds up every level, capped so it stays catchable at ten frames a second
24const speedFor = (level: number) => Math.min(1.6, 0.8 + 0.15 * level)
25
26// full-width rows of bricks, one more row each level (leaving the lower two thirds for play); from
27// level 3 the top row is armored and takes two hits
28export function bricksFor(w: number, h: number, level: number): Brick[] {
29 const rows = Math.max(1, Math.min(2 + level, Math.floor(h / 3)))
30 const cols = Math.floor(w / BRICK_W)
31 const left = Math.floor((w - cols * BRICK_W) / 2)
32 const bricks: Brick[] = []
33 for (let r = 0; r < rows; r++) {
34 for (let c = 0; c < cols; c++) bricks.push({ x: left + c * BRICK_W, y: TOP + r, hp: level >= 3 && r === 0 ? 2 : 1 })
35 }
36 return bricks
37}
38
39// the ball waits on top of the paddle until it's launched
40const serve = (g: Breakout): Breakout => ({
41 ...g,
42 launched: false,
43 ball: { x: g.paddle + g.paddleW / 2, y: g.h - 2, dx: 0, dy: 0 },
44})
45
46export function newBreakout(w: number, h: number): Breakout {
47 const paddleW = Math.max(6, Math.floor(w / 6))
48 return serve({
49 w, h, paddle: Math.floor((w - paddleW) / 2), paddleW,
50 ball: { x: 0, y: 0, dx: 0, dy: 0 }, launched: false,
51 bricks: bricksFor(w, h, 1), level: 1, score: 0, lives: 3, over: false,
52 })
53}
54
55export function movePaddle(g: Breakout, dir: -1 | 1): Breakout {
56 if (g.over) return g
57 const paddle = Math.max(0, Math.min(g.w - g.paddleW, g.paddle + dir * STEP))
58 return g.launched ? { ...g, paddle } : { ...g, paddle, ball: { ...g.ball, x: paddle + g.paddleW / 2 } }
59}
60
61export function launch(g: Breakout): Breakout {
62 if (g.over || g.launched) return g
63 const s = speedFor(g.level)
64 return { ...g, launched: true, ball: { ...g.ball, dx: s * 0.6, dy: -s * 0.8 } }
65}
66
67const brickAt = (bricks: Brick[], x: number, y: number) =>
68 bricks.find(b => b.y === Math.floor(y) && x >= b.x && x < b.x + BRICK_W)
69
70const damage = (bricks: Brick[], hit: Brick) =>
71 hit.hp > 1 ? bricks.map(b => (b === hit ? { ...b, hp: b.hp - 1 } : b)) : bricks.filter(b => b !== hit)
72
73// the last brick gone: next level, a fresh wall, and a faster serve
74function settle(g: Breakout): Breakout {
75 if (g.bricks.length > 0) return g
76 const level = g.level + 1
77 return serve({ ...g, level, bricks: bricksFor(g.w, g.h, level) })
78}
79
80// one frame. The ball moves in sub-steps of at most half a pixel, x then y, so it can never skip over a
81// brick or the paddle, and each axis bounces on its own: hitting a brick's side flips dx, its top or
82// bottom flips dy.
83export function tick(g: Breakout): Breakout {
84 if (g.over || !g.launched) return g
85 let { x, y, dx, dy } = g.ball
86 let bricks = g.bricks
87 let score = g.score
88 const hit = (b: Brick) => {
89 bricks = damage(bricks, b)
90 score += 10 * g.level
91 }
92 const paddleRow = g.h - 1
93 const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / 0.5))
94 for (let i = 0; i < steps && y < g.h; i++) {
95 let nx = x + dx / steps
96 if (nx < 0) { nx = -nx; dx = -dx }
97 else if (nx >= g.w) { nx = Math.min(g.w - 0.001, 2 * g.w - nx); dx = -dx }
98 const side = brickAt(bricks, nx, y)
99 if (side) { hit(side); dx = -dx; nx = x }
100 x = nx
101
102 let ny = y + dy / steps
103 if (ny < 0) { ny = -ny; dy = -dy }
104 const face = brickAt(bricks, x, ny)
105 if (face) { hit(face); dy = -dy; ny = y }
106 // landing on the paddle sends it back up, angled by where it hit: the middle straight up, the
107 // edges steeply sideways
108 if (dy > 0 && y < paddleRow && ny >= paddleRow && x >= g.paddle && x < g.paddle + g.paddleW) {
109 const speed = Math.hypot(dx, dy)
110 const offset = (x - (g.paddle + g.paddleW / 2)) / (g.paddleW / 2)
111 dx = speed * Math.max(-0.85, Math.min(0.85, offset))
112 dy = -Math.sqrt(speed * speed - dx * dx)
113 ny = paddleRow - 0.001
114 }
115 y = ny
116 }
117 if (y >= g.h) {
118 const lives = g.lives - 1
119 const missed = { ...g, bricks, score, lives }
120 return lives > 0 ? serve(missed) : { ...missed, over: true, launched: false, ball: { x, y: paddleRow, dx: 0, dy: 0 } }
121 }
122 return settle({ ...g, bricks, score, ball: { x, y, dx, dy } })
123}
124
125export type Pulse = BuildEvent
126
127const lowest = (bricks: Brick[]) => [...bricks].sort((a, b) => b.y - a.y || a.x - b.x)[0]
128
129// one real build event: an edit scores a point, a passing test breaks the lowest brick for you, a
130// failing test armors it (up to 3 hits), a commit is an extra life (up to 5)
131export function pulse(g: Breakout, event: Pulse): Breakout {
132 if (g.over) return g
133 if (event === 'edit') return { ...g, score: g.score + 1 }
134 if (event === 'commit') return { ...g, lives: Math.min(MAX_LIVES, g.lives + 1) }
135 const target = lowest(g.bricks)
136 if (!target) return g
137 if (event === 'test-pass') return settle({ ...g, bricks: g.bricks.filter(b => b !== target), score: g.score + 10 * g.level })
138 return { ...g, bricks: g.bricks.map(b => (b === target ? { ...b, hp: Math.min(3, b.hp + 1) } : b)) }
139}
140hooks/games/dino.ts 97 lines1// Dino Runner: pure game logic, in pixels. The dino runs in place near the left edge while cacti and
2// birds scroll in from the right; it jumps over cacti and ducks under birds, and the run speeds up the
3// whole time. Altitude is measured up from the ground. ../boards/dino.tsx runs it and draws it.
4
5import type { BuildEvent } from './signals.ts'
6
7export type Obstacle = { kind: 'cactus' | 'bird'; x: number; w: number; h: number; alt: number }
8export type Dino = {
9 w: number; h: number
10 alt: number; vy: number; duck: number
11 obstacles: Obstacle[]; speed: number; distance: number; shield: number; ticks: number
12 over: boolean
13}
14
15export const DINO_X = 4
16export const STAND = { w: 5, h: 4 }
17export const DUCKED = { w: 6, h: 2 }
18const JUMP_V = 2.0
19const GRAVITY = 0.36 // a hop lasts about 12 ticks and peaks at 6.6 px, inside a 12-px field
20const START_SPEED = 1.5
21const MAX_SPEED = 3.5
22// the next obstacle never spawns closer than a whole hop's travel plus room to land, so every gap is
23// clearable: at the slowest speed a 5-px dino and a 3-px cactus overlap for at most 6 ticks, while a
24// hop keeps it above 3 px for 9
25const HOP_TICKS = 13
26const BIRDS_FROM = 150
27const BIRD_ALT = STAND.h - 1 // clips a standing dino's head, clears a ducking one
28const DUCK_TICKS = 4
29
30export function newDino(w: number, h: number): Dino {
31 return { w, h, alt: 0, vy: 0, duck: 0, obstacles: [], speed: START_SPEED, distance: 0, shield: 0, ticks: 0, over: false }
32}
33
34const onGround = (g: Dino) => g.alt === 0 && g.vy === 0
35
36export function jump(g: Dino): Dino {
37 return g.over || !onGround(g) ? g : { ...g, vy: JUMP_V, duck: 0 }
38}
39
40// the terminal sends no key-up, so a duck lasts a few ticks and holding the key keeps renewing it; in
41// the air, ducking dives straight back down
42export function duck(g: Dino): Dino {
43 if (g.over) return g
44 return onGround(g) ? { ...g, duck: DUCK_TICKS } : { ...g, vy: Math.min(g.vy, -JUMP_V) }
45}
46
47export const box = (g: Dino) => (onGround(g) && g.duck > 0 ? DUCKED : STAND)
48
49const hits = (g: Dino, o: Obstacle) => {
50 const b = box(g)
51 return o.x < DINO_X + b.w && o.x + o.w > DINO_X && o.alt < g.alt + b.h && o.alt + o.h > g.alt
52}
53
54const roomAtEdge = (obstacles: Obstacle[], w: number, speed: number) =>
55 w - obstacles.reduce((right, o) => Math.max(right, o.x + o.w), -Infinity) >= speed * HOP_TICKS + 6
56
57export function tick(g: Dino, rand = Math.random): Dino {
58 if (g.over) return g
59 const speed = Math.min(MAX_SPEED, g.speed + 0.003)
60 let alt = g.alt + g.vy
61 let vy = g.vy - GRAVITY
62 if (alt <= 0) {
63 alt = 0
64 vy = 0
65 }
66 let obstacles = g.obstacles.map(o => ({ ...o, x: o.x - speed })).filter(o => o.x + o.w > 0)
67 if (roomAtEdge(obstacles, g.w, speed) && rand() < 0.12) {
68 const spawned: Obstacle = g.distance >= BIRDS_FROM && rand() < 0.35
69 ? { kind: 'bird', x: g.w, w: 3, h: 2, alt: BIRD_ALT }
70 : { kind: 'cactus', x: g.w, w: 2 + Math.floor(rand() * 2), h: 2 + Math.floor(rand() * 2), alt: 0 }
71 obstacles = [...obstacles, spawned]
72 }
73 const next: Dino = {
74 ...g, alt, vy, duck: Math.max(0, g.duck - 1), obstacles, speed,
75 distance: g.distance + speed, shield: Math.max(0, g.shield - 1), ticks: g.ticks + 1,
76 }
77 const hit = next.obstacles.filter(o => hits(next, o))
78 if (hit.length === 0) return next
79 return g.shield > 0 ? { ...next, obstacles: next.obstacles.filter(o => !hit.includes(o)) } : { ...next, over: true }
80}
81
82export type Pulse = BuildEvent
83
84// one real build event: an edit is a point, a passing test clears the next obstacle, a failing test drops
85// a cactus at the edge (only where the gap rule allows), a commit shields you for 3 seconds
86export function pulse(g: Dino, event: Pulse): Dino {
87 if (g.over) return g
88 if (event === 'edit') return { ...g, distance: g.distance + 1 }
89 if (event === 'commit') return { ...g, shield: g.shield + 30 }
90 if (event === 'test-pass') {
91 const ahead = g.obstacles.filter(o => o.x + o.w > DINO_X).sort((a, b) => a.x - b.x)[0]
92 return ahead ? { ...g, obstacles: g.obstacles.filter(o => o !== ahead), distance: g.distance + 10 } : g
93 }
94 if (!roomAtEdge(g.obstacles, g.w, g.speed)) return g
95 return { ...g, obstacles: [...g.obstacles, { kind: 'cactus', x: g.w, w: 2, h: 2, alt: 0 }] }
96}
97hooks/games/shooter.ts 134 lines1// Side-scrolling shooter: pure game logic, in pixels. Your ship sits near the left edge and moves up and
2// down, firing right; enemies fly in from the right. Levels come from score and bring faster, denser
3// waves. ../boards/shooter.tsx runs it and draws it.
4
5import type { BuildEvent } from './signals.ts'
6
7export type Enemy = { kind: 0 | 1 | 2; x: number; y: number; baseY: number; hp: number; vx: number }
8export type Shot = { x: number; y: number; dx: number } // dx > 0 is yours, dx < 0 is an enemy bullet
9export type Shooter = {
10 w: number; h: number
11 shipY: number; enemies: Enemy[]; shots: Shot[]
12 cooldown: number; power: number; invuln: number
13 score: number; lives: number; level: number; ticks: number
14 over: boolean
15}
16
17export const SHIP_X = 1
18export const SHIP = { w: 5, h: 3 }
19// basic; armored (two hits, fires back from level 2); zigzag (weaves around its lane)
20export const ENEMY = [
21 { w: 3, h: 2, hp: 1, points: 10 },
22 { w: 4, h: 3, hp: 2, points: 25 },
23 { w: 3, h: 3, hp: 1, points: 15 },
24] as const
25const MOVE = 2
26const COOLDOWN = 2
27const SHOT_SPEED = 3
28const BULLET_SPEED = 2
29const INVULN = 15
30const POWER = 50
31const LEVEL_EVERY = 250
32// the level keeps counting up, but waves stop getting faster and denser past this one: an enemy moving
33// more than its own width per frame could slip past a shot or the ship
34const MAX_PACE = 8
35
36const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
37const levelFor = (score: number) => 1 + Math.floor(score / LEVEL_EVERY)
38
39export function newShooter(w: number, h: number): Shooter {
40 return {
41 w, h, shipY: Math.floor((h - SHIP.h) / 2), enemies: [], shots: [],
42 cooldown: 0, power: 0, invuln: 0, score: 0, lives: 3, level: 1, ticks: 0, over: false,
43 }
44}
45
46export const move = (g: Shooter, dir: -1 | 1): Shooter =>
47 g.over ? g : { ...g, shipY: clamp(g.shipY + dir * MOVE, 0, g.h - SHIP.h) }
48
49// one shot from the nose, or three while powered up; holding space auto-fires through key repeat
50export function fire(g: Shooter): Shooter {
51 if (g.over || g.cooldown > 0) return g
52 const ys = g.power > 0 ? [g.shipY, g.shipY + 1, g.shipY + 2] : [g.shipY + 1]
53 return { ...g, cooldown: COOLDOWN, shots: [...g.shots, ...ys.map(y => ({ x: SHIP_X + SHIP.w, y, dx: SHOT_SPEED }))] }
54}
55
56export function tick(g: Shooter, rand = Math.random): Shooter {
57 if (g.over) return g
58 const ticks = g.ticks + 1
59 const level = levelFor(g.score)
60 const pace = Math.min(level, MAX_PACE)
61 let score = g.score
62
63 let shots = g.shots.map(s => ({ ...s, x: s.x + s.dx }))
64 let enemies = g.enemies
65 .map(e => ({
66 ...e,
67 x: e.x - e.vx,
68 y: e.kind === 2 ? clamp(e.baseY + Math.round(Math.sin(ticks / 3) * 2), 0, g.h - ENEMY[2].h) : e.y,
69 }))
70 .filter(e => e.x + ENEMY[e.kind].w > 0)
71
72 if (level >= 2) {
73 for (const e of enemies) if (e.kind === 1 && rand() < 0.03) shots.push({ x: e.x - 1, y: e.y + 1, dx: -BULLET_SPEED })
74 }
75 if (!enemies.some(e => e.x > g.w - 8) && rand() < Math.min(0.3, 0.08 + 0.03 * pace)) {
76 const roll = rand()
77 const kind: Enemy['kind'] = level >= 2 && roll < 0.3 ? 1 : roll < 0.65 ? 0 : 2
78 const y = Math.floor(rand() * (g.h - ENEMY[kind].h + 1))
79 enemies.push({ kind, x: g.w, y, baseY: y, hp: ENEMY[kind].hp, vx: 0.5 + 0.1 * pace + rand() * 0.3 })
80 }
81
82 // your shots hit along the whole path they moved this tick, so a fast shot can't skip past an enemy
83 const flying: Shot[] = []
84 for (const s of shots) {
85 const target = s.dx > 0 && enemies.find(e =>
86 e.hp > 0 && s.y >= e.y && s.y < e.y + ENEMY[e.kind].h && e.x <= s.x && e.x + ENEMY[e.kind].w > s.x - s.dx)
87 if (!target) {
88 flying.push(s)
89 continue
90 }
91 target.hp -= 1
92 if (target.hp === 0) score += ENEMY[target.kind].points * pace
93 }
94 enemies = enemies.filter(e => e.hp > 0)
95 shots = flying.filter(s => (s.dx > 0 ? s.x < g.w : s.x + BULLET_SPEED >= 0))
96
97 // anything touching the ship costs a life, then a moment of invulnerability
98 const onShip = (x0: number, x1: number, y0: number, y1: number) =>
99 x0 < SHIP_X + SHIP.w && x1 > SHIP_X && y0 < g.shipY + SHIP.h && y1 > g.shipY
100 const rammed = enemies.filter(e => onShip(e.x, e.x + ENEMY[e.kind].w, e.y, e.y + ENEMY[e.kind].h))
101 const shotDown = shots.filter(s => s.dx < 0 && onShip(s.x, s.x + BULLET_SPEED, s.y, s.y + 1))
102 let lives = g.lives
103 let invuln = Math.max(0, g.invuln - 1)
104 if (rammed.length > 0 || shotDown.length > 0) {
105 enemies = enemies.filter(e => !rammed.includes(e))
106 shots = shots.filter(s => !shotDown.includes(s))
107 if (invuln === 0) {
108 lives -= 1
109 invuln = INVULN
110 }
111 }
112
113 return {
114 ...g, enemies, shots, score, lives, invuln, ticks, level: levelFor(score),
115 cooldown: Math.max(0, g.cooldown - 1), power: Math.max(0, g.power - 1), over: lives <= 0,
116 }
117}
118
119export type Pulse = BuildEvent
120
121// one real build event: an edit is a point, a passing test powers up a triple shot, a failing test sends
122// an extra pair of enemies, a commit is a bomb that destroys (and scores) everything on screen
123export function pulse(g: Shooter, event: Pulse): Shooter {
124 if (g.over) return g
125 if (event === 'edit') return { ...g, score: g.score + 1 }
126 if (event === 'test-pass') return { ...g, power: g.power + POWER }
127 if (event === 'test-fail') {
128 const pair = [0, g.h - ENEMY[0].h].map((y): Enemy => ({ kind: 0, x: g.w, y, baseY: y, hp: ENEMY[0].hp, vx: 0.5 + 0.1 * Math.min(g.level, MAX_PACE) }))
129 return { ...g, enemies: [...g.enemies, ...pair] }
130 }
131 const score = g.score + g.enemies.reduce((sum, e) => sum + ENEMY[e.kind].points * Math.min(g.level, MAX_PACE), 0)
132 return { ...g, score, level: levelFor(score), enemies: [], shots: g.shots.filter(s => s.dx > 0) }
133}
134