SLOPSHOPPER

claude-games

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…

newbandguardcommandtoast
★ 3v0.1.0MITupdated 2026-09-14mohi-devhub/claude-games
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-games
› 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 › /racer ⎿ claude-games: racer · click the board, then ↑ ↓ or w/s switch lanes · best 0 · /racer stop closes [ close ] ▣ client module ./boards/shooter.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ close ] ▣ client module ./boards/shooter.tsx ⟨Claude Code's own drawing⟩
README

claude-games

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">

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.
  • 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 mohi-devhub/claude-games
   claude plugin install claude-games@claude-games
  1. Start claude and run /dino (or any other game). The board appears above the prompt.
  1. Click the board, then play. The click gives the game the keyboard; until then your keys go to the prompt. Press Esc to give the keyboard back to the prompt, and /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.

Games

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 Racer

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

  • Edit — a little distance.
  • Passing test — clears the nearest car in your lane, plus a bonus.
  • Failing test — drops a hazard in your lane.
  • Commit — a shield that absorbs one hit, plus a bonus.

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

  • Edit — a point.
  • Passing test — breaks the lowest brick for you.
  • Failing test — armors the lowest brick (up to three hits).
  • Commit — an extra life (up to five).

/dino — Dino Runner

space, ↑ 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.

  • Edit — a point.
  • Passing test — clears the next obstacle.
  • Failing test — drops a cactus at the edge.
  • Commit — a three-second shield.

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

  • Edit — a point.
  • Passing test — a triple shot for five seconds.
  • Failing test — an extra pair of enemies.
  • Commit — a bomb that destroys everything on screen, and scores it.

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

What counts as Claude's work

Only Claude's own tool calls count, never commands you run yourself:

  • An edit is a successful Edit, Write or NotebookEdit.
  • A test run is a Bash command running 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.
  • A commit is a successful 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.

Best scores

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.

How it works

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

Troubleshooting

  • A game's command is unknown. 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.
  • 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. Close the game and open it again. If it repeats, start Claude with --debug-file /tmp/claude-games.log and search the log for claude-games.
  • Games stopped working after editing the plugin. A hot reload that fails partway leaves the old version loaded. Restart the session.

Limits

  • Boards redraw ten times a second, so fast games feel coarse next to a real console.
  • One game is open at a time, in the rows Claude Code gives the band above the prompt.
  • 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 games.
  • Best scores live in the plugin's store on this machine. Nothing is shared between machines.

Develop

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.

Credits

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.

License

MIT. See LICENSE.

Source 12 files
hooks/register.tsx 148 lines
1/* @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}
148
hooks/games/signals.ts 21 lines
1// 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}
21
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/boards/racer.tsx 125 lines
1/* @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}
125
hooks/boards/breakout.tsx 106 lines
1/* @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}
106
hooks/boards/dino.tsx 96 lines
1/* @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}
96
hooks/boards/shooter.tsx 110 lines
1/* @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}
110
hooks/boards/common.tsx 64 lines
1/* @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}
64
hooks/games/racer.ts 89 lines
1// 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}
89
hooks/games/breakout.ts 140 lines
1// 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}
140
hooks/games/dino.ts 97 lines
1// 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}
97
hooks/games/shooter.ts 134 lines
1// 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