SLOPSHOPPER

cc-dino

The T-rex from the browser's offline page, above the Claude Code prompt: jump cacti and duck birds while Claude works. Needs function hooks (early access) and…

newbandcommandtoastnetworktimer
★ 1v0.1.1MITupdated 2026-09-15giovaborgogno/cc-dino
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-dino
› 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 › /dino ⎿ cc-dino: dino · click the board, then space or ↑ jumps and ↓ ducks · t shows the leaderboard · h sets your handle to jo [ close ] no internet ▣ client module ./boards/dino.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

cc-dino

The T-rex from the browser's offline page, above the Claude Code prompt: jump cacti and duck birds while Claude works. The run pauses when Claude finishes a turn.

cc-dino: the T-rex jumping cacti and a bird above the Claude Code prompt, then game over beside the leaderboard

Watch it as a video

Needs function hooks (early access) and an interactive terminal.

Install

Turn on function hooks in ~/.claude/settings.json:

{
  "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" }
}

Then, in Claude Code:

/plugin marketplace add giovaborgogno/cc-dino
/plugin install cc-dino@cc-dino

Restart Claude Code and run /dino.

Play

CommandWhat it does
/dinoopen or close the board
/dino autoopen the board whenever Claude starts working
/dino topshow the leaderboard
/dino handle @youjoin the leaderboard under your X handle
/dino demowatch a run that plays itself

On the board: click it first, then space or ↑ jumps, ↓ ducks, p pauses, r restarts, t shows the leaderboard, h edits your handle. Esc returns to the prompt.

Leaderboard

Press h on the board (or run /dino handle @you) and type your X/Twitter handle.

Develop

bun test

License

MIT

Source 5 files
hooks/register.tsx 261 lines
1/* @jsx h */
2import type { EngineInterface, HttpInit, Register } from 'claude-code'
3import { RULES } from './game/dino.ts'
4import { HANDLE } from './game/handle.ts'
5
6// /dino opens the T-rex from the browser's offline page above the prompt. The board in
7// ./boards/dino.tsx runs on the drawing thread; this module mounts it, keeps the best score in
8// $.store, pauses the run when Claude finishes a turn, in auto mode opens it when a turn starts, and
9// sends each run under the player's X handle to the hosted leaderboard at API.
10//
11// The leaderboard does not take a score: it deals a ticket whose seed the next run's obstacles come
12// from, and takes the run's key presses, which it replays with the same ./game/dino.ts to find the
13// score. A run that took less time than its ticks need is refused.
14
15const API = 'https://cc-dino-leaderboard.vercel.app/api'
16// a ticket older than this is swapped for a new one before a run takes it: the leaderboard keeps
17// them for 6 hours
18const TICKET_TTL_MS = 3 * 60 * 60 * 1000
19
20type Entry = { handle: string; score: number }
21type Ticket = { id: string; seed: number }
22type Run = { ticket: string; w: number; h: number; inputs: number[] }
23type Answer = { top?: unknown; best?: unknown; score?: unknown; rank?: unknown; id?: unknown; seed?: unknown; error?: unknown }
24
25let open = false
26// /dino demo: the board plays a run by itself, for a screen recording
27let demo = false
28let auto = false
29let best = 0
30// bumped at each finished turn; the board pauses when it sees a new value
31let turnsDone = 0
32let handle: string | undefined
33let rank: number | undefined
34let top: Entry[] = []
35// the ticket the next run takes, when it was dealt, and whether a new one is on its way
36let ticket: Ticket | undefined
37let ticketAt = 0
38let dealing = false
39
40const isEntries = (value: unknown): value is Entry[] =>
41  Array.isArray(value) && value.every(e => typeof e?.handle === 'string' && typeof e?.score === 'number')
42
43const isRun = (value: unknown): value is Run => {
44  const r = value as Partial<Run> | null
45  return typeof r?.ticket === 'string' && typeof r.w === 'number' && typeof r.h === 'number'
46    && Array.isArray(r.inputs) && r.inputs.every(n => typeof n === 'number')
47}
48
49// everything the board draws or deals a run from that this module knows. A prop that is undefined
50// fails the engine's check and the band draws without the board, so a missing ticket is left out
51const boardProps = () => ({ best, done: turnsDone, handle: handle ?? '', rank: rank ?? 0, top, demo, ...(ticket ? { ticket } : {}) })
52
53// one call to the leaderboard; a network or a server that fails costs the board, never the game
54async function leaderboard($: EngineInterface, path: string, init?: HttpInit): Promise<Answer | undefined> {
55  try {
56    const res = await $.http.fetch(`${API}${path}`, init)
57    const body = JSON.parse(res.text) as Answer
58    if (!res.ok) throw new Error(`${res.status}${typeof body.error === 'string' ? ` ${body.error}` : ''}`)
59    if (isEntries(body.top)) top = body.top
60    if (typeof body.rank === 'number') rank = body.rank
61    $.ui.invalidate('ui.render')
62    return body
63  } catch (err) {
64    $.ui.log(`cc-dino: leaderboard failed: ${err}`)
65    return undefined
66  }
67}
68
69// a ticket for the next run while the player has a handle, unless the one held is still fresh;
70// without a ticket a run is played all the same and stays on this machine
71async function deal($: EngineInterface) {
72  if (!handle || dealing) return
73  dealing = true
74  try {
75    const now = await $.clock.now()
76    if (ticket && now - ticketAt < TICKET_TTL_MS) return
77    const body = await leaderboard($, '/runs', { method: 'POST' })
78    if (typeof body?.id === 'string' && typeof body.seed === 'number') {
79      ticket = { id: body.id, seed: body.seed }
80      ticketAt = now
81      $.ui.invalidate('ui.render')
82    }
83  } finally {
84    dealing = false
85  }
86}
87
88// sent from a timer, not awaited in the hook: a sleeping database can take longer to answer than a
89// hook's budget allows
90function submit($: EngineInterface, run: Run) {
91  const who = handle
92  if (!who) return
93  $.clock.after(0, () => {
94    const body = JSON.stringify({ handle: who, rules: RULES, ...run })
95    void leaderboard($, '/scores', { method: 'POST', headers: { 'content-type': 'application/json' }, body })
96      .then(answer => {
97        if (typeof answer?.score === 'number' && answer.score > 0 && answer.best === answer.score && rank) {
98          $.ui.toast(`dino: @${who} is #${rank} on the leaderboard with ${answer.score}`)
99        }
100      })
101  })
102}
103
104// a new handle, or none: kept across sessions; runs from now on go to the leaderboard under it
105async function setHandle($: EngineInterface, next: string | undefined) {
106  handle = next
107  rank = undefined
108  await (next ? $.store.set('handle', next) : $.store.delete('handle'))
109    .catch(err => $.ui.log(`cc-dino: store write failed: ${err}`))
110  $.clock.after(0, () => void deal($))
111  $.ui.invalidate('ui.render')
112}
113
114const listing = () => top.length === 0
115  ? 'the leaderboard is empty'
116  : top.map((e, i) => `${String(i + 1).padStart(2)}. @${e.handle.padEnd(16)}${e.score}`).join('\n')
117
118export const register: Register = on => {
119  on('session.start', async ($, e, next) => {
120    const r = await next(e)
121    // a store that cannot be read costs the best score, never the game: an unhandled rejection
122    // here would unmount the whole module
123    const stored = (key: string) => $.store.get(key).catch(err => { $.ui.log(`cc-dino: store read failed: ${err}`); return undefined })
124    best = Number(await stored('best')) || 0
125    auto = (await stored('auto')) === true
126    const saved = await stored('handle')
127    handle = typeof saved === 'string' && HANDLE.test(saved) ? saved : undefined
128    await $.command.register({
129      name: 'dino',
130      description: 'The offline T-rex above the prompt: jump cacti while Claude works, with a leaderboard (cc-dino)',
131      argumentHint: '[stop | auto | top | handle @you | demo]',
132      immediate: true,
133    }).catch(err => $.ui.log(`cc-dino: /dino not registered: ${err}`))
134    $.clock.after(0, () => void leaderboard($, '/scores').then(() => deal($)))
135    return r
136  })
137
138  // every form of /dino answers here; none of them passes the command on
139  on('command.run', { command: 'dino' }, async ($, e) => {
140    const [word = '', value] = e.args.trim().split(/\s+/)
141    const arg = word.toLowerCase()
142    if (arg === 'auto') {
143      auto = !auto
144      await $.store.set('auto', auto).catch(err => $.ui.log(`cc-dino: store write failed: ${err}`))
145      return { text: `dino auto ${auto ? 'on · the dino opens whenever Claude starts working' : 'off'}` }
146    }
147    if (arg === 'handle') {
148      if (!value) return { text: handle ? `your runs go to the leaderboard as @${handle}` : 'no handle yet · /dino handle @you joins the leaderboard' }
149      const match = HANDLE.exec(value)
150      if (!match) return { text: `"${value}" is not an X handle: letters, digits and _, up to 15` }
151      await setHandle($, match[1])
152      return { text: `your runs from now on go to the leaderboard as @${handle} · handles are not verified` }
153    }
154    if (arg === 'top') {
155      const body = await leaderboard($, '/scores')
156      if (!body) return { text: 'the leaderboard did not answer · try again in a moment' }
157      return { text: `dino leaderboard\n${listing()}${handle ? `\n\nyou play as @${handle}` : '\n\n/dino handle @you joins it'}` }
158    }
159    if (arg === 'demo') {
160      open = true
161      demo = true
162      $.ui.invalidate('ui.render')
163      return { text: 'dino demo · a run that plays itself and is never sent · /dino closes it' }
164    }
165    if (arg === 'stop' || arg === 'close' || (arg === '' && open)) {
166      open = false
167      demo = false
168      $.ui.invalidate('ui.render')
169      return { text: 'dino closed' }
170    }
171    if (arg !== '') return { text: `no option "${arg}" · /dino opens or closes, /dino top shows the leaderboard, /dino handle @you joins it, /dino auto opens it whenever Claude works, /dino demo plays a run by itself` }
172    open = true
173    $.clock.after(0, () => void deal($))
174    $.ui.invalidate('ui.render')
175    return { text: `dino · click the board, then space or ↑ jumps and ↓ ducks · t shows the leaderboard · h ${handle ? 'changes your handle' : 'sets your handle to join it'} · Esc returns to the prompt · best ${best}` }
176  })
177
178  on('turn.start', async ($, e, next) => {
179    if (auto && !open) {
180      open = true
181      $.clock.after(0, () => void deal($))
182      $.ui.invalidate('ui.render')
183    }
184    return next(e)
185  })
186
187  on('turn.complete', async ($, e, next) => {
188    const r = await next(e)
189    if (open) {
190      turnsDone++
191      $.ui.invalidate('ui.render')
192    }
193    return r
194  })
195
196  // what the board posts, each answered with its next props: a handle typed in its editor, a look
197  // at the leaderboard, a run that took the ticket, or a finished run, whose best is kept and which
198  // goes to the leaderboard when it was dealt from a ticket
199  on('ui.message', async ($, e, next) => {
200    const data = e.data as { game?: unknown; score?: unknown; run?: unknown; handle?: unknown; top?: unknown; started?: unknown } | null
201    if (data?.game !== 'dino') return next(e)
202    if (typeof data.handle === 'string') {
203      // an empty one leaves the leaderboard; the board only builds handles, but its posts are checked
204      const match = HANDLE.exec(data.handle)
205      if (data.handle === '') {
206        await setHandle($, undefined)
207        $.ui.toast('dino: no handle · your runs stay off the leaderboard')
208      } else if (match) {
209        await setHandle($, match[1])
210        $.ui.toast(`dino: your runs go to the leaderboard as @${match[1]}`)
211      }
212      return { props: boardProps() }
213    }
214    if (data.top === true) {
215      // the fresh list reaches the board through the redraw leaderboard() asks for
216      $.clock.after(0, () => void leaderboard($, '/scores'))
217      return { props: boardProps() }
218    }
219    if (typeof data.started === 'string') {
220      // a ticket deals one run: the next run needs a new one
221      if (ticket?.id === data.started) ticket = undefined
222      $.clock.after(0, () => void deal($))
223      return { props: boardProps() }
224    }
225    if (typeof data.score !== 'number') return next(e)
226    if (data.score > best) {
227      best = data.score
228      await $.store.set('best', best).catch(err => $.ui.log(`cc-dino: store write failed: ${err}`))
229      $.ui.toast(`dino: new best ${best}`)
230    }
231    if (data.score > 0 && isRun(data.run)) submit($, data.run)
232    return { props: boardProps() }
233  })
234
235  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
236    // the board needs a terminal's keys and mouse; the desktop and mobile surfaces draw their own band
237    if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
238    const { Box, Button, Client, Text } = await $.ui.resolve(e)
239    // the band's own box, narrower than the viewport while a pane is docked beside the transcript
240    const cols = e.props.bodyColumns ?? e.viewport?.columns ?? 80
241    // one row for the close button, one for whatever else draws in the band
242    const rows = Math.min(16, Math.max(8, e.props.maxRows - 2))
243    const close = () => {
244      open = false
245      demo = false
246      $.ui.invalidate('ui.render')
247    }
248    // the module path must be a string literal, since the engine reads it off this source
249    return (
250      <Box flexDirection="column">
251        <Box flexDirection="row" columnGap={1}>
252          <Button key="dino:close" label="close" onPress={close} />
253          <Text dimColor>{`no internet${auto ? ' · auto on' : ''}${handle ? ` · @${handle}` : ''}`}</Text>
254        </Box>
255        <Client key={demo ? 'board:dino-demo' : 'board:dino'} module="./boards/dino.tsx" width={cols} height={rows} props={boardProps()} />
256        {await next(e)}
257      </Box>
258    )
259  })
260}
261
hooks/game/dino.ts 215 lines
1// Dino: the T-rex from the browser's offline page, as pure logic drawn by ../boards/dino.tsx.
2// Units are terminal cells; one tick is one TICK_MS frame. Heights count up from the ground.
3//
4// A run is deterministic: the obstacles come from a seed, so the seed, the board's size and the
5// frame of each key press play it again exactly. The leaderboard replays every run it is sent with
6// this same file, which is why a change to the rules must bump RULES.
7
8export type Shape = readonly string[]
9export type Obstacle = { kind: 'cactus' | 'bird'; x: number; lift: number; shape: number }
10export type Cloud = { x: number; lift: number }
11export type DinoGame = {
12  w: number
13  h: number
14  // the feet's height above the ground, and the upward speed
15  y: number
16  vy: number
17  // ticks of ducking left: a terminal sends no key-up, so a press ducks for a moment and key repeat holds it
18  duck: number
19  speed: number
20  dist: number
21  obstacles: Obstacle[]
22  // columns to run before the next obstacle
23  gap: number
24  clouds: Cloud[]
25  frame: number
26  over: boolean
27  // the random generator's state, advanced by every draw a tick makes
28  seed: number
29}
30
31// the version of these rules: a run recorded under other rules does not replay the same
32export const RULES = 1
33export const TICK_MS = 50
34// the board's size in cells, the same bounds the board draws within
35export const MIN_W = 30
36export const MAX_W = 100
37export const MIN_H = 7
38export const MAX_H = 13
39
40export const DINO_X = 2
41// columns a tick: 20 a second at the start, 44 at the top
42export const START_SPEED = 1
43export const MAX_SPEED = 2.2
44export const CLUSTER_SPEED = 1.3
45// speed gained per column run: the top speed arrives at 2000 points
46export const ACCEL = (MAX_SPEED - START_SPEED) / 4000
47const GRAVITY = 0.22
48// peaks near four rows, in the air for about 12 ticks
49const JUMP = 1.3
50const FAST_FALL = 1.2
51// long enough to bridge the pause before a held key starts repeating
52const DUCK_TICKS = 10
53const BIRD_EXTRA = 0.3
54const BIRD_DIST = 500
55const BIRD_LIFTS = [1, 2, 3]
56// wide enough to land and jump again at any speed
57const MIN_GAP = 14
58const GAP_PER_SPEED = 12
59const GAP_RANDOM = 22
60
61// Only full blocks collide, so the tail, arm tips and wing tips forgive a near miss, as in the original.
62export const DINO = {
63  run: [
64    ['    █▀██', '▀▄▄███▀ ', '  █ ▀   '],
65    ['    █▀██', '▀▄▄███▀ ', '  ▀ █   '],
66  ],
67  jump: ['    █▀██', '▀▄▄███▀ ', '  █ █   '],
68  duck: [
69    ['▀▄▄▄███▀█', '  █ ▀    '],
70    ['▀▄▄▄███▀█', '  ▀ █    '],
71  ],
72} satisfies Record<string, Shape | Shape[]>
73
74// the last one, a cluster, only comes once the dino runs fast enough to clear it
75export const CACTI: Shape[] = [
76  ['▖█', '▀█'],
77  ['▖█▗', '▀█▀'],
78  ['▖█▗', '▀█▀', ' █ '],
79  ['▖█ █▗', '▀█▖█▀', ' █▀█ '],
80]
81
82export const BIRD: Shape[] = [['▄▀█▀▀'], ['▀▄█▄▄']]
83
84export const CLOUD: Shape = ['▁▄▆▄▁']
85
86// mulberry32: a number in [0, 1) and the next state. Integer arithmetic only, so every JavaScript
87// engine draws the same sequence from a seed
88export function random(seed: number): [value: number, next: number] {
89  const next = (seed + 0x6d2b79f5) | 0
90  let t = Math.imul(next ^ (next >>> 15), next | 1)
91  t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
92  return [((t ^ (t >>> 14)) >>> 0) / 4294967296, next]
93}
94
95export function newDino(w: number, h: number, seed = 0): DinoGame {
96  return {
97    w, h, y: 0, vy: 0, duck: 0, speed: START_SPEED, dist: 0, obstacles: [],
98    gap: Math.max(20, Math.floor(w / 2)), clouds: [{ x: Math.floor(w * 0.6), lift: 5 }], frame: 0, over: false,
99    seed: seed | 0,
100  }
101}
102
103const airborne = (g: DinoGame) => g.y > 0 || g.vy > 0
104
105// only from the ground: no double jumps
106export const jump = (g: DinoGame): DinoGame => (g.over || airborne(g) ? g : { ...g, vy: JUMP, duck: 0 })
107
108// on the ground it ducks; in the air it drops fast
109export function duck(g: DinoGame): DinoGame {
110  if (g.over) return g
111  return airborne(g) ? { ...g, vy: Math.min(g.vy, -FAST_FALL) } : { ...g, duck: DUCK_TICKS }
112}
113
114export const score = (g: DinoGame) => Math.floor(g.dist / 2)
115
116export function dinoShape(g: DinoGame): Shape {
117  // the legs swap every 100 ms
118  const step = Math.floor(g.frame / 2) % 2
119  if (g.y > 0) return DINO.jump
120  return g.duck > 0 ? DINO.duck[step] : DINO.run[step]
121}
122
123export const obstacleShape = (o: Obstacle, frame: number): Shape =>
124  o.kind === 'bird' ? BIRD[Math.floor(frame / 4) % 2] : CACTI[o.shape]
125
126// the full blocks of a shape whose left column is x and bottom row sits at height lift, as "col,height"
127export function solid(shape: Shape, x: number, lift: number): string[] {
128  const out: string[] = []
129  shape.forEach((row, r) => [...row].forEach((ch, c) => {
130    if (ch === '█') out.push(`${x + c},${lift + shape.length - 1 - r}`)
131  }))
132  return out
133}
134
135export function hits(g: DinoGame): boolean {
136  const body = new Set(solid(dinoShape(g), DINO_X, Math.round(g.y)))
137  return g.obstacles.some(o => solid(obstacleShape(o, g.frame), Math.round(o.x), o.lift).some(cell => body.has(cell)))
138}
139
140function spawn(g: DinoGame, speed: number, rand: () => number): Obstacle {
141  if (g.dist >= BIRD_DIST && rand() < 0.25) {
142    return { kind: 'bird', x: g.w, lift: BIRD_LIFTS[Math.floor(rand() * BIRD_LIFTS.length)], shape: 0 }
143  }
144  const kinds = speed >= CLUSTER_SPEED ? CACTI.length : CACTI.length - 1
145  return { kind: 'cactus', x: g.w, lift: 0, shape: Math.floor(rand() * kinds) }
146}
147
148// one tick: the dino rises or falls, the world scrolls left faster the further it has run, a new
149// obstacle arrives past the right edge once the gap is run, and touching one ends the round.
150// A tick moves more than a column at speed, so it runs in sub-steps of at most one column each:
151// nothing slips through the dino between two frames.
152// Without a rand of its own, a tick draws from the game's seed.
153export function tick(g: DinoGame, rand?: () => number): DinoGame {
154  if (g.over) return g
155  let seed = g.seed
156  const draw = rand ?? (() => {
157    const [value, next] = random(seed)
158    seed = next
159    return value
160  })
161  const speed = Math.min(MAX_SPEED, START_SPEED + g.dist * ACCEL)
162  const steps = Math.ceil(speed + BIRD_EXTRA)
163  let moved: DinoGame = { ...g, speed, duck: Math.max(0, g.duck - 1), frame: g.frame + 1 }
164  for (let i = 0; i < steps; i++) {
165    let { y, vy } = moved
166    if (airborne(moved)) {
167      y = Math.max(0, y + vy / steps)
168      vy = y === 0 ? 0 : vy - GRAVITY / steps
169    }
170    const obstacles = moved.obstacles.map(o => ({ ...o, x: o.x - (o.kind === 'bird' ? speed + BIRD_EXTRA : speed) / steps }))
171    moved = { ...moved, y, vy, obstacles }
172    if (hits(moved)) return { ...moved, over: true }
173  }
174  let obstacles = moved.obstacles.filter(o => o.x + obstacleShape(o, 0)[0].length > 0)
175  let gap = g.gap - speed
176  if (gap <= 0) {
177    obstacles = [...obstacles, spawn(g, speed, draw)]
178    gap = MIN_GAP + speed * GAP_PER_SPEED + draw() * GAP_RANDOM
179  }
180  let clouds = g.clouds.map(c => ({ ...c, x: c.x - speed / 4 })).filter(c => c.x + CLOUD[0].length > 0)
181  if (clouds.length < 3 && draw() < 0.01) clouds = [...clouds, { x: g.w, lift: 5 + Math.floor(draw() * 3) }]
182  return { ...moved, dist: g.dist + speed, obstacles, gap, clouds, seed }
183}
184
185// A recorded run: the board's size, the seed its obstacles came from, and each key press that moved
186// the dino as frame * 2 + 0 for a jump or 1 for a duck, in the order pressed. A press at frame f
187// lands before the tick that makes frame f + 1.
188export type Run = { w: number; h: number; seed: number; inputs: number[] }
189
190export const press = (frame: number, kind: 'jump' | 'duck') => frame * 2 + (kind === 'duck' ? 1 : 0)
191
192// the press to record for a key, or undefined when it would not move the dino: a jump mid-air, or a
193// duck the frame already holds
194export function record(inputs: readonly number[], before: DinoGame, after: DinoGame, kind: 'jump' | 'duck'): number | undefined {
195  if (after === before || before.over) return undefined
196  const p = press(before.frame, kind)
197  return inputs[inputs.length - 1] === p ? undefined : p
198}
199
200// plays a recorded run again from its seed and returns the crashed game, or undefined when the record
201// is not a run under these rules: a press out of order or after the crash, or no crash within maxTicks
202export function replay(run: Run, maxTicks: number): DinoGame | undefined {
203  let g = newDino(run.w, run.h, run.seed)
204  let i = 0
205  while (!g.over) {
206    if (g.frame >= maxTicks) return undefined
207    for (; i < run.inputs.length && Math.floor(run.inputs[i] / 2) <= g.frame; i++) {
208      if (Math.floor(run.inputs[i] / 2) < g.frame) return undefined
209      g = run.inputs[i] % 2 === 1 ? duck(g) : jump(g)
210    }
211    g = tick(g)
212  }
213  return i === run.inputs.length ? g : undefined
214}
215
hooks/game/handle.ts 14 lines
1// The player's X handle, shared by the hooks module and the board's handle editor.
2
3// letters, digits and underscores, up to 15, with or without the @
4export const HANDLE = /^@?([A-Za-z0-9_]{1,15})$/
5export const HANDLE_MAX = 15
6
7// one key in the board's editor: a handle character goes on while there is room, backspace takes
8// the last one off, and anything else leaves the draft as it was
9export function editHandle(draft: string, key: string): string {
10  if (key === 'backspace' || key === 'delete') return draft.slice(0, -1)
11  if (/^[A-Za-z0-9_]$/.test(key) && draft.length < HANDLE_MAX) return draft + key
12  return draft
13}
14
hooks/boards/dino.tsx 275 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { CACTI, CLOUD, DINO, DINO_X, MAX_H, MAX_W, MIN_H, MIN_W, TICK_MS, dinoShape, duck, jump, newDino, obstacleShape, press, record, score, tick, type DinoGame, type Shape } from '../game/dino.ts'
4import { DEMO_SEED, autopilot } from '../game/autopilot.ts'
5import { editHandle } from '../game/handle.ts'
6
7// The board: a surface module on the drawing thread, with its own frame clock, keys (after a click
8// gives it focus; Esc gives the prompt back) and mouse.
9//
10// Never name a local `h` in this file: every JSX tag compiles to a call of `h`.
11
12type Entry = { handle: string; score: number }
13// a ticket from the leaderboard: its seed deals a run's obstacles, so the leaderboard can replay it
14type Ticket = { id: string; seed: number }
15// demo: /dino demo, a run from a fixed seed that presses for itself and is never sent
16type Props = { best?: number; done?: number; handle?: string; rank?: number; top?: Entry[]; ticket?: Ticket; demo?: boolean } | undefined
17// at: when the last tick ran, so frames the clock delivers late still move the world on time
18// view: the run, the leaderboard (t) or the handle editor (h), which holds its draft
19// ticket: the id of the ticket the run was dealt from; a run without one stays on this machine
20type State = { game?: DinoGame; playing: boolean; banner: boolean; seenDone: number; cool: number; at: number; view: 'run' | 'top' | 'handle'; draft: string; ticket?: string }
21type Cell = [glyph: string, color?: string]
22
23// a stall longer than this is not caught up: the run would jump ahead into an obstacle
24const MAX_CATCH_UP = 4
25// after a crash, a jump key still held must not start the next round straight away
26const COOL_TICKS = 8
27const clock = () => (typeof Date === 'function' ? Date.now() : 0)
28// the props of the latest call: the key listener is set once, on the first call, and outlives its props
29let latest: Props
30// the run's presses, sent with it when it ends, and the last ticket a run took, which must not deal
31// a second one before the hooks module's next props bring a new ticket
32let inputs: number[] = []
33let usedTicket: string | undefined
34
35export default function Dino(props: Props, surface: ClientSurface<State>) {
36  const { Box, Text } = surface.elements
37  latest = props
38  const size = () => ({
39    cols: Math.max(MIN_W, Math.min(MAX_W, surface.columns - 2)),
40    tall: Math.max(MIN_H, Math.min(MAX_H, surface.rows - 3)),
41  })
42  const start = () => {
43    const s = surface.state
44    if (!s) return
45    const { cols, tall } = size()
46    const fresh = !latest?.demo && latest?.ticket && latest.ticket.id !== usedTicket ? latest.ticket : undefined
47    if (fresh) {
48      usedTicket = fresh.id
49      surface.post({ game: 'dino', started: fresh.id })
50    }
51    const seed = latest?.demo ? DEMO_SEED : fresh ? fresh.seed : Math.floor(Math.random() * 2 ** 32)
52    // the first jump starts the run
53    inputs = [press(0, 'jump')]
54    surface.setState({ ...s, banner: false, playing: true, cool: 0, at: clock(), ticket: fresh?.id, game: jump(newDino(cols, tall, seed)) })
55  }
56  // space, up, Enter or a click: jump, resume a paused run, or start a new one
57  const up = () => {
58    const s = surface.state
59    if (!s || s.cool > 0) return
60    if (!s.game || s.game.over) return start()
61    if (!s.playing) return surface.setState({ ...s, banner: false, playing: true, at: clock() })
62    const game = jump(s.game)
63    const p = record(inputs, s.game, game, 'jump')
64    if (p !== undefined) inputs.push(p)
65    surface.setState({ ...s, banner: false, game })
66  }
67  const down = () => {
68    const s = surface.state
69    if (!s?.game || !s.playing || s.game.over) return
70    const game = duck(s.game)
71    const p = record(inputs, s.game, game, 'duck')
72    if (p !== undefined) inputs.push(p)
73    surface.setState({ ...s, game })
74  }
75
76  if (surface.state === undefined) {
77    surface.setState({ playing: false, banner: false, seenDone: props?.done ?? 0, cool: 0, at: 0, view: 'run', draft: '' })
78    surface.every(TICK_MS, () => {
79      const s = surface.state
80      if (!s) return
81      const now = clock()
82      // as many ticks as the time since the last one holds, so a slow frame clock does not slow the run
83      const due = now && s.at ? Math.min(MAX_CATCH_UP, Math.max(1, Math.round((now - s.at) / TICK_MS))) : 1
84      if (s.cool > 0) return surface.setState({ ...s, at: now, cool: Math.max(0, s.cool - due) })
85      if (!s.game || !s.playing || s.game.over) return
86      let game = s.game
87      for (let i = 0; i < due && !game.over; i++) {
88        const key = latest?.demo ? autopilot(game) : undefined
89        if (key) game = key === 'duck' ? duck(game) : jump(game)
90        game = tick(game)
91      }
92      if (game.over && !latest?.demo) {
93        surface.post(s.ticket
94          ? { game: 'dino', score: score(game), run: { ticket: s.ticket, w: game.w, h: game.h, inputs } }
95          : { game: 'dino', score: score(game) })
96      }
97      // a late tick leaves the remainder for the next one instead of dropping it
98      const at = now ? s.at + due * TICK_MS : 0
99      surface.setState({ ...s, game, at: now && now - at > TICK_MS ? now : at, cool: game.over ? COOL_TICKS : 0 })
100    })
101    surface.onKey(({ key, ctrl, meta }) => {
102      const s = surface.state
103      if (!s) return
104      // the editor takes every key: Enter sends the draft (empty leaves the leaderboard), Tab puts it
105      // away, since Escape never reaches the board
106      if (s.view === 'handle') {
107        if (ctrl || meta) return
108        if (key === 'return') {
109          surface.post({ game: 'dino', handle: s.draft })
110          return surface.setState({ ...s, view: 'run' })
111        }
112        if (key === 'tab') return surface.setState({ ...s, view: 'run' })
113        const draft = editHandle(s.draft, key)
114        if (draft !== s.draft) surface.setState({ ...s, draft })
115        return
116      }
117      // the space bar may arrive as the character or by name
118      const k = key === 'space' ? ' ' : key.toLowerCase()
119      // either view pauses the run; space resumes it once the board is back
120      if (k === 'h') return surface.setState({ ...s, banner: false, playing: false, view: 'handle', draft: latest?.handle ?? '' })
121      if (k === 't' && s.view === 'run') {
122        surface.post({ game: 'dino', top: true })
123        return surface.setState({ ...s, banner: false, playing: false, view: 'top' })
124      }
125      if (s.view === 'top') {
126        if (k === 't' || k === ' ' || k === 'return' || k === 'tab') surface.setState({ ...s, view: 'run' })
127        return
128      }
129      if (k === ' ' || k === 'up' || k === 'w' || k === 'return') up()
130      else if (k === 'down' || k === 's') down()
131      else if (k === 'r') start()
132      else if (k === 'p' && s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing, at: clock() })
133    })
134    surface.onPointer(ev => {
135      const s = surface.state
136      if (ev.type !== 'down' || !s || s.view === 'handle') return
137      if (s.view === 'top') return surface.setState({ ...s, view: 'run' })
138      up()
139    })
140  }
141
142  // the demo starts on its own
143  if (props?.demo && surface.state && !surface.state.game) start()
144
145  // the hooks module bumps props.done when Claude finishes a turn: pause and say so, once per turn
146  const done = props?.done ?? 0
147  if (surface.state && done !== surface.state.seenDone) {
148    surface.setState({ ...surface.state, seenDone: done, banner: true, playing: false })
149  }
150
151  const s = surface.state
152  const g = s?.game
153  const cols = g ? g.w : size().cols
154  const tall = g ? g.h : size().tall
155  const ground = tall - 1
156  const canvas: Cell[][] = Array.from({ length: tall }, () => Array.from({ length: cols }, (): Cell => [' ']))
157  const put = (row: number, col: number, cell: Cell) => {
158    if (row >= 0 && row < tall && col >= 0 && col < cols) canvas[row][col] = cell
159  }
160  // a shape whose left column is x and bottom row sits at height lift; whatever rises past the top is cut
161  const stamp = (shape: Shape, x: number, lift: number, color?: string) => {
162    shape.forEach((line, r) => {
163      const row = ground - lift - (shape.length - r)
164      if (row >= ground) return
165      ;[...line].forEach((ch, c) => { if (ch !== ' ') put(row, x + c, [ch, color]) })
166    })
167  }
168  const write = (row: number, col: number, text: string, color?: string) =>
169    [...text].forEach((ch, i) => put(row, col + i, [ch, color]))
170
171  const scroll = Math.floor(g?.dist ?? 0)
172  for (let col = 0; col < cols; col++) put(ground, col, [((col + scroll) * 7919) % 37 === 0 ? '▂' : '─', 'gray'])
173  for (const c of g?.clouds ?? [{ x: Math.floor(cols * 0.6), lift: 5 }]) stamp(CLOUD, Math.round(c.x), c.lift, 'gray')
174  if (g) {
175    for (const o of g.obstacles) stamp(obstacleShape(o, g.frame), Math.round(o.x), o.lift, o.kind === 'bird' ? 'yellow' : 'green')
176    stamp(dinoShape(g), DINO_X, Math.round(g.y), g.over ? 'red' : undefined)
177  } else {
178    stamp(DINO.run[0], DINO_X, 0)
179    stamp(CACTI[2], Math.floor(cols * 0.7), 0, 'green')
180  }
181
182  const now = g ? score(g) : 0
183  const pad = (n: number) => String(n).padStart(5, '0')
184  // the score flashes for a moment at every hundred, as in the original
185  const flash = now >= 100 && now % 100 < 4
186  const hud = `HI ${pad(Math.max(props?.best ?? 0, now))}  ${pad(now)}`
187  write(0, cols - hud.length - 1, hud, flash ? 'yellowBright' : 'gray')
188  // between runs, the leaderboard down the right, under the score, clear of the dino's rows
189  const board = !g || g.over ? props?.top ?? [] : []
190  const listWidth = 26
191  const showList = board.length > 0 && cols >= 50
192  const listLeft = cols - listWidth - 1
193  // the list's rows run from 1 to the row above the dino's tallest reach
194  const shown = showList ? board.slice(0, Math.max(0, ground - 5)) : []
195  const listBottom = showList ? 1 + shown.length : 0
196  if (showList) {
197    const me = props?.handle?.toLowerCase()
198    write(1, listLeft, props?.handle && props.rank ? `TOP · @${props.handle} #${props.rank}` : 'TOP', 'cyan')
199    shown.forEach((e, i) => {
200      const line = `${String(i + 1).padStart(2)} @${e.handle.padEnd(15)} ${pad(e.score)}`
201      write(2 + i, listLeft, line, e.handle.toLowerCase() === me ? 'yellowBright' : 'gray')
202    })
203  }
204  if (g?.over) {
205    const title = 'G A M E   O V E R'
206    // centred on the whole board; if the list is in the way, on the first row under it, and only
207    // when that would reach the dino's rows, centred in the space left of the list instead
208    let row = Math.max(1, Math.floor((ground - 3) / 2))
209    let col = Math.floor((cols - title.length) / 2)
210    if (showList && row <= listBottom && col + title.length > listLeft) {
211      if (listBottom + 1 < ground - 3) row = listBottom + 1
212      else col = Math.max(0, Math.floor((listLeft - title.length) / 2))
213    }
214    write(row, Math.max(0, col), title, 'red')
215  }
216
217  // the leaderboard and the handle editor take the sky over the ground
218  const clear = () => { for (let row = 0; row < ground; row++) canvas[row].fill([' ']) }
219  if (s?.view === 'top') {
220    clear()
221    const list = props?.top ?? []
222    const me = props?.handle?.toLowerCase()
223    const left = Math.max(0, Math.floor((cols - listWidth) / 2))
224    write(0, left, 'L E A D E R B O A R D', 'cyan')
225    // rows 1 to the one above the ground; a player ranked below them takes the last one
226    const room = ground - 1
227    const mine = me ? list.findIndex(e => e.handle.toLowerCase() === me) : -1
228    const below = !!props?.handle && !!props.rank && (mine < 0 || mine >= room)
229    const entry = (place: number, who: string, points: number) => `${String(place).padStart(2)} @${who.padEnd(15)} ${pad(points)}`
230    if (list.length === 0) write(1, left, 'nobody has run yet', 'gray')
231    list.slice(0, Math.max(0, below ? room - 1 : room))
232      .forEach((e, i) => write(1 + i, left, entry(i + 1, e.handle, e.score), i === mine ? 'yellowBright' : 'gray'))
233    if (below) write(room, left, entry(props?.rank ?? 0, props?.handle ?? '', props?.best ?? 0), 'yellowBright')
234  }
235  if (s?.view === 'handle') {
236    clear()
237    const top = Math.max(0, Math.floor((ground - 4) / 2))
238    const left = Math.max(0, Math.floor((cols - 32) / 2))
239    write(top, left, 'YOUR X HANDLE', 'cyan')
240    write(top + 1, left, `@${s.draft}`, 'whiteBright')
241    write(top + 1, left + 1 + s.draft.length, '▌', 'cyan')
242    write(top + 2, left, 'letters, digits and _, up to 15', 'gray')
243    write(top + 3, left, s.draft ? 'Enter saves · Tab cancels' : 'Enter with none leaves the board', 'gray')
244  }
245
246  // one styled span per stretch of cells sharing a color
247  const lines = canvas.map(cells => {
248    const spans: Cell[] = []
249    for (const [glyph, color] of cells) {
250      const last = spans[spans.length - 1]
251      if (last && last[1] === color) last[0] += glyph
252      else spans.push([glyph, color])
253    }
254    return <Text>{spans.map(([t, c]) => <Text color={c}>{t}</Text>)}</Text>
255  })
256
257  const join = ` · t top · h ${props?.handle ? 'handle' : 'sets your handle'}`
258  const text = s?.view === 'handle' ? 'dino · type your X handle · Enter saves · Tab cancels'
259    : s?.view === 'top' ? `dino · leaderboard${props?.handle ? ` · you are @${props.handle}` : ''} · t, space or click goes back · h handle`
260    : props?.demo ? `dino · demo${g?.over ? ` · crashed at ${now}` : ''} · /dino plays for real`
261    : !g ? `dino · click here, then space or ↑ to jump, ↓ to duck${join}`
262    : g.over ? `dino · crashed at ${now} · space or click runs again${join}`
263    : !s?.playing ? `dino · paused at ${now} · space resumes${join}`
264    : 'dino · space/↑ jump · ↓ duck · p pause · r restart'
265  const border = s?.view === 'handle' || s?.view === 'top' ? 'cyan' : g?.over ? 'red' : 'gray'
266  return (
267    <Box flexDirection="column">
268      <Box flexDirection="column" borderStyle="round" borderColor={border} width={cols + 2}>{lines}</Box>
269      {s?.banner
270        ? <Text color="yellow" bold wrap="truncate-end">{`● Claude is done · ${text}`}</Text>
271        : <Text dimColor wrap="truncate-end">{text}</Text>}
272    </Box>
273  )
274}
275
hooks/game/autopilot.ts 16 lines
1import { DINO_X, type DinoGame } from './dino.ts'
2
3// A player that reads the next obstacle, for /dino demo: it jumps whatever is on the ground or low,
4// ducks the bird at head height and lets the high one pass over. It reacts late at speed, so its
5// runs end.
6
7// on a 100 by 13 board, a run of about 27 seconds that meets two birds before it crashes
8export const DEMO_SEED = 993
9
10export function autopilot(g: DinoGame): 'jump' | 'duck' | undefined {
11  const near = g.obstacles.find(o => o.x + 5 > DINO_X && o.x - DINO_X < 3 + g.speed * 3)
12  if (!near) return undefined
13  if (near.kind === 'bird') return near.lift === 2 ? 'duck' : near.lift === 3 ? undefined : 'jump'
14  return 'jump'
15}
16