SLOPSHOPPER

dino

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

newpanebandspinnerguardcommand
v0.4.3MITupdated 2026-10-05barisdemirhan/claude-dino
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dino
│ ┃ Dino ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./dino.tsx │ ┃ keys: space jump · s duck · p pause · t top ⏺ Read(src/auth.ts) │ ┃ ctrl+x tab gives the game the keys · then ⎿ Read 6 lines │ ┃ space/w jump · s duck · p pause · t top ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dino │ ⎿ dino: Dino is open: space to jump, s to duck, p to pause. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dino
▣ client module ./dino.tsx keys: space jump · s duck · p pause · t top ⏎ jump ctrl+x tab gives the game the keys · then space/w jump · s duck · p pause · t top
README

claude-dino

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

Install

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.

If you installed from claude-dino

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

Play

KeyAction
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
pPause, and run on again
tShow the global top over the field, between runs
escGive the keys back to the prompt
ctrl+x tabGive 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.

CommandWhat it does
/dino or /dino playOpens the game
/dino stopCloses the game. The mini dino and your best score under the prompt stay
/dino closeTakes 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 miniTurns 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 soundTurns the sounds off or on. /dino sound on and /dino sound off say which
/dino hiTakes your best score off the line under the prompt, or puts it back. /dino hi on and /dino hi off say which
/dino statsYour runs so far, your best and your average
/dino topThe 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 leaveTakes you off the global top and deletes your runs there

It plays along with Claude

  • It pauses when Claude needs you. When Claude finishes its turn or asks for a permission in the middle of a run, the run is held and a toast says why. Space runs it on.
  • Claude's work is the obstacle course. While a run is on, every tool call Claude makes sends a crate your way, with the tool's name in the corner, and every call that fails sends a low bird. They never come closer together than the game's own obstacles do.

The global top

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:

  • Your obstacles are partly Claude's doing, so no two people run the same course.
  • A run only counts under the rules it was played by. When an update changes the rules, an older plugin is told to update before its runs go up again.

Requirements

  • A Claude Code build with mod support (plugins that ship a hooks module). Built and tested on 2.1.287. Mods sit behind a rollout switch, so if /dino does not show up after installing, the switch may still be off for you.
  • The terminal or the desktop app. Other surfaces show a notice instead of the game.
  • A pane at least 30 columns wide and 8 rows tall.
  • Sound needs macOS, where Claude Code has a player for it. Elsewhere the game is silent.

Privacy and data handling

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.
  • Joining sends the name you chose, a random id and a random secret made on your machine. The secret is what makes a run yours.
  • Each run that beats your best there sends its seed and its moves: when you jumped and ducked, how wide the pane was, and how many crates and birds Claude's work queued and when. The tools' names are not in it, nor anything else of your session.

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.

Hooks

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.

Develop

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.

The leaderboard's server

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

RouteWhat it does
GET /admin/flaggedThe 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

More mods

From the same marketplace, claude-mods:

  • ambient: a living band above the prompt, with sound, fed by Claude's work.
  • pomodoro: a pomodoro timer whose break lands while Claude works.
  • tycoon: Token Tycoon, an idle game where Claude's tool calls earn the money.

License

MIT

Source 6 files
hooks/register.tsx 864 lines
1import { 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}
864
hooks/board.ts 203 lines
1// 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}
203
hooks/mini.tsx 216 lines
1import 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
216
hooks/dino.tsx 400 lines
1import 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
400
hooks/sim.ts 556 lines
1// 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}
556
types/index.d.ts 75 lines
1/**
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