A T-Rex runner in a pane, inspired by Chrome's offline game: /dino, then space to jump, s to duck and p to pause while Claude works

A T-Rex runner you can play in a pane inside Claude Code, inspired by Chrome's offline game.
Type /dino, press space, and jump the cacti while Claude works.
<img src="docs/dino.png" width="604" alt="Dino's pane: the dino in the air over a crate a Bash call sent, a bird and two clouds ahead, the best score and the score at the top right">
claude plugin marketplace add barisdemirhan/claude-mods
claude plugin install dino@claude-mods
Restart Claude Code, then run /dino.
The same two steps work from inside a session with /plugin marketplace add barisdemirhan/claude-mods and /plugin install dino@claude-mods.
claude-mods is one marketplace for all of these mods, so its first line is needed once for the lot.
claude-dinoNothing has to change. This repository is still a marketplace of its own, and dino@claude-dino goes on getting updates.
Moving to claude-mods is a new install as Claude Code sees it, and it starts with an empty store: without your best score, your stats and, if you are on the global top, the secret that makes your name there yours. To bring them along, copy the store's file to its new name before you install, with no Claude Code session open:
cp -R ~/.claude/plugins/store ~/claude-store-backup
cp ~/.claude/plugins/store/dino_claude-dino-98d7fb92c86a.json ~/.claude/plugins/store/dino_claude-mods-aeb35b495c33.json
claude plugin marketplace add barisdemirhan/claude-mods
claude plugin install dino@claude-mods
claude plugin uninstall dino@claude-dino
The two file names are where Claude Code 2.1.288 keeps a mod's store. They are Claude Code's own and may change with it. The first line keeps a copy of every store in ~/claude-store-backup, to put back if the move goes wrong; delete it once the mod shows what it showed before. Uninstalling leaves the old store's file where it is. Keep one of the two installs, not both: with both on, every hook runs twice.
| Key | Action |
|---|---|
space, w, k, ↑ | Jump, start a run, run again after a game over, or run on after a pause |
s, j, ↓ | Duck, or drop faster while in the air |
p | Pause, and run on again |
t | Show the global top over the field, between runs |
esc | Give the keys back to the prompt |
ctrl+x tab | Give the keys back to the game |
Birds show up from 150 points, the run speeds up as it goes, and night falls every 700 points. Every 100 points the score blinks. Your best score is kept between sessions and shows at the right end of the hint line under the prompt.
| Command | What it does |
|---|---|
/dino or /dino play | Opens the game |
/dino stop | Closes the game. The mini dino and your best score under the prompt stay |
/dino close | Takes it all away, in every open session within two seconds: the game, the mini dino, your best score under the prompt and the sounds. /dino exit and /dino quit do the same. Your runs, best and settings are kept, and /dino brings it all back as it was |
/dino mini | Turns the mini dino on: a small one that plays itself above the prompt while Claude works, hopping the cacti and ducking the birds under the clouds. /dino mini off turns it off. Every open session follows within two seconds, as it does for /dino sound and /dino hi |
/dino sound | Turns the sounds off or on. /dino sound on and /dino sound off say which |
/dino hi | Takes your best score off the line under the prompt, or puts it back. /dino hi on and /dino hi off say which |
/dino stats | Your runs so far, your best and your average |
/dino top | The global top ten, and where you stand if you are on it |
/dino name <name> | Joins the global top under that name, or changes the name you have there. /dino name says whether you are on it, /dino name off stops the game asking |
/dino leave | Takes you off the global top and deletes your runs there |
The global top is a leaderboard everybody who joins shares. It is off until you join: after a lost run the keys field asks for a name, and enter joins with it. A space runs again instead, enter alone shows the board, and /dino name off stops the question for good. /dino name <name> joins at any time.
A name takes 2 to 16 letters, digits, - or _, and belongs to whoever took it first. What proves it is yours is a secret kept in the plugin's store on this machine: on another machine, or after the store is cleared, the name is taken and you need a new one.
Once you are on it, every run that beats your best there is sent by itself, and a toast says where it put you. A lost run then shows your name and your place, and one past your old best says by how much.
Scores are not taken on trust. The game never sends a score. It sends the seed the run started on and its moves, and the server plays the run again with the game's own rules (hooks/sim.ts, the same file) to see what it comes to. A made-up score, a changed rule or an edited run does not play out, and is refused.
What that cannot tell is who pressed the keys. A program that plays the real game well gets a real score. Against that the server holds back runs whose jumps are too even to be a person's until they have been looked over, takes no run longer than half an hour, and keeps every run's moves so that one can be watched and removed. Read the board as a bit of fun, not as a referee.
Two more things to know:
/dino does not show up after installing, the switch may still be off for you.The mod registers one slash command, draws one pane and, if you turn it on, one band above the prompt. It reads no files and runs no processes. It never changes a prompt, a tool call or a tool's result.
What it reads. While a run is on, two things of each tool call Claude makes: the tool's name, to show it in the game, and whether the call failed. It reads nothing of the call's arguments or output, and keeps neither fact past the session.
What it sends. Nothing, unless you use the global top. Then it talks to one server, claude-dino-board.barisdemirhan.workers.dev, a Cloudflare Worker the author runs, through Claude Code's own $.http.fetch:
/dino top, and t in the game, ask for the board. If you have joined, they send your id along to learn where you stand.What the server keeps of these, what others see and for how long is in PRIVACY.md. /dino leave deletes your name and your runs there.
What reaches Claude. What /dino answers is a row of the conversation, as any command's output is, and Claude reads it with the rest: /dino stats shows your runs, your best and your average, and /dino top the board with your name and your place on it. The mod puts nothing else into the conversation and gives Claude no tool.
What it keeps. Four things, in the plugin's own Claude Code store, one JSON file under ~/.claude/plugins/store/ on your disk: your best score, how many runs and points you have in all, your settings (sound, mini, whether your best score shows under the prompt and whether /dino close put it all away) and, once you join the global top, your name, id and secret there.
Sound. It plays three short sounds from its own sounds/ folder, through Claude Code's player.
PRIVACY.md is the same as a privacy policy, with how to take your data off.
Its hooks, all in hooks/register.tsx:
session.start registers the /dino command and loads your settings and best score, then passes the event on unchanged. From then on the session reads your settings from the store every two seconds, to follow what another open session switched.command.run answers only the /dino command. Other commands never reach it.ui.render draws only the mod's own pane, and the mini band while it is on and Claude is working. It also adds your best score after the hint line under the prompt, leaving the hint itself as it is.ui.message acts only on what the mod's own game posts: it counts finished runs, keeps the best score, plays the sounds and, if you are on the global top, sends a run that beats your best there. Any other message is passed on untouched.ui.close notes that the mod's own pane closed, and passes every close on.tool.call counts Claude's calls while a run is on, as described above, and passes each call and its result on untouched.turn.complete and classic.Notification pause a run that is on and show the toast: the first when Claude's turn ends, the second when Claude Code notifies you that it waits on a permission or a question. Both pass the event on unchanged and decide nothing.The files under tests/ run only under claude plugin test. They mount the pane in the test harness and are never loaded in a session.
git clone https://github.com/barisdemirhan/claude-dino
claude plugin validate claude-dino
claude plugin test claude-dino
claude --plugin-dir claude-dino
The mod is five files: hooks/register.tsx holds the hooks, hooks/dino.tsx draws the game, hooks/sim.ts is the run's rules, hooks/board.ts is what the mod says to the leaderboard and hooks/mini.tsx is the mini dino.
worker/ is a Cloudflare Worker over a D1 database. It imports hooks/sim.ts, so the server and the game cannot disagree on the rules. A change to how a run steps makes older runs play out differently: give RULES in hooks/sim.ts a new number and deploy the Worker with the release.
To run a board of your own:
cd worker
wrangler d1 create claude-dino-board # put the id it prints in wrangler.toml
wrangler d1 migrations apply claude-dino-board --remote
wrangler deploy
wrangler secret put ADMIN_TOKEN # any long random string
Then point BOARD in hooks/board.ts at your Worker's address.
The keeper's routes take Authorization: Bearer <ADMIN_TOKEN>:
| Route | What it does |
|---|---|
GET /admin/flagged | The runs held back as too even to be a person's |
POST /admin/clear {"run": 12} | Counts a held run after all |
POST /admin/ban {"name": "x"} | Takes a player off the board and refuses their runs; "banned": false undoes it |
POST /admin/remove {"name": "x"} | Deletes a player and their runs |
From the same marketplace, claude-mods:
MIT
hooks/register.tsx 864 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { DinoAlert, DinoBoard, DinoInput, DinoSettings } from '../types'
5import {
6 NOBODY,
7 enrolled,
8 nameText,
9 named,
10 placed,
11 playersOf,
12 posted,
13 ranked,
14 refusalOf,
15 rowsOf,
16 runBody,
17 sentText,
18 toPlayer,
19 topPath,
20 topText,
21 urlOf,
22} from './board'
23import type { Player, Run, Told } from './board'
24
25type Stats = { runs: number; points: number }
26type Post = { event: string; score: number; seed: number; log: string }
27
28const PANE = 'dino'
29const GAME = 'game'
30const KEYS = 'keys'
31const MINI = 'mini'
32const HI = 'hi'
33const STATS = 'stats'
34const SETTINGS = 'settings'
35const PLAYER = 'player'
36const ROWS = 16
37const MINI_ROWS = 4
38// Every session looks this often at what another may have switched.
39const TICK_MS = 2000
40// Cells kept clear after the best score: the hint line draws marks its text
41// does not count, and a tail that overruns the row is cut.
42const HINT_MARGIN = 6
43const MIN_HINT_GAP = 2
44const JUMP_KEYS = [' ', 'w', 'k']
45const DUCK_KEYS = ['s', 'j']
46const PAUSE_KEYS = ['p']
47const TOP_KEYS = ['t']
48// What the game posts while a run is on; any other post ends or holds it.
49const RUNNING = ['start', 'resume', 'jump', 'point']
50const CLIPS: Readonly<Record<string, string>> = {
51 start: 'sounds/jump.wav',
52 jump: 'sounds/jump.wav',
53 point: 'sounds/point.wav',
54 over: 'sounds/hit.wav',
55}
56const ALERTS = {
57 done: 'Claude is done · dino paused',
58 ask: 'Claude needs you · dino paused',
59} as const
60// The notices Claude Code sends when it waits on the person's answer.
61const WAITING = ['permission_prompt', 'elicitation_dialog']
62const USAGE =
63 'Usage: /dino [play], /dino stop, /dino close, /dino mini [on|off], /dino sound [on|off], /dino hi [on|off], /dino stats, /dino top, /dino name [<name>|off] or /dino leave.'
64const PLAY_WORDS = ['', 'play']
65const STOP_WORDS = ['stop', 'stop-play']
66// Other words for closing it all: the game, the mini dino, the best score
67// under the prompt and the sounds.
68const CLOSE_WORDS = ['close', 'exit', 'quit']
69const SWITCH: Readonly<Record<string, boolean>> = { on: true, off: false }
70
71const input = atom({ plugin: 'dino', key: 'input' } as const, {
72 jumps: 0,
73 ducks: 0,
74 pauses: 0,
75 tops: 0,
76 typed: 0,
77})
78const feed = atom({ plugin: 'dino', key: 'feed' } as const, {
79 tools: 0,
80 fails: 0,
81 tool: '',
82})
83const alert = atom({ plugin: 'dino', key: 'alert' } as const, {
84 count: 0,
85 reason: 'done',
86})
87const settings = atom({ plugin: 'dino', key: 'settings' } as const, {
88 isMini: false,
89 isMuted: false,
90 isHiHidden: false,
91 isClosed: false,
92})
93const best = atom({ plugin: 'dino', key: 'best' } as const, 0)
94// The lost run a name is being asked for, to put it on the global top: 0
95// while nothing is asked.
96const offer = atom({ plugin: 'dino', key: 'offer' } as const, { score: 0 })
97// The global top as last fetched for the game to show, and the person's
98// name and rank on it: no name until they join.
99const NO_BOARD: DinoBoard = { state: 'idle', top: [], players: 0 }
100const board = atom({ plugin: 'dino', key: 'board' } as const, NO_BOARD)
101const standing = atom({ plugin: 'dino', key: 'standing' } as const, {
102 name: '',
103 rank: 0,
104})
105
106const toScore = (value: unknown): number =>
107 typeof value === 'number' && Number.isFinite(value) && value > 0
108 ? Math.floor(value)
109 : 0
110
111const digits = (score: number): string => String(score).padStart(5, '0')
112
113const toStats = (value: unknown): Stats =>
114 typeof value === 'object' && value !== null
115 ? {
116 runs: toScore('runs' in value ? value.runs : 0),
117 points: toScore('points' in value ? value.points : 0),
118 }
119 : { runs: 0, points: 0 }
120
121const toSettings = (value: unknown): DinoSettings =>
122 typeof value === 'object' && value !== null
123 ? {
124 isMini: 'isMini' in value && value.isMini === true,
125 isMuted: 'isMuted' in value && value.isMuted === true,
126 isHiHidden: 'isHiHidden' in value && value.isHiHidden === true,
127 isClosed: 'isClosed' in value && value.isClosed === true,
128 }
129 : { isMini: false, isMuted: false, isHiHidden: false, isClosed: false }
130
131const toPost = (value: unknown): Post =>
132 typeof value === 'object' && value !== null
133 ? {
134 event:
135 'event' in value && typeof value.event === 'string' ? value.event : '',
136 score: toScore('score' in value ? value.score : 0),
137 seed: toScore('seed' in value ? value.seed : 0),
138 log: 'log' in value && typeof value.log === 'string' ? value.log : '',
139 }
140 : { event: '', score: 0, seed: 0, log: '' }
141
142/**
143 * The counts as kept, each a number: a value written before a reload stays
144 * in the session's state, and may predate a count this version added.
145 */
146const toInput = (kept: Partial<DinoInput>): DinoInput => ({
147 jumps: toScore(kept.jumps),
148 ducks: toScore(kept.ducks),
149 pauses: toScore(kept.pauses),
150 tops: toScore(kept.tops),
151 typed: toScore(kept.typed),
152})
153
154const keyed = (kept: DinoInput, key: string): DinoInput => {
155 const now = toInput(kept)
156
157 return {
158 jumps: now.jumps + (JUMP_KEYS.includes(key) ? 1 : 0),
159 ducks: now.ducks + (DUCK_KEYS.includes(key) ? 1 : 0),
160 pauses: now.pauses + (PAUSE_KEYS.includes(key) ? 1 : 0),
161 tops: now.tops + (TOP_KEYS.includes(key) ? 1 : 0),
162 typed: now.typed + 1,
163 }
164}
165
166/** Everything the game module draws from, as its props. */
167const propsOf = async ($: EngineInterface) => {
168 const { jumps, ducks, pauses, tops } = toInput(await read($, input))
169 const { tools, fails, tool } = await read($, feed)
170 const { count, reason } = await read($, alert)
171 const { name, rank } = await read($, standing)
172 const { state, top, players } = await read($, board)
173 const hi = toScore(await $.store.get(HI))
174
175 return {
176 hi,
177 jumps,
178 ducks,
179 pauses,
180 tops,
181 tools,
182 fails,
183 tool,
184 alerts: count,
185 alert: reason,
186 name,
187 rank,
188 isAsked: (await read($, offer)).score > 0,
189 board: state,
190 top,
191 players,
192 }
193}
194
195/** Counts a finished run and keeps the best score. */
196const record = async ($: EngineInterface, score: number): Promise<void> => {
197 const stats = toStats(await $.store.get(STATS))
198 const hi = Math.max(score, toScore(await $.store.get(HI)))
199 await $.store.set(HI, hi)
200 await $.store.set(STATS, {
201 runs: stats.runs + 1,
202 points: stats.points + score,
203 })
204 await update($, best, () => hi)
205}
206
207const playerOf = async ($: EngineInterface): Promise<Player> =>
208 toPlayer(await $.store.get(PLAYER))
209
210/** Keeps the player, and hands the game their name and rank to show. */
211const filed = async ($: EngineInterface, player: Player): Promise<void> => {
212 await $.store.set(PLAYER, player)
213 await update($, standing, () => ({ name: player.name, rank: player.rank }))
214}
215
216/**
217 * Asks the leaderboard's server, and answers its status and what it said:
218 * status 0 when it could not be reached or said nothing a board would say.
219 */
220const asked = async (
221 $: EngineInterface,
222 path: string,
223 body?: Record<string, unknown>,
224): Promise<{ status: number; said: unknown }> => {
225 try {
226 const { status, text } = await $.http.fetch(
227 urlOf(path),
228 body === undefined ? undefined : posted(body),
229 )
230 const said: unknown = JSON.parse(text)
231
232 return { status, said }
233 } catch {
234 return { status: 0, said: undefined }
235 }
236}
237
238/**
239 * Takes `name` on the global top for this person, or changes the name they
240 * have there. The first time, it makes the id and the secret their runs go
241 * under, and keeps them once the server has taken the name.
242 */
243const joined = async ($: EngineInterface, name: string): Promise<Told> => {
244 const player = enrolled(await playerOf($))
245 const { status, said } = await asked($, '/players', {
246 id: player.id,
247 key: player.key,
248 name,
249 })
250
251 if (status !== 200) {
252 return { isDone: false, text: refusalOf(said) }
253 }
254
255 const taken = named(player, said)
256 await filed($, taken)
257
258 return {
259 isDone: true,
260 text: `You are on the global top as ${taken.name}. Your best runs go up from here on.`,
261 }
262}
263
264/** Sends a run for the server to play again and count. */
265const sent = async ($: EngineInterface, run: Run): Promise<Told> => {
266 const player = await playerOf($)
267 const { status, said } = await asked($, '/runs', runBody(player, run))
268
269 if (status !== 200) {
270 return { isDone: false, text: refusalOf(said) }
271 }
272
273 await filed($, ranked(player, run, said))
274
275 return { isDone: true, text: sentText(said) }
276}
277
278/** `/dino leave`: takes this person and their runs off the global top. */
279const leftText = async ($: EngineInterface): Promise<string> => {
280 const player = await playerOf($)
281
282 if (player.name === '') {
283 return 'You are not on the global top.'
284 }
285
286 const { status, said } = await asked($, '/leave', {
287 id: player.id,
288 key: player.key,
289 })
290
291 // A player the server no longer knows is as gone as one it just removed.
292 if (status !== 200 && status !== 403) {
293 return refusalOf(said)
294 }
295
296 await filed($, { ...NOBODY, isOff: true })
297
298 return 'You are off the global top, and your runs there are deleted.'
299}
300
301/** `/dino name off`: the game stops asking for a name after a lost run. */
302const declinedText = async ($: EngineInterface): Promise<string> => {
303 const player = await playerOf($)
304
305 if (player.name !== '') {
306 return `You are on the global top as ${player.name}. /dino leave takes you off it.`
307 }
308
309 await $.store.set(PLAYER, { ...player, isOff: true })
310
311 return 'Dino will not ask for a name again. /dino name <name> joins the global top.'
312}
313
314/**
315 * Fetches the global top for the game to show. What it showed before stays
316 * up while the server is asked, and after a silence.
317 */
318const boarded = async ($: EngineInterface): Promise<void> => {
319 await update($, board, (now): DinoBoard => ({ ...now, state: 'asking' }))
320 const player = await playerOf($)
321 const { status, said } = await asked($, topPath(player))
322 const top = rowsOf(said)
323
324 if (status !== 200 || top === undefined) {
325 await update($, board, (now): DinoBoard => ({ ...now, state: 'silent' }))
326
327 return
328 }
329
330 await update(
331 $,
332 board,
333 (): DinoBoard => ({ state: 'ready', top, players: playersOf(said) }),
334 )
335
336 if (player.name !== '') {
337 await filed($, placed(player, said))
338 }
339}
340
341/**
342 * What a lost run means for the global top. A player's run that beats the
343 * one they have there is sent; a run of somebody who is not on it, and has
344 * not said to be left alone, opens the offer of a name.
345 */
346const topped = async ($: EngineInterface, run: Run): Promise<void> => {
347 const player = await playerOf($)
348
349 if (player.name !== '') {
350 if (run.score > player.best) {
351 $.ui.toast((await sent($, run)).text)
352 }
353 } else if (!player.isOff) {
354 await update($, offer, () => ({ score: run.score }))
355 }
356}
357
358/**
359 * Joins the global top under `name` and sends `run`, the latest one, if it
360 * beats what the player has there. Answers whether the name was taken, and
361 * what to tell the person.
362 */
363const join = async (
364 $: EngineInterface,
365 name: string,
366 run: Run | undefined,
367): Promise<Told> => {
368 const told = await joined($, name)
369
370 if (!told.isDone || run === undefined) {
371 return told
372 }
373
374 return run.score > (await playerOf($)).best
375 ? { isDone: true, text: (await sent($, run)).text }
376 : told
377}
378
379const play = async ($: EngineInterface, event: string): Promise<void> => {
380 const asset = CLIPS[event]
381 const { isMuted, isClosed } = await read($, settings)
382
383 if (asset !== undefined && !isMuted && !isClosed) {
384 // A machine with no player has no sound; the game runs on without it.
385 await $.audio.play({ asset }, { gain: 0.5 }).catch(() => undefined)
386 }
387}
388
389const statsText = async ($: EngineInterface): Promise<string> => {
390 const { runs, points } = toStats(await $.store.get(STATS))
391 const hi = toScore(await $.store.get(HI))
392
393 if (runs === 0) {
394 return hi > 0
395 ? `Dino: best ${digits(hi)}. Runs are counted from this version on.`
396 : 'Dino: no runs yet. /dino opens the game.'
397 }
398
399 const average = Math.round(points / runs)
400 const counted = `${runs} ${runs === 1 ? 'run' : 'runs'}`
401
402 return `Dino: ${counted} · best ${digits(hi)} · average ${average} · ${points} points in all`
403}
404
405/**
406 * Changes a setting for this session and keeps it for the next ones, and for
407 * the others open now, which take it up at their next look.
408 */
409const stored = async (
410 $: EngineInterface,
411 change: Partial<DinoSettings>,
412): Promise<DinoSettings> => {
413 // Closed is every part of the dino away together: a change that shows one
414 // of them again leaves it closed no longer.
415 const changed =
416 change.isClosed === undefined &&
417 (change.isMini === true || change.isHiHidden === false)
418 ? { ...change, isClosed: false }
419 : change
420 // Another session may have switched the rest since this one last looked.
421 const next = { ...toSettings(await $.store.get(SETTINGS)), ...changed }
422 await update($, settings, () => next)
423 await $.store.set(SETTINGS, next)
424
425 return next
426}
427
428/**
429 * Takes up what another session switched. The settings are one for every
430 * session, kept in the store, and a `/dino close` taken up from there closes
431 * the game here too. Answers whether it did.
432 */
433const synced = async ($: EngineInterface): Promise<boolean> => {
434 const shared = toSettings(await $.store.get(SETTINGS))
435 const held = await read($, settings)
436 const isHeld =
437 held.isMini === shared.isMini &&
438 held.isMuted === shared.isMuted &&
439 held.isHiHidden === shared.isHiHidden &&
440 held.isClosed === shared.isClosed
441
442 if (isHeld) {
443 return false
444 }
445
446 await update($, settings, () => shared)
447
448 if (!shared.isClosed || held.isClosed) {
449 return false
450 }
451
452 await $.ui.close({ id: PANE })
453
454 return true
455}
456
457/** True while the mini dino is on, and not closed with the rest. */
458const isMiniShown = (now: DinoSettings): boolean => now.isMini && !now.isClosed
459
460/** True while the best score shows under the prompt: kept, and not closed. */
461const isHiShown = (now: DinoSettings): boolean =>
462 !now.isHiHidden && !now.isClosed
463
464/** `/dino mini [on|off]`: the mini dino on, unless the word says off. */
465const miniText = async ($: EngineInterface, word: string): Promise<string> => {
466 const isMini = word === '' ? true : SWITCH[word]
467
468 if (isMini === undefined) {
469 return USAGE
470 }
471
472 await stored($, { isMini })
473
474 return isMini
475 ? 'Mini dino is on: it plays itself above the prompt while Claude works.'
476 : 'Mini dino is off.'
477}
478
479/** `/dino sound [on|off]`: the word's way, or the other way with no word. */
480const soundText = async ($: EngineInterface, word: string): Promise<string> => {
481 const isOn = word === '' ? (await read($, settings)).isMuted : SWITCH[word]
482
483 if (isOn === undefined) {
484 return USAGE
485 }
486
487 await stored($, { isMuted: !isOn })
488
489 return `Dino sound is ${isOn ? 'on' : 'off'}.`
490}
491
492/** `/dino hi [on|off]`: the word's way, or the other way with no word. */
493const hiText = async ($: EngineInterface, word: string): Promise<string> => {
494 const isOn = word === '' ? !isHiShown(await read($, settings)) : SWITCH[word]
495
496 if (isOn === undefined) {
497 return USAGE
498 }
499
500 await stored($, { isHiHidden: !isOn })
501
502 return isOn
503 ? 'Your best score shows under the prompt.'
504 : 'Your best score is off the line under the prompt.'
505}
506
507/**
508 * `/dino close`: the game, the mini dino and the best score under the prompt
509 * away together, and so the sounds, where `/dino stop` closes only the game.
510 * `/dino` brings them back as they were; the other sessions follow at their
511 * next look at the store.
512 */
513const closedText = async ($: EngineInterface): Promise<string> => {
514 await $.ui.close({ id: PANE })
515 await stored($, { isClosed: true })
516
517 return 'Dino is closed: the game, the mini dino, your best score under the prompt and the sounds are away. /dino brings them back.'
518}
519
520/** Holds the run and says why: Claude is done, or waits on the person. */
521const alerted = async (
522 $: EngineInterface,
523 reason: DinoAlert['reason'],
524): Promise<void> => {
525 await update($, alert, now => ({ count: now.count + 1, reason }))
526 $.ui.toast(ALERTS[reason])
527}
528
529/**
530 * A change of the keys field. While a name is asked for, the field takes
531 * the name, and only a space with nothing before it is still a move: the
532 * one that runs again.
533 */
534const typed = async ($: EngineInterface, value: string): Promise<void> => {
535 const isAsked = (await read($, offer)).score > 0
536
537 if (!isAsked) {
538 await update($, input, now => keyed(now, value.at(-1)?.toLowerCase() ?? ''))
539 } else if (value !== '' && value.trim() === '') {
540 await update($, input, now => keyed(now, ' '))
541 }
542}
543
544/**
545 * Enter in the keys field: a jump. While a name is asked for, the name
546 * joins the global top with the latest run, and enter alone shows the top.
547 */
548const entered = async (
549 $: EngineInterface,
550 value: string,
551 latest: Run | undefined,
552): Promise<void> => {
553 const name = value.trim()
554 const isAsked = (await read($, offer)).score > 0
555
556 if (!isAsked || name === '') {
557 await update($, input, now => keyed(now, isAsked ? 't' : ' '))
558
559 return
560 }
561
562 const told = await join($, name, latest)
563 $.ui.toast(told.text)
564
565 if (told.isDone) {
566 await update($, offer, () => ({ score: 0 }))
567 // A key that is no move: it only empties the field of the name.
568 await update($, input, now => keyed(now, ''))
569 }
570}
571
572export const register: Register = on => {
573 // Whether a run is on, as the game last posted: Claude's work reaches the
574 // game only then, and nothing else of this module looks at it.
575 let isRunning = false
576 // The latest finished run, kept for a name that comes after it.
577 let latest: Run | undefined
578 // The look at the store for what another session switched.
579 let ticker: Timer | undefined
580
581 on('session.start', async ($, e, next) => {
582 await $.command.register({
583 name: 'dino',
584 description: 'Play a T-Rex runner in a pane while Claude works',
585 argumentHint: '[play|stop|close|mini|sound|hi|stats|top|name|leave]',
586 })
587 const saved = toSettings(await $.store.get(SETTINGS))
588 const hi = toScore(await $.store.get(HI))
589 const { name, rank } = await playerOf($)
590 await update($, settings, () => saved)
591 await update($, best, () => hi)
592 await update($, standing, () => ({ name, rank }))
593 // A close another session made ends a run here, as one made here does.
594 ticker ??= $.clock.every(TICK_MS, async () => {
595 if (await synced($)) {
596 isRunning = false
597 }
598 })
599
600 return next(e)
601 })
602
603 on('command.run', { command: 'dino' }, async ($, e) => {
604 const [verb = '', word = '', ...rest] = e.args
605 .trim()
606 .toLowerCase()
607 .split(/\s+/)
608
609 if (rest.length > 0) {
610 return { text: USAGE }
611 }
612
613 if (verb === 'mini') {
614 return { text: await miniText($, word) }
615 }
616
617 if (verb === 'sound') {
618 return { text: await soundText($, word) }
619 }
620
621 if (verb === 'hi') {
622 return { text: await hiText($, word) }
623 }
624
625 if (verb === 'name' && word === 'off') {
626 await update($, offer, () => ({ score: 0 }))
627
628 return { text: await declinedText($) }
629 }
630
631 if (verb === 'name' && word !== '') {
632 // The name as typed: the words above are lowered to match commands.
633 const told = await join($, e.args.trim().split(/\s+/)[1] ?? '', latest)
634
635 if (told.isDone) {
636 await update($, offer, () => ({ score: 0 }))
637 }
638
639 return { text: told.text }
640 }
641
642 if (word !== '') {
643 return { text: USAGE }
644 }
645
646 if (verb === 'name') {
647 return { text: nameText(await playerOf($)) }
648 }
649
650 if (verb === 'top') {
651 const { status, said } = await asked($, topPath(await playerOf($)))
652
653 return { text: topText(status, said) }
654 }
655
656 if (verb === 'leave') {
657 return { text: await leftText($) }
658 }
659
660 if (verb === 'stats') {
661 return { text: await statsText($) }
662 }
663
664 if (STOP_WORDS.includes(verb)) {
665 isRunning = false
666 await $.ui.close({ id: PANE })
667
668 return {
669 text: 'The game is closed. /dino opens it again, and /dino close takes the mini dino and your best score away too.',
670 }
671 }
672
673 if (CLOSE_WORDS.includes(verb)) {
674 isRunning = false
675
676 return { text: await closedText($) }
677 }
678
679 if (!PLAY_WORDS.includes(verb)) {
680 return { text: USAGE }
681 }
682
683 // The game brings back what `/dino close` took away, as it was.
684 await stored($, { isClosed: false })
685
686 const opened = await $.ui.open({
687 id: PANE,
688 title: 'Dino',
689 focus: true,
690 rows: ROWS + 2,
691 })
692
693 return {
694 text: opened.isPlaced
695 ? 'Dino is open: space to jump, s to duck, p to pause.'
696 : `Dino is open but has no room to show: ${opened.reason}`,
697 }
698 })
699
700 on('ui.message', async ($, e, next) => {
701 if (e.requestId !== PANE || e.element !== GAME) {
702 return next(e)
703 }
704
705 const post = toPost(e.data)
706 isRunning = RUNNING.includes(post.event)
707
708 if (post.event === 'over') {
709 latest = { seed: post.seed, log: post.log, score: post.score }
710 await record($, post.score)
711 void topped($, latest)
712 } else if (post.event === 'top') {
713 void boarded($)
714 } else if (post.event === 'start' && (await read($, offer)).score > 0) {
715 // The person ran again instead of taking a name.
716 await update($, offer, () => ({ score: 0 }))
717 }
718
719 void play($, post.event)
720
721 return { props: await propsOf($) }
722 })
723
724 on('ui.close', ($, e, next) => {
725 if (e.id === PANE) {
726 isRunning = false
727 }
728
729 return next(e)
730 })
731
732 // Claude's tool calls feed a run its obstacles. The call itself is passed on
733 // and answered untouched: only its name and whether it failed are read.
734 on('tool.call', async ($, e, next) => {
735 if (!isRunning) {
736 return next(e)
737 }
738
739 const tool = (String(e.tool).split('__').at(-1) ?? '').slice(0, 24)
740 await update($, feed, now => ({ ...now, tools: now.tools + 1, tool }))
741 const ran = await next(e)
742
743 if (ran.deny === undefined && ran.isError === true) {
744 await update($, feed, now => ({ ...now, fails: now.fails + 1 }))
745 }
746
747 return ran
748 })
749
750 on('turn.complete', async ($, e, next) => {
751 if (isRunning && e.agentId === undefined) {
752 isRunning = false
753 await alerted($, 'done')
754 }
755
756 return next(e)
757 })
758
759 // Claude Code's own notice that it waits on the person. It is only read:
760 // the notice goes on as it came, and what it asks stays theirs to answer.
761 on('classic.Notification', async ($, e, next) => {
762 if (isRunning && WAITING.includes(e.notification_type)) {
763 isRunning = false
764 await alerted($, 'ask')
765 }
766
767 return next(e)
768 })
769
770 // The best score at the right end of the hint line under the prompt, where
771 // it shows with the pane closed and takes no row of its own. The hint's text
772 // is the engine's to draw: this only pads a tail out to the row's end.
773 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
774 const hi = await read($, best)
775
776 if (hi === 0 || !isHiShown(await read($, settings))) {
777 return next(e)
778 }
779
780 const label = `🦖 HI ${digits(hi)}`
781 const before = `${e.props.hint}${e.props.tail ?? ''}`
782 const room = (e.viewport?.columns ?? 0) - before.length - label.length
783 const gap = ' '.repeat(Math.max(MIN_HINT_GAP, room - HINT_MARGIN))
784 const tail = `${e.props.tail ?? ''}${gap}${label}`
785
786 return next({ ...e, props: { ...e.props, tail } })
787 })
788
789 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
790 const isOffered =
791 e.props.isWorking &&
792 !e.props.hasSurvey &&
793 (e.surface === 'terminal' || e.surface === 'desktop')
794
795 if (!isOffered || !isMiniShown(await read($, settings))) {
796 return next(e)
797 }
798
799 const ui = $.ui.resolve(e)
800
801 return (
802 <ui.Client
803 key={MINI}
804 module="./mini.tsx"
805 props={null}
806 width="100%"
807 height={MINI_ROWS}
808 />
809 )
810 })
811
812 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
813 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
814 const { Text } = $.ui.resolve(e)
815
816 return <Text dimColor>Dino runs in the terminal and the desktop app.</Text>
817 }
818
819 const ui = $.ui.resolve(e)
820 const { Box, Input, Text } = ui
821 const { typed: keys } = toInput(await read($, input))
822 const { score: offered } = await read($, offer)
823 const isAsked = offered > 0
824 const hint = e.props.isFocused
825 ? 'space/w jump · s duck · p pause · t top · esc gives the keys back to the prompt'
826 : 'ctrl+x tab gives the game the keys · then space/w jump · s duck · p pause · t top'
827
828 return (
829 <Box flexDirection="column">
830 <ui.Client
831 key={GAME}
832 module="./dino.tsx"
833 props={await propsOf($)}
834 width="100%"
835 height={ROWS}
836 />
837 {/* The field is the keyboard: every key typed into it is one move, and
838 drawing a value other than the last one empties it again. */}
839 {/* After a lost run by somebody not on the global top, the same field
840 asks for a name: it keeps the focus, and a space still runs again. */}
841 <Input
842 key={KEYS}
843 label={isAsked ? 'name' : 'keys'}
844 placeholder={
845 isAsked
846 ? 'a name for the global top'
847 : 'space jump · s duck · p pause · t top'
848 }
849 submitLabel={isAsked ? 'join' : 'jump'}
850 value={keys % 2 === 0 ? '' : ' '}
851 autoFocus
852 onInput={value => typed($, value)}
853 onSubmit={value => entered($, value, latest)}
854 />
855 <Text dimColor>
856 {isAsked
857 ? `enter sends this name and your ${digits(offered)} run to the global top · space runs again · enter alone shows the top`
858 : hint}
859 </Text>
860 </Box>
861 )
862 })
863}
864hooks/board.ts 203 lines1// The global top as the plugin sees it: who the person is there, what to ask
2// the leaderboard's server, and how to read what it says. Nothing here reaches
3// the network or the store; the hooks module does, with what this hands it.
4
5import { RULES } from './sim'
6
7// The person on the global top: the name they took, and the id and the secret
8// that make a run theirs. Nobody until they take a name; isOff once they have
9// said not to be asked for one.
10export type Player = {
11 id: string
12 key: string
13 name: string
14 best: number
15 // Where they stand on it, as the server last said: 0 until a run counts.
16 rank: number
17 isOff: boolean
18}
19/** One line of the board. */
20export type Row = { name: string; score: number }
21/** A finished run: what it scored here, and what plays it again elsewhere. */
22export type Run = { seed: number; log: string; score: number }
23/** What the leaderboard said: whether it did as asked, and a line on it. */
24export type Told = { isDone: boolean; text: string }
25
26const BOARD = 'https://claude-dino-board.barisdemirhan.workers.dev'
27export const NOBODY: Player = {
28 id: '',
29 key: '',
30 name: '',
31 best: 0,
32 rank: 0,
33 isOff: false,
34}
35// What the server refuses with, as the person reads it.
36const REFUSALS: Readonly<Record<string, string>> = {
37 'bad-name': 'A name takes 2 to 16 letters, digits, - or _.',
38 'name-taken': 'That name is taken. Try another.',
39 'slow-down': 'The leaderboard is busy. Try again in a few minutes.',
40 'bad-run': 'The leaderboard could not play that run again, so it does not count.',
41 'other-rules':
42 'The leaderboard plays by another version. Update the dino plugin to send runs.',
43 duplicate: 'The leaderboard already has that run.',
44 banned: 'This name was taken off the leaderboard.',
45 forbidden:
46 'The leaderboard does not know this player any more. /dino leave, then join again.',
47}
48const SILENT = 'The leaderboard did not answer. Your scores stay on this machine.'
49
50const digits = (score: number): string => String(score).padStart(5, '0')
51
52const fieldOf = (value: unknown, key: string): unknown =>
53 typeof value === 'object' && value !== null ? Reflect.get(value, key) : undefined
54
55const textOf = (value: unknown): string =>
56 typeof value === 'string' ? value : ''
57
58const countOf = (value: unknown): number =>
59 typeof value === 'number' && Number.isFinite(value) && value > 0
60 ? Math.floor(value)
61 : 0
62
63export const toPlayer = (value: unknown): Player => ({
64 id: textOf(fieldOf(value, 'id')),
65 key: textOf(fieldOf(value, 'key')),
66 name: textOf(fieldOf(value, 'name')),
67 best: countOf(fieldOf(value, 'best')),
68 rank: countOf(fieldOf(value, 'rank')),
69 isOff: fieldOf(value, 'isOff') === true,
70})
71
72/** The player with the id and the secret their runs go under, made once. */
73export const enrolled = (player: Player): Player =>
74 player.id === ''
75 ? { ...player, id: crypto.randomUUID(), key: crypto.randomUUID() }
76 : player
77
78/** Where on the server a path is. */
79export const urlOf = (path: string): string => `${BOARD}${path}`
80
81/** A write to the server, as `$.http.fetch` takes it. */
82export const posted = (body: Record<string, unknown>) => ({
83 method: 'POST',
84 headers: { 'content-type': 'application/json' },
85 body: JSON.stringify(body),
86})
87
88export const runBody = (player: Player, run: Run): Record<string, unknown> => ({
89 id: player.id,
90 key: player.key,
91 rules: RULES,
92 seed: run.seed,
93 log: run.log,
94})
95
96/** The board's path, with the id that asks where this person stands on it. */
97export const topPath = (player: Player): string =>
98 player.id === '' ? '/top' : `/top?id=${encodeURIComponent(player.id)}`
99
100export const refusalOf = (said: unknown): string =>
101 REFUSALS[textOf(fieldOf(said, 'error'))] ?? SILENT
102
103const standing = (rank: number, best: number): string =>
104 `#${rank} on the global top with ${digits(best)}`
105
106/** The player as the server took them under a name. */
107export const named = (player: Player, said: unknown): Player => ({
108 ...player,
109 name: textOf(fieldOf(said, 'name')),
110 best: countOf(fieldOf(said, 'best')),
111 rank: countOf(fieldOf(said, 'rank')),
112 isOff: false,
113})
114
115/** The player once the server has counted `run`: nothing lower goes up again. */
116export const ranked = (player: Player, run: Run, said: unknown): Player => ({
117 ...player,
118 best: Math.max(run.score, countOf(fieldOf(said, 'best'))),
119 // A run held back to be looked over comes with no rank: the last one stands.
120 rank: countOf(fieldOf(said, 'rank')) || player.rank,
121})
122
123/** The board's rows as the server sent them, or undefined when it sent none. */
124export const rowsOf = (said: unknown): Row[] | undefined => {
125 const top = fieldOf(said, 'top')
126
127 return Array.isArray(top)
128 ? top.map((row: unknown) => ({
129 name: textOf(fieldOf(row, 'name')),
130 score: countOf(fieldOf(row, 'score')),
131 }))
132 : undefined
133}
134
135/** How many players the board holds in all. */
136export const playersOf = (said: unknown): number =>
137 countOf(fieldOf(said, 'players'))
138
139/** The player with where the board says they stand now. */
140export const placed = (player: Player, said: unknown): Player => {
141 const you = fieldOf(said, 'you')
142
143 return you === null || you === undefined
144 ? player
145 : {
146 ...player,
147 best: countOf(fieldOf(you, 'best')),
148 rank: countOf(fieldOf(you, 'rank')),
149 }
150}
151
152export const sentText = (said: unknown): string => {
153 const rank = countOf(fieldOf(said, 'rank'))
154
155 if (fieldOf(said, 'held') === true) {
156 return 'Run sent. It shows on the global top once it has been looked over.'
157 }
158
159 return rank > 0
160 ? `You are ${standing(rank, countOf(fieldOf(said, 'best')))}.`
161 : 'Run sent.'
162}
163
164/** Whether this person is on the leaderboard, in a line. */
165export const nameText = ({ name, best }: Player): string => {
166 if (name === '') {
167 return 'You are not on the global top. /dino name <name> joins it.'
168 }
169
170 return best > 0
171 ? `You are on the global top as ${name}, with ${digits(best)}.`
172 : `You are on the global top as ${name}. Your next best run goes up.`
173}
174
175/** The board's first ten, and where this person stands. */
176export const topText = (status: number, said: unknown): string => {
177 const top = rowsOf(said)
178
179 if (status !== 200 || top === undefined) {
180 return SILENT
181 }
182
183 if (top.length === 0) {
184 return 'Nobody is on the global top yet. Finish a run and enter a name to be the first.'
185 }
186
187 const players = playersOf(said)
188 const rows = top.map(
189 ({ name, score }, at) =>
190 `${String(at + 1).padStart(2)} · ${digits(score)} · ${name}`,
191 )
192 const you = fieldOf(said, 'you')
193 const rank = countOf(fieldOf(you, 'rank'))
194 const yours =
195 rank > 0 ? [`You are ${standing(rank, countOf(fieldOf(you, 'best')))}.`] : []
196
197 return [
198 `Dino global top · ${players} ${players === 1 ? 'player' : 'players'}`,
199 ...rows,
200 ...yours,
201 ].join('\n')
202}
203hooks/mini.tsx 216 lines1import type { ClientModule, ClientSurface } from 'claude-code'
2
3type Sprite = readonly string[]
4// What comes at the dino, by its left column: a cell nearer every tick.
5type Obstacle = { x: number; kind: 'cactus' | 'bird' }
6type Run = {
7 ticks: number
8 obstacles: Obstacle[]
9 // Each cloud's left column, drifting slower than the ground runs.
10 clouds: number[]
11 // Ticks into the hop over a cactus; -1 on the ground.
12 hop: number
13 // True while the dino keeps its head down for a bird.
14 isDucking: boolean
15 seed: number
16}
17
18// A band four text rows tall: eight pixel rows (▀ ▄ █), no ground line. The
19// dino plays itself: it hops the cacti and ducks the birds.
20const TICK_MS = 90
21const DINO_X = 2
22const MAX_COLUMNS = 120
23const MIN_COLUMNS = 20
24// The closest two obstacles come: room to land a hop before the next move.
25const MIN_GAP = 16
26const SPAWN_ODDS = 0.08
27const BIRD_ODDS = 0.35
28// A hop starts with the cactus this many cells ahead of the dino's column.
29const HOP_AT = 7
30// Pixels off the ground at each tick of a hop: over a cactus two pixels tall.
31const HOP: readonly number[] = [1, 2, 2, 2, 2, 2, 2, 2, 1]
32// The dino ducks from a bird this many cells ahead until it is this far past.
33const DUCK_FROM = 9
34const DUCK_UNTIL = -5
35// A bird flies level with the standing dino's head, over the ducking one.
36const BIRD_Y = 3
37const CLOUD_Y = 6
38const CLOUD_EVERY = 3
39const CLOUD_GAP = 28
40const CLOUD_ODDS = 0.03
41
42const HEAD: Sprite = [' ###', '# ## ', '#### ']
43const STRIDE_A: Sprite = [...HEAD, ' # # ']
44const STRIDE_B: Sprite = [...HEAD, ' # ']
45const LOW: Sprite = ['# ###', '###### ']
46const DUCK_A: Sprite = [...LOW, ' # # ']
47const DUCK_B: Sprite = [...LOW, ' # ']
48const CACTUS: Sprite = ['#', '#']
49const BIRD_UP: Sprite = [' # ', '####']
50const BIRD_DOWN: Sprite = ['####', ' # ']
51const CLOUD: Sprite = [' ## ', '#####']
52const WIDEST = 5
53
54const roll = (run: Run): number => {
55 run.seed = (Math.imul(run.seed, 1664525) + 1013904223) >>> 0
56
57 return run.seed / 2 ** 32
58}
59
60const hopAfter = (previous: Run, isCactusDue: boolean): number => {
61 const isAirborne = previous.hop >= 0 && previous.hop < HOP.length - 1
62
63 if (isAirborne) {
64 return previous.hop + 1
65 }
66
67 return isCactusDue ? 0 : -1
68}
69
70const step = (previous: Run, columns: number): Run => {
71 const run: Run = { ...previous, ticks: previous.ticks + 1 }
72 const edge = columns - 1
73 run.obstacles = previous.obstacles
74 .map(obstacle => ({ ...obstacle, x: obstacle.x - 1 }))
75 .filter(obstacle => obstacle.x > -WIDEST)
76 const last = run.obstacles.at(-1)?.x ?? -MIN_GAP
77
78 if (edge - last >= MIN_GAP && roll(run) < SPAWN_ODDS) {
79 run.obstacles.push({
80 x: edge,
81 kind: roll(run) < BIRD_ODDS ? 'bird' : 'cactus',
82 })
83 }
84
85 const drift = run.ticks % CLOUD_EVERY === 0 ? 1 : 0
86 run.clouds = previous.clouds.map(x => x - drift).filter(x => x > -WIDEST)
87 const lastCloud = run.clouds.at(-1) ?? -CLOUD_GAP
88
89 if (edge - lastCloud >= CLOUD_GAP && roll(run) < CLOUD_ODDS) {
90 run.clouds.push(edge)
91 }
92
93 const isCactusDue = run.obstacles.some(
94 obstacle => obstacle.kind === 'cactus' && obstacle.x === DINO_X + HOP_AT,
95 )
96 run.hop = hopAfter(previous, isCactusDue)
97 run.isDucking =
98 run.hop < 0 &&
99 run.obstacles.some(
100 obstacle =>
101 obstacle.kind === 'bird' &&
102 obstacle.x <= DINO_X + DUCK_FROM &&
103 obstacle.x >= DINO_X + DUCK_UNTIL,
104 )
105
106 return run
107}
108
109const dinoSprite = (run: Run): Sprite => {
110 const isFirstStride = Math.floor(run.ticks / 2) % 2 === 0
111
112 if (run.isDucking) {
113 return isFirstStride ? DUCK_A : DUCK_B
114 }
115
116 return run.hop >= 0 || isFirstStride ? STRIDE_A : STRIDE_B
117}
118
119/** The band as pixel rows, bottom row first. */
120const paint = (run: Run, columns: number, height: number): boolean[][] => {
121 const pixels = Array.from({ length: height }, () =>
122 new Array<boolean>(columns).fill(false),
123 )
124 const draw = (sprite: Sprite, x: number, y: number): void => {
125 sprite.forEach((line, row) => {
126 const target = pixels[y + sprite.length - 1 - row]
127
128 for (const [column, pixel] of [...line].entries()) {
129 const at = x + column
130
131 if (target !== undefined && pixel === '#' && at >= 0 && at < columns) {
132 target[at] = true
133 }
134 }
135 })
136 }
137 const isWingUp = Math.floor(run.ticks / 3) % 2 === 0
138
139 for (const x of run.clouds) {
140 draw(CLOUD, x, CLOUD_Y)
141 }
142
143 for (const obstacle of run.obstacles) {
144 if (obstacle.kind === 'cactus') {
145 draw(CACTUS, obstacle.x, 0)
146 } else {
147 draw(isWingUp ? BIRD_UP : BIRD_DOWN, obstacle.x, BIRD_Y)
148 }
149 }
150
151 draw(dinoSprite(run), DINO_X, HOP[run.hop] ?? 0)
152
153 return pixels
154}
155
156const start = (surface: ClientSurface<Run>): Run => {
157 const run: Run = {
158 ticks: 0,
159 obstacles: [],
160 clouds: [],
161 hop: -1,
162 isDucking: false,
163 seed: Math.floor(Math.random() * 2 ** 32),
164 }
165 surface.setState(run)
166 surface.every(TICK_MS, () => {
167 const now = surface.state
168
169 if (now !== undefined) {
170 surface.setState(step(now, Math.min(surface.columns, MAX_COLUMNS)))
171 }
172 })
173
174 return run
175}
176
177const Mini: ClientModule<null, Run> = (_props, surface) => {
178 const { Box, Text } = surface.elements
179 const run = surface.state ?? start(surface)
180 const columns = Math.min(surface.columns, MAX_COLUMNS)
181 const rows = surface.rows
182
183 if (columns < MIN_COLUMNS || rows < 1) {
184 return <Text dimColor> </Text>
185 }
186
187 const pixels = paint(run, columns, rows * 2)
188 const lines = Array.from({ length: rows }, (_, row) => {
189 const upper = pixels[rows * 2 - 1 - row * 2]
190 const lower = pixels[rows * 2 - 2 - row * 2]
191
192 return Array.from({ length: columns }, (_, column) => {
193 const isUpper = upper?.[column] === true
194 const isLower = lower?.[column] === true
195
196 if (isUpper) {
197 return isLower ? '█' : '▀'
198 }
199
200 return isLower ? '▄' : ' '
201 }).join('')
202 })
203
204 return (
205 <Box flexDirection="column">
206 {lines.map(line => (
207 <Text dimColor wrap="truncate-end">
208 {line}
209 </Text>
210 ))}
211 </Box>
212 )
213}
214
215export default Mini
216hooks/dino.tsx 400 lines1import type { ClientModule, ClientSurface } from 'claude-code'
2
3import {
4 CLOUD,
5 DINO_X,
6 JUMP,
7 MAX_COLUMNS,
8 MIN_COLUMNS,
9 RESTART_TICKS,
10 TICK_MS,
11 dinoSprite,
12 ducked,
13 fresh,
14 jumped,
15 obstacleSprite,
16 paused,
17 peeked,
18 queued,
19 scoreOf,
20 step,
21 toggled,
22} from './sim'
23import type { Game, Sprite } from './sim'
24
25type Row = { name: string; score: number }
26type Props = {
27 hi: number
28 jumps: number
29 ducks: number
30 pauses: number
31 tops: number
32 tools: number
33 fails: number
34 tool: string
35 alerts: number
36 alert: 'done' | 'ask'
37 // The person on the global top: no name until they join, rank 0 until a
38 // run of theirs counts. isAsked while the field below takes a name.
39 name: string
40 rank: number
41 isAsked: boolean
42 // The board as last fetched, and whether it is being asked for or silent.
43 board: 'idle' | 'asking' | 'ready' | 'silent'
44 top: Row[]
45 players: number
46}
47
48const GROUND = 2
49const MIN_ROWS = 8
50const NIGHT_EVERY = 700
51const NIGHT_LASTS = 250
52const LABEL_TICKS = 40
53const JUMP_KEYS = [' ', 'space', 'up', 'return', 'w', 'k']
54const DUCK_KEYS = ['down', 's', 'j']
55const PAUSE_KEYS = ['p']
56const TOP_KEYS = ['t']
57const TOP_ROWS = 10
58const BOARD_NOTES = {
59 idle: 'asking the leaderboard…',
60 asking: 'asking the leaderboard…',
61 ready: 'nobody is on it yet: finish a run and take a name',
62 silent: 'the leaderboard did not answer',
63} as const
64const PAUSE_NOTES = {
65 key: ' space or p to run on ',
66 done: ' Claude is done · space to run on ',
67 ask: ' Claude needs you · space to run on ',
68} as const
69
70/** The run with the obstacles Claude's new tool calls and failures queue. */
71const fed = (game: Game, props: Props): Game => {
72 const seen = { ...game, tools: props.tools, fails: props.fails }
73
74 if (game.phase !== 'run') {
75 return seen
76 }
77
78 return {
79 ...queued(seen, props.tools - game.tools, props.fails - game.fails),
80 label: props.tool,
81 labelTicks: LABEL_TICKS,
82 }
83}
84
85/** The game after every count the props carry beyond the ones seen. */
86const caughtUp = (game: Game, props: Props): Game => {
87 const moves: readonly [boolean, (now: Game) => Game][] = [
88 [
89 props.alerts !== game.alerts,
90 now => paused({ ...now, alerts: props.alerts }, props.alert),
91 ],
92 [
93 props.tools !== game.tools || props.fails !== game.fails,
94 now => fed(now, props),
95 ],
96 [
97 props.pauses !== game.pauses,
98 now => toggled({ ...now, pauses: props.pauses }),
99 ],
100 [props.tops !== game.tops, now => peeked({ ...now, tops: props.tops })],
101 [
102 props.jumps !== game.jumps,
103 now => jumped({ ...now, jumps: props.jumps }),
104 ],
105 [
106 props.ducks !== game.ducks,
107 now => ducked({ ...now, ducks: props.ducks }),
108 ],
109 ]
110
111 return moves.reduce((now, [isDue, move]) => (isDue ? move(now) : now), game)
112}
113
114/** What the hooks module hears of a change, to keep scores and play sounds. */
115const eventOf = (before: Game, after: Game): string | undefined => {
116 if (before.phase !== after.phase) {
117 if (after.phase === 'run') {
118 return before.phase === 'paused' ? 'resume' : 'start'
119 }
120
121 return after.phase === 'ready' ? undefined : after.phase
122 }
123
124 if (after.isTop && !before.isTop) {
125 // The hooks module fetches the board when it is asked for, not before.
126 return 'top'
127 }
128
129 if (after.phase !== 'run') {
130 return undefined
131 }
132
133 if (after.flash > before.flash) {
134 return 'point'
135 }
136
137 return after.vy === JUMP && before.vy !== JUMP ? 'jump' : undefined
138}
139
140const settle = (
141 surface: ClientSurface<Game>,
142 before: Game,
143 after: Game,
144): void => {
145 const event = eventOf(before, after)
146 surface.setState(after)
147
148 if (event === 'over') {
149 // A lost run goes with what plays it again: the leaderboard takes those.
150 surface.post({
151 event,
152 score: scoreOf(after),
153 seed: after.start,
154 log: after.log,
155 })
156 } else if (event !== undefined) {
157 surface.post({ event, score: scoreOf(after) })
158 }
159}
160
161const act = (surface: ClientSurface<Game>, move: (game: Game) => Game): void => {
162 const game = surface.state
163 const next = game === undefined ? undefined : move(game)
164
165 if (game !== undefined && next !== undefined && next !== game) {
166 settle(surface, game, next)
167 }
168}
169
170const tick = (surface: ClientSurface<Game>): void => {
171 const game = surface.state
172 const isMoving =
173 game?.phase === 'run' ||
174 (game?.phase === 'over' && game.ticks < RESTART_TICKS)
175
176 if (game === undefined || !isMoving) {
177 return
178 }
179
180 settle(surface, game, step(game, Math.min(surface.columns, MAX_COLUMNS)))
181}
182
183const start = (surface: ClientSurface<Game>, props: Props): Game => {
184 const game = fresh(props.hi, Math.floor(Math.random() * 2 ** 32), props)
185 surface.setState(game)
186 surface.every(TICK_MS, () => tick(surface))
187 surface.onKey(event => {
188 if (JUMP_KEYS.includes(event.key)) {
189 act(surface, jumped)
190 } else if (DUCK_KEYS.includes(event.key)) {
191 act(surface, ducked)
192 } else if (PAUSE_KEYS.includes(event.key)) {
193 act(surface, toggled)
194 } else if (TOP_KEYS.includes(event.key)) {
195 act(surface, peeked)
196 }
197 })
198 surface.onPointer(event => {
199 if (event.type === 'down') {
200 act(surface, jumped)
201 }
202 })
203
204 return game
205}
206
207const isBump = (at: number): boolean => {
208 const mixed = Math.imul(at ^ (at >>> 15), 2246822519)
209 const scattered = Math.imul(mixed ^ (mixed >>> 13), 3266489917)
210
211 return ((scattered ^ (scattered >>> 16)) >>> 0) % 9 === 0
212}
213
214/** The field as pixel rows, bottom row first. */
215const paint = (game: Game, columns: number, height: number): boolean[][] => {
216 const pixels = Array.from({ length: height }, () =>
217 new Array<boolean>(columns).fill(false),
218 )
219 const draw = (sprite: Sprite, x: number, y: number): void => {
220 sprite.forEach((line, row) => {
221 const target = pixels[y + GROUND + sprite.length - 1 - row]
222
223 for (const [column, pixel] of [...line].entries()) {
224 const at = x + column
225
226 if (target !== undefined && pixel === '#' && at >= 0 && at < columns) {
227 target[at] = true
228 }
229 }
230 })
231 }
232 const scrolled = Math.floor(game.dist)
233
234 pixels[1]?.fill(true)
235 pixels[0]?.forEach((_, column, row) => {
236 row[column] = isBump(scrolled + column)
237 })
238
239 for (const cloud of game.clouds) {
240 draw(CLOUD, Math.round(cloud.x), cloud.y)
241 }
242
243 for (const obstacle of game.obstacles) {
244 draw(obstacleSprite(obstacle, game.ticks), Math.round(obstacle.x), obstacle.y)
245 }
246
247 draw(dinoSprite(game), DINO_X, Math.round(game.y))
248
249 return pixels
250}
251
252const stamp = (line: string, text: string, at: number): string => {
253 const from = Math.max(0, at)
254
255 return line.slice(0, from) + text + line.slice(from + text.length)
256}
257
258const centered = (line: string, text: string): string =>
259 stamp(line, text, Math.floor((line.length - text.length) / 2))
260
261const digits = (score: number): string => String(score).padStart(5, '0')
262
263/** The global top as text rows, in place of the field. */
264const boardLines = (props: Props, columns: number, rows: number): string[] => {
265 const lines = Array.from({ length: rows }, () => ' '.repeat(columns))
266 const put = (row: number, text: string): void => {
267 lines[row] = stamp(lines[row] ?? '', text, 2)
268 }
269 const shown = props.top.slice(0, Math.min(TOP_ROWS, rows - 4))
270 const count = `${props.players} ${props.players === 1 ? 'player' : 'players'}`
271
272 put(0, `G L O B A L T O P${shown.length > 0 ? ` · ${count}` : ''}`)
273
274 if (shown.length === 0) {
275 put(2, BOARD_NOTES[props.board])
276 }
277
278 shown.forEach(({ name, score }, at) => {
279 const mark = name === props.name ? '▸' : ' '
280 put(2 + at, `${mark}${String(at + 1).padStart(2)} ${digits(score)} ${name}`)
281 })
282
283 if (props.rank > shown.length) {
284 put(rows - 2, `▸ you are #${props.rank} · ${props.name}`)
285 }
286
287 put(rows - 1, 't closes · space runs')
288
289 return lines
290}
291
292/** What a lost run says in the middle of the field, top line first. */
293const overLines = (game: Game, props: Props): string[] => {
294 const score = scoreOf(game)
295 // A first run beats nothing: only a best that was there can be beaten.
296 const isBest = game.was > 0 && score > game.was
297 const standing =
298 props.rank > 0 ? `#${props.rank} on the global top` : 'on the global top'
299
300 return [
301 isBest ? ' N E W B E S T ' : ' G A M E O V E R ',
302 '',
303 ...(isBest
304 ? [` ${score - game.was} past your old best of ${digits(game.was)} `]
305 : []),
306 ...(props.name !== '' ? [` ${props.name} · ${standing} `] : []),
307 ...(props.name === '' && props.isAsked
308 ? [' a name below puts this run on the global top ']
309 : []),
310 '',
311 props.isAsked ? ' space runs again ' : ' space runs again · t shows the top ',
312 ]
313}
314
315const Dino: ClientModule<Props, Game> = (props, surface) => {
316 const { Box, Text } = surface.elements
317 const known = surface.state ?? start(surface, props)
318 const game = caughtUp(known, props)
319
320 if (game !== known) {
321 settle(surface, known, game)
322 }
323
324 const columns = Math.min(surface.columns, MAX_COLUMNS)
325 const rows = surface.rows
326
327 if (columns < MIN_COLUMNS || rows < MIN_ROWS) {
328 return <Text dimColor>Dino needs a little more room.</Text>
329 }
330
331 if (game.isTop && game.phase !== 'run') {
332 return (
333 <Box flexDirection="column">
334 {boardLines(props, columns, rows).map(line => (
335 <Text wrap="truncate-end">{line}</Text>
336 ))}
337 </Box>
338 )
339 }
340
341 const pixels = paint(game, columns, rows * 2)
342 const lines = Array.from({ length: rows }, (_, row) => {
343 const upper = pixels[rows * 2 - 1 - row * 2]
344 const lower = pixels[rows * 2 - 2 - row * 2]
345
346 return Array.from({ length: columns }, (_, column) => {
347 const isUpper = upper?.[column] === true
348 const isLower = lower?.[column] === true
349
350 if (isUpper) {
351 return isLower ? '█' : '▀'
352 }
353
354 return isLower ? '▄' : ' '
355 }).join('')
356 })
357 const score = scoreOf(game)
358 const hi = Math.max(props.hi, game.hi)
359 // A round score stays up and blinks, as the arcade's does.
360 const isBlinking = game.flash > 0 && game.phase === 'run'
361 const isDark = isBlinking && Math.floor(game.flash / 4) % 2 === 0
362 const shown = isDark ? ' ' : digits(isBlinking ? game.milestone : score)
363 const tally = `${hi > 0 ? `HI ${digits(hi)} ` : ''}${shown} `
364 const middle = Math.max(1, Math.floor(rows / 2) - 3)
365
366 lines[0] = stamp(lines[0] ?? '', tally, columns - tally.length)
367
368 if (game.labelTicks > 0 && game.label !== '') {
369 lines[0] = stamp(lines[0] ?? '', ` ▸ ${game.label} `, 0)
370 }
371
372 if (game.phase === 'ready') {
373 lines[middle] = centered(lines[middle] ?? '', ' PRESS SPACE TO PLAY ')
374 lines[middle + 2] = centered(lines[middle + 2] ?? '', ' t shows the global top ')
375 } else if (game.phase === 'paused') {
376 lines[middle] = centered(lines[middle] ?? '', ' P A U S E D ')
377 lines[middle + 2] = centered(lines[middle + 2] ?? '', PAUSE_NOTES[game.note])
378 } else if (game.phase === 'over') {
379 overLines(game, props).forEach((text, at) => {
380 if (text !== '') {
381 lines[middle + at] = centered(lines[middle + at] ?? '', text)
382 }
383 })
384 }
385
386 const isNight = score >= NIGHT_EVERY && score % NIGHT_EVERY < NIGHT_LASTS
387
388 return (
389 <Box flexDirection="column">
390 {lines.map(line => (
391 <Text inverse={isNight} wrap="truncate-end">
392 {line}
393 </Text>
394 ))}
395 </Box>
396 )
397}
398
399export default Dino
400hooks/sim.ts 556 lines1// The run itself, with nothing of the pane in it: the game steps through it,
2// and the leaderboard's server plays a run again from its seed and its moves.
3// Both must reach the same score, so neither keeps rules of its own.
4
5export type Sprite = readonly string[]
6export type Obstacle = {
7 x: number
8 y: number
9 kind: 'cactus' | 'bird' | 'crate'
10 shape: number
11}
12export type Cloud = { x: number; y: number }
13// The counts the props carry that a game has already acted on.
14export type Seen = {
15 jumps: number
16 ducks: number
17 pauses: number
18 tops: number
19 tools: number
20 fails: number
21 alerts: number
22}
23export type Game = Seen & {
24 phase: 'ready' | 'run' | 'paused' | 'over'
25 // Why the run is paused: the person's key, or Claude asking for them.
26 note: 'key' | 'done' | 'ask'
27 ticks: number
28 y: number
29 vy: number
30 duck: number
31 speed: number
32 dist: number
33 obstacles: Obstacle[]
34 gap: number
35 // Pixels run since the last obstacle came in.
36 since: number
37 // Obstacles Claude's tool calls queued: a crate a call, a bird a failure.
38 crates: number
39 birds: number
40 // The tool behind the latest queued obstacle, and the ticks its name shows.
41 label: string
42 labelTicks: number
43 // The round score the tally blinks, and the ticks it still blinks for.
44 milestone: number
45 flash: number
46 clouds: Cloud[]
47 seed: number
48 hi: number
49 // The best score before this run: a run that ends past it is a new best.
50 was: number
51 // Whether the global top is shown over the field, as it can be between runs.
52 isTop: boolean
53 // The seed the run started on and its moves since, each stamped with the
54 // ticks since the one before: what plays the run again, tick for tick.
55 start: number
56 log: string
57 // The tick of the last move in the log.
58 noted: number
59 // The field's width at the last step: obstacles come in at its right end.
60 columns: number
61}
62// How far ahead the nearest obstacle was at a jump, in pixels and in ticks.
63export type Lead = { pixels: number; ticks: number }
64export type Replay = { score: number; ticks: number; leads: Lead[] }
65
66// Which rules these are. A change to how a run steps makes an older log play
67// out differently, so it takes a new number here and the server deployed anew.
68export const RULES = 1
69// Cells across are pixels across; every text row holds two pixel rows (▀ ▄ █),
70// so a pixel is about square. Heights count pixels up from the ground.
71export const TICK_MS = 40
72const DT = TICK_MS / 1000
73const GRAVITY = 354
74export const JUMP = 110
75const DROP = -220
76const START_SPEED = 38
77const MAX_SPEED = 90
78const ACCELERATION = 1.2
79export const DINO_X = 3
80const DUCK_TICKS = 12
81export const RESTART_TICKS = 12
82export const MAX_COLUMNS = 160
83export const MIN_COLUMNS = 30
84const PIXELS_PER_POINT = 4
85const BIRDS_FROM = 150
86const TRIPLES_FROM = 55
87// The closest two obstacles come, as a share of the pixels run in a second.
88const MIN_GAP = 0.9
89const MAX_QUEUED = 3
90const MILESTONE = 100
91const FLASH_TICKS = 24
92// Half an hour of ticks, and the log that long a run could fill: a longer run
93// is not played again.
94const MAX_TICKS = 45_000
95const MAX_LOG = 60_000
96// One move of a log: the ticks since the last one, then a jump, a duck, the
97// field's new width, or the crates and birds Claude's work queued.
98const MOVE = /^(\d{1,5})(?:([jd])|c(\d{1,3})|f([0-3])\.([0-3]))$/
99const UNSEEN: Seen = {
100 jumps: 0,
101 ducks: 0,
102 pauses: 0,
103 tops: 0,
104 tools: 0,
105 fails: 0,
106 alerts: 0,
107}
108
109const BODY: Sprite = [
110 ' ######',
111 ' ## ####',
112 ' #######',
113 ' #### ',
114 ' ###### ',
115 '# #### ',
116 '## ###### ',
117 '######## # ',
118 ' ####### ',
119 ' ##### ',
120]
121const STAND: Sprite = [...BODY, ' # # ', ' ## ## ']
122const RUN_A: Sprite = [...BODY, ' # ## ', ' ## ']
123const RUN_B: Sprite = [...BODY, ' ## # ', ' ## ']
124const LOW: Sprite = [
125 '# ######',
126 '## ##### ## ####',
127 '#################',
128 ' ############ ',
129 ' ######### #####',
130]
131const DUCK_A: Sprite = [...LOW, ' # ## ', ' ## ']
132const DUCK_B: Sprite = [...LOW, ' ## # ', ' ## ']
133
134const SMALL: Sprite = [
135 ' # ',
136 '# # ',
137 '# # #',
138 '### #',
139 ' ###',
140 ' # ',
141 ' # ',
142]
143const BIG: Sprite = [
144 ' ## ',
145 ' ## ',
146 '# ## ',
147 '# ## # ',
148 '# ## # ',
149 '#### # ',
150 ' #### ',
151 ' ## ',
152 ' ## ',
153 ' ## ',
154]
155const BIRD_UP: Sprite = [
156 ' # ',
157 ' # ## ',
158 ' ## ### ',
159 '###########',
160 ' ###### ',
161 ' ',
162]
163const BIRD_DOWN: Sprite = [
164 ' ',
165 ' # ',
166 ' ## ',
167 '###########',
168 ' ###### ',
169 ' ## ',
170]
171const CRATE: Sprite = ['#####', '# #', '# # #', '# #', '#####']
172export const CLOUD: Sprite = [' #### ', ' ## ### ', '############']
173
174export const widthOf = (sprite: Sprite): number =>
175 Math.max(...sprite.map(line => line.length))
176
177const beside = (...sprites: Sprite[]): Sprite => {
178 const height = Math.max(...sprites.map(sprite => sprite.length))
179
180 return Array.from({ length: height }, (_, row) =>
181 sprites
182 .map(sprite =>
183 (sprite[row - (height - sprite.length)] ?? '').padEnd(widthOf(sprite)),
184 )
185 .join(' '),
186 )
187}
188
189// The last one is three cacti wide: only cleared once the run is fast enough.
190const CACTI: readonly Sprite[] = [
191 SMALL,
192 BIG,
193 beside(SMALL, SMALL),
194 beside(BIG, SMALL),
195 beside(SMALL, BIG, SMALL),
196]
197// Over a jump, under a jump or a duck, over a standing dino.
198const BIRD_HEIGHTS = [1, 7, 13] as const
199// The widest the dino and an obstacle get: further apart, they cannot touch.
200const DINO_WIDTH = widthOf(LOW)
201const WIDEST = Math.max(...[...CACTI, BIRD_UP, CRATE].map(widthOf))
202const CLOUD_WIDTH = widthOf(CLOUD)
203
204export const scoreOf = (game: Game): number =>
205 Math.floor(game.dist / PIXELS_PER_POINT)
206
207export const fresh = (hi: number, seed: number, seen: Seen): Game => ({
208 phase: 'ready',
209 note: 'key',
210 ticks: 0,
211 y: 0,
212 vy: 0,
213 duck: 0,
214 speed: START_SPEED,
215 dist: 0,
216 obstacles: [],
217 gap: 50,
218 since: 0,
219 crates: 0,
220 birds: 0,
221 label: '',
222 labelTicks: 0,
223 milestone: 0,
224 flash: 0,
225 clouds: [{ x: 34, y: 20 }],
226 seed,
227 hi,
228 was: hi,
229 isTop: false,
230 start: seed,
231 log: '',
232 noted: 0,
233 columns: 0,
234 jumps: seen.jumps,
235 ducks: seen.ducks,
236 pauses: seen.pauses,
237 tops: seen.tops,
238 tools: seen.tools,
239 fails: seen.fails,
240 alerts: seen.alerts,
241})
242
243/** Advances the game's seed and answers a number in [0, 1). */
244const roll = (game: Game): number => {
245 game.seed = (Math.imul(game.seed, 1664525) + 1013904223) >>> 0
246
247 return game.seed / 2 ** 32
248}
249
250/** The run with one more move in its log. */
251const noted = (game: Game, move: string): Game => ({
252 ...game,
253 log: `${game.log}${game.log === '' ? '' : ' '}${game.ticks - game.noted}${move}`,
254 noted: game.ticks,
255})
256
257export const dinoSprite = (game: Game): Sprite => {
258 if (game.phase !== 'run' || game.y > 0) {
259 return STAND
260 }
261
262 const isFirstStride = Math.floor(game.ticks / 3) % 2 === 0
263
264 if (game.duck > 0) {
265 return isFirstStride ? DUCK_A : DUCK_B
266 }
267
268 return isFirstStride ? RUN_A : RUN_B
269}
270
271export const obstacleSprite = (obstacle: Obstacle, ticks: number): Sprite => {
272 if (obstacle.kind === 'cactus') {
273 return CACTI[obstacle.shape] ?? SMALL
274 }
275
276 if (obstacle.kind === 'crate') {
277 return CRATE
278 }
279
280 return Math.floor(ticks / 6) % 2 === 0 ? BIRD_UP : BIRD_DOWN
281}
282
283const isSolid = (sprite: Sprite, x: number, y: number): boolean =>
284 sprite[sprite.length - 1 - y]?.[x] === '#'
285
286const isHit = (game: Game): boolean => {
287 const dino = dinoSprite(game)
288 const dinoY = Math.round(game.y)
289
290 return game.obstacles.some(obstacle => {
291 const left = Math.round(obstacle.x) - DINO_X
292
293 if (left >= DINO_WIDTH || left <= -WIDEST) {
294 return false
295 }
296
297 const sprite = obstacleSprite(obstacle, game.ticks)
298 const bottom = obstacle.y - dinoY
299 // Only the pixels both sprites cover, counted from the dino's feet: the
300 // server plays whole runs again, and most ticks have nothing this near.
301 const top = Math.min(dino.length, bottom + sprite.length)
302 const right = Math.min(DINO_WIDTH, left + WIDEST)
303
304 for (let y = Math.max(0, bottom); y < top; y += 1) {
305 for (let x = Math.max(0, left); x < right; x += 1) {
306 if (isSolid(dino, x, y) && isSolid(sprite, x - left, y - bottom)) {
307 return true
308 }
309 }
310 }
311
312 return false
313 })
314}
315
316const spawn = (game: Game): void => {
317 const x = Math.max(game.columns, MIN_COLUMNS)
318 const isBird = scoreOf(game) >= BIRDS_FROM && roll(game) < 0.25
319
320 if (game.birds > 0) {
321 // A failed call's bird flies low: over a jump, or under a jump or a duck.
322 const y = BIRD_HEIGHTS[Math.floor(roll(game) * 2)] ?? 1
323 game.obstacles.push({ x, y, kind: 'bird', shape: 0 })
324 game.birds -= 1
325 } else if (game.crates > 0) {
326 game.obstacles.push({ x, y: 0, kind: 'crate', shape: 0 })
327 game.crates -= 1
328 } else if (isBird) {
329 const y = BIRD_HEIGHTS[Math.floor(roll(game) * BIRD_HEIGHTS.length)] ?? 1
330 game.obstacles.push({ x, y, kind: 'bird', shape: 0 })
331 } else {
332 const shapes = game.speed >= TRIPLES_FROM ? CACTI.length : CACTI.length - 1
333 game.obstacles.push({
334 x,
335 y: 0,
336 kind: 'cactus',
337 shape: Math.floor(roll(game) * shapes),
338 })
339 }
340
341 game.since = 0
342 game.gap = Math.round(game.speed * (MIN_GAP + roll(game) * 1.1))
343}
344
345export const step = (previous: Game, columns: number): Game => {
346 if (previous.phase !== 'run') {
347 return { ...previous, ticks: previous.ticks + 1 }
348 }
349
350 const sized =
351 previous.columns === columns
352 ? previous
353 : noted({ ...previous, columns }, `c${columns}`)
354 const game: Game = { ...sized, ticks: sized.ticks + 1 }
355 const isAirborne = game.y > 0 || game.vy > 0
356
357 if (isAirborne) {
358 game.y += game.vy * DT - 0.5 * GRAVITY * DT * DT
359 game.vy -= GRAVITY * DT
360
361 if (game.y <= 0) {
362 game.y = 0
363 game.vy = 0
364 }
365 }
366
367 const moved = game.speed * DT
368 game.duck = Math.max(0, game.duck - 1)
369 game.speed = Math.min(MAX_SPEED, game.speed + ACCELERATION * DT)
370 game.dist += moved
371 game.gap -= moved
372 game.since += moved
373 game.labelTicks = Math.max(0, game.labelTicks - 1)
374 game.flash = Math.max(0, game.flash - 1)
375
376 const score = scoreOf(game)
377 const isRound =
378 Math.floor(score / MILESTONE) > Math.floor(scoreOf(previous) / MILESTONE)
379
380 if (isRound) {
381 game.milestone = score - (score % MILESTONE)
382 game.flash = FLASH_TICKS
383 }
384
385 game.obstacles = previous.obstacles
386 .map(obstacle => ({ ...obstacle, x: obstacle.x - moved }))
387 .filter(obstacle => obstacle.x > -24)
388 game.clouds = previous.clouds
389 .map(cloud => ({ ...cloud, x: cloud.x - moved / 5 }))
390 .filter(cloud => cloud.x > -CLOUD_WIDTH)
391
392 const isQueued =
393 game.crates + game.birds > 0 && game.since >= game.speed * MIN_GAP
394
395 if (game.gap <= 0 || isQueued) {
396 spawn(game)
397 }
398
399 if (game.clouds.length < 3 && roll(game) < 0.008) {
400 game.clouds.push({ x: columns, y: 16 + Math.floor(roll(game) * 8) })
401 }
402
403 if (isHit(game)) {
404 game.phase = 'over'
405 game.ticks = 0
406 game.hi = Math.max(game.hi, scoreOf(game))
407 }
408
409 return game
410}
411
412export const paused = (game: Game, note: Game['note']): Game =>
413 game.phase === 'run' ? { ...game, phase: 'paused', note } : game
414
415export const toggled = (game: Game): Game =>
416 game.phase === 'paused'
417 ? { ...game, phase: 'run', isTop: false }
418 : paused(game, 'key')
419
420/** Shows the global top over a game that stands still, or takes it away. */
421export const peeked = (game: Game): Game =>
422 game.phase === 'run' ? game : { ...game, isTop: !game.isTop }
423
424export const jumped = (game: Game): Game => {
425 const isWaiting =
426 game.phase === 'ready' ||
427 (game.phase === 'over' && game.ticks >= RESTART_TICKS)
428
429 if (isWaiting) {
430 return { ...fresh(game.hi, game.seed, game), phase: 'run', vy: JUMP }
431 }
432
433 if (game.phase === 'paused') {
434 return { ...game, phase: 'run', isTop: false }
435 }
436
437 if (game.phase === 'run' && game.y === 0) {
438 return noted({ ...game, vy: JUMP, duck: 0 }, 'j')
439 }
440
441 return game
442}
443
444export const ducked = (game: Game): Game => {
445 if (game.phase !== 'run') {
446 return game
447 }
448
449 return noted(
450 {
451 ...game,
452 duck: DUCK_TICKS,
453 vy: game.y > 0 ? Math.min(game.vy, DROP) : game.vy,
454 },
455 'd',
456 )
457}
458
459/** The run with the crates and birds Claude's new calls and failures queue. */
460export const queued = (game: Game, calls: number, failures: number): Game => {
461 const crates = Math.max(0, Math.min(MAX_QUEUED, calls))
462 const birds = Math.max(0, Math.min(MAX_QUEUED, failures))
463
464 return noted(
465 {
466 ...game,
467 crates: Math.min(MAX_QUEUED, game.crates + crates),
468 birds: Math.min(MAX_QUEUED, game.birds + birds),
469 },
470 `f${crates}.${birds}`,
471 )
472}
473
474/** How far ahead of the dino the nearest obstacle is, if one is in the field. */
475const leadOf = (game: Game): Lead | undefined => {
476 const ahead = game.obstacles
477 .map(obstacle => obstacle.x - DINO_X)
478 .filter(pixels => pixels > 0)
479
480 if (ahead.length === 0) {
481 return undefined
482 }
483
484 const pixels = Math.min(...ahead)
485
486 return { pixels, ticks: pixels / (game.speed * DT) }
487}
488
489/**
490 * Plays a run again from the seed it started on and its log, and answers what
491 * it came to: undefined unless the log is exactly the one that run would have
492 * written, down to the tick it ended on an obstacle.
493 */
494export const replayed = (seed: number, log: string): Replay | undefined => {
495 if (!Number.isInteger(seed) || seed < 0 || seed >= 2 ** 32) {
496 return undefined
497 }
498
499 if (log.length > MAX_LOG) {
500 return undefined
501 }
502
503 let game: Game = { ...fresh(0, seed, UNSEEN), phase: 'run', vy: JUMP }
504 let columns = 0
505 let ticks = 0
506 const leads: Lead[] = []
507
508 for (const move of log === '' ? [] : log.split(' ')) {
509 const [, wait, key, width, calls, failures] = MOVE.exec(move) ?? []
510
511 if (wait === undefined || ticks + Number(wait) > MAX_TICKS) {
512 return undefined
513 }
514
515 for (let left = Number(wait); left > 0 && game.phase === 'run'; left -= 1) {
516 game = step(game, columns)
517 ticks += 1
518 }
519
520 if (game.phase !== 'run') {
521 return undefined
522 }
523
524 if (key === 'j') {
525 const lead = game.y === 0 ? leadOf(game) : undefined
526
527 if (lead !== undefined) {
528 leads.push(lead)
529 }
530
531 game = jumped(game)
532 } else if (key === 'd') {
533 game = ducked(game)
534 } else if (width !== undefined) {
535 columns = Number(width)
536
537 if (columns > MAX_COLUMNS) {
538 return undefined
539 }
540 } else {
541 game = queued(game, Number(calls), Number(failures))
542 }
543 }
544
545 while (game.phase === 'run' && ticks < MAX_TICKS) {
546 game = step(game, columns)
547 ticks += 1
548 }
549
550 if (game.phase !== 'over' || game.log !== log) {
551 return undefined
552 }
553
554 return { score: scoreOf(game), ticks, leads }
555}
556types/index.d.ts 75 lines1/**
2 * How many jumps, ducks, pauses and looks at the global top the person has
3 * keyed so far, and how many keys in all: the game acts on a count that
4 * moved, the field is cleared on `typed`.
5 */
6export type DinoInput = {
7 jumps: number
8 ducks: number
9 pauses: number
10 tops: number
11 typed: number
12}
13
14/**
15 * What Claude's work feeds the run: how many tool calls started and how many
16 * failed while a run was on, and the name of the latest one.
17 */
18export type DinoFeed = { tools: number; fails: number; tool: string }
19
20/**
21 * How many times Claude has asked for the person while a run was on, and why
22 * the last time: its turn is done, or it waits on a permission.
23 */
24export type DinoAlert = { count: number; reason: 'done' | 'ask' }
25
26/** What the person switched with `/dino mini`, `/dino sound` and `/dino hi`. */
27export type DinoSettings = {
28 isMini: boolean
29 isMuted: boolean
30 isHiHidden: boolean
31 /**
32 * True after `/dino close`: the mini dino, the best score under the prompt
33 * and the sounds are all away, whatever the three above say.
34 */
35 isClosed: boolean
36}
37
38/**
39 * The best run the game offers to put on the global top, once its player
40 * gives a name: 0 while it asks for none.
41 */
42export type DinoOffer = { score: number }
43
44/**
45 * The global top as the game last fetched it: its first rows, how many
46 * players it holds, and whether it is being asked for or did not answer.
47 */
48export type DinoBoard = {
49 state: 'idle' | 'asking' | 'ready' | 'silent'
50 top: { name: string; score: number }[]
51 players: number
52}
53
54/** The person's name and rank on the global top: no name until they join. */
55export type DinoStanding = { name: string; rank: number }
56
57declare module 'claude-code' {
58 interface PluginState {
59 dino: {
60 input: DinoInput
61 feed: DinoFeed
62 alert: DinoAlert
63 settings: DinoSettings
64 /** The best score so far, as the prompt footer shows it. */
65 best: number
66 /** The best run a name is asked for, 0 while none is. */
67 offer: DinoOffer
68 /** The global top, as the game shows it. */
69 board: DinoBoard
70 /** Who the person is on the global top. */
71 standing: DinoStanding
72 }
73 }
74}
75