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…

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.

Needs function hooks (early access) and an interactive terminal.
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.
| Command | What it does |
|---|---|
/dino | open or close the board |
/dino auto | open the board whenever Claude starts working |
/dino top | show the leaderboard |
/dino handle @you | join the leaderboard under your X handle |
/dino demo | watch 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.
Press h on the board (or run /dino handle @you) and type your X/Twitter handle.
bun test
MIT
hooks/register.tsx 261 lines1/* @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}
261hooks/game/dino.ts 215 lines1// 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}
215hooks/game/handle.ts 14 lines1// 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}
14hooks/boards/dino.tsx 275 lines1/* @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}
275hooks/game/autopilot.ts 16 lines1import { 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