The Chrome dinosaur runner in a pane. Press w to jump the cacti; the ground speeds up as you go, and your best distance is kept. Zero tokens.

The dinosaur runner in a pane beside the conversation, the one Chrome shows when the internet is down. Press w to jump the cacti. The lane scrolls on a clock while the pane is open and speeds up every 100 points; p pauses, Esc closes and pauses, and your best distance survives restarts.
It's a game you play, not a watcher: it never calls a model, never reads what Claude is doing, and costs no tokens. Chrome gave you a dinosaur for when the connection was down; this one is for when Claude is thinking.

Recorded in a terminal. The Desktop app draws the same pane, with each key shown as a badge.
DINO score 00142 HI 00388 · 110 ms
A tall cactus ahead: jump at the last moment to clear it.
┌────────────────────────────────────────────────────────────┐
│ │
│ D # │
│. . . . . . . . . . . . . . . . . . . . . # . . . . # # . . │
└────────────────────────────────────────────────────────────┘
w: Jump p: Pause
n: New game h: Help Esc closes and pauses
The lane is colored, which the text above can't show: the dinosaur is a D on green that turns red when you crash, the cacti are # on yellow, and the border turns red at the end of a run. Colors follow your theme.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install dino@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/dino
/dino. The pane opens with the dinosaur waiting on the ground. A fresh game shows a short how-to until the first jump; h hides or shows it any time./dino again picks up where you were, paused, so you choose when it moves.The keys work while the pane has the focus. If pressing w types into the prompt instead, click the pane first.
| Command | What it does |
|---|---|
/dino | Open the pane. Where no pane can be drawn, print the score and state. |
/dino new | Start a new game. The best score is kept. |
/dino stats | Games played, best score. |
From claude plugin validate ./mods/dino:
hooks: session.start, classic.SessionStart{source=clear|resume|fork}, command.run{command=dino}, ui.close{id=dino}, ui.render{component=Pane}
calls: $.clock.every (via startTimer), $.clock.now (via load, startNew), $.command.register, $.session.surfaces, $.state.get, $.state.set, $.store.get (via load), $.store.set (via save), $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve, $.ui.toast
$.store keeps the game and the record. A plain step is drawn but not saved; a jump, a crash, pausing, and closing the pane save, so a restart loses at most a few steps of the run. A loaded game always waits, paused, for a key, with its speed set from its score. If the store can't be read at start, the mod plays in memory and writes nothing over the saved record.$.clock spaces the cacti and scrolls the lane every speed milliseconds while the pane is open and the game runs. When the score crosses a 100-point step the clock is re-armed at the faster interval. It stops on pause, on game over, on a new game, and when the pane closes.ui.close is observed to stop the clock and pause.claude plugin validate --strict and claude plugin test passing: the rules (the waiting start, the jump arc, a press in the air, cacti spawning and walking off, the speed ramp, the clearing windows for small and tall cacti, landing on one, standing still), a player who jumps at the last moment surviving 3,000 steps on each of 20 seeds, the record, the guard, and mounted pane tests on the mock clock for jumping, scrolling, pausing, the faster clock at 100 points, crashing and the toast, the commands, an unreadable store, and a narrow pane. The test kit can't fire ui.close, so pausing on close is covered by the pause key and the load path.Boxes as snake, which draws correctly there.MIT, see the repository root.
hooks/register.ts 266 lines1// dino: the dinosaur runner, in a pane. Jump the cacti, go as far as you can.
2//
3// /dino open the game
4// /dino new start a new game
5// /dino stats games, best score
6//
7// In the pane: w jumps (the first press starts the game), p pauses and
8// resumes, n starts a new game, h shows the how-to. The lane scrolls on a
9// clock while the pane is open and speeds up as the score grows; closing the
10// pane pauses it. Nothing here calls a model, reads what Claude is doing, or
11// leaves the machine.
12
13import { atom, read, update } from 'claude-code'
14import type { Game, Stats } from '../types'
15import { COLS, DINO_COL, START_SPEED, heightOf, isGame, jump, newGame, nextObstacle, speedFor, tick } from './game'
16
17const PANE = 'dino'
18const EMPTY_STATS: Stats = { games: 0, best: 0 }
19const EMPTY: Game = { seed: 0, height: 0, jump: -1, obstacles: [], nextIn: 1, spawned: 0, score: 0, paused: true, over: false, record: false, speed: START_SPEED, stats: EMPTY_STATS }
20const game = atom({ plugin: 'dino', key: 'game' } as const, EMPTY)
21
22// The clock that scrolls the lane, while the pane is open and the game runs,
23// and the interval it was armed with: a faster score re-arms it.
24let timer: { cancel: () => void } | null = null
25let timerSpeed = 0
26// The how-to block: shown on a fresh game until the first jump, or until h has
27// set it either way for the session.
28let help: boolean | null = null
29// False after the store could not be read: then nothing is written over the
30// saved record until a later load reads it.
31let storeOk = true
32
33async function save($, g: Game) {
34 if (!storeOk || !g.seed) return
35 try {
36 await $.store.set('game', g)
37 } catch {
38 // The drawing still updates; the store catches up on the next change.
39 }
40}
41
42// Apply a change through update's read-modify-write and mirror it to the
43// store. Ticks pass `persist: false`: a step every 120 ms is drawn, not saved,
44// until something worth keeping happens.
45async function change($, fn: (cur: Game) => Game, persist = true): Promise<Game> {
46 let out: Game | null = null
47 await update($, game, (cur) => {
48 out = fn(cur)
49 return out
50 })
51 if (out && persist) await save($, out)
52 return out ?? (await read($, game))
53}
54
55async function load($) {
56 let saved: unknown = undefined
57 let readable = true
58 try {
59 saved = await $.store.get('game')
60 } catch {
61 readable = false
62 }
63 storeOk = readable
64 if (isGame(saved)) {
65 // A game is never running at load: it waits for a key. The speed always
66 // follows the score.
67 const s = saved as Game
68 await update($, game, () => ({ ...s, paused: true, speed: speedFor(s.score) }))
69 return
70 }
71 if (!readable) {
72 const cur = await read($, game)
73 if (!cur.seed) await update($, game, () => newGame(Date.now(), { ...EMPTY_STATS, games: 1 }))
74 $.ui.log('dino could not read its saved game; playing without saving')
75 return
76 }
77 const fresh = newGame(await $.clock.now(), { ...EMPTY_STATS, games: 1 })
78 await update($, game, () => fresh)
79 await save($, fresh)
80}
81
82function stopTimer() {
83 if (timer) timer.cancel()
84 timer = null
85 timerSpeed = 0
86}
87
88// Scroll the lane every `speed` ms until it pauses or the dinosaur crashes.
89// A crash is saved; plain steps are only drawn. When the score crosses a speed
90// step the clock is re-armed at the new interval.
91function startTimer($, g: Game) {
92 stopTimer()
93 if (g.paused || g.over) return
94 timerSpeed = g.speed
95 timer = $.clock.every(g.speed, async () => {
96 try {
97 let ended = false
98 const after = await change($, (cur) => {
99 const n = tick(cur)
100 ended = n.over && !cur.over
101 return n
102 }, false)
103 if (after.over || after.paused) stopTimer()
104 else if (after.speed !== timerSpeed) startTimer($, after)
105 if (ended || after.paused) await save($, after)
106 if (ended) $.ui.toast('Game over: ' + after.score + (after.record ? ', a new best! ' : '. ') + 'New game: n', { timeoutMs: 5000 })
107 } catch {
108 // A failed tick is skipped; the next one tries again.
109 }
110 })
111}
112
113async function startNew($) {
114 stopTimer()
115 const now = await $.clock.now()
116 return change($, (cur) => newGame(now, { ...cur.stats, games: cur.stats.games + 1 }))
117}
118
119async function pause($, on: boolean) {
120 const g = await change($, (cur) => (cur.over || cur.paused === on ? cur : { ...cur, paused: on }))
121 if (on) stopTimer()
122 else startTimer($, g)
123 return g
124}
125
126const pad = (n: number) => String(n).padStart(5, '0')
127
128function summary(g: Game): string {
129 if (!g.seed) return 'No game yet.'
130 const state = g.over ? 'Game over.' : g.paused ? 'Paused.' : 'Running.'
131 return 'Dino: score ' + g.score + ', best ' + g.stats.best + ', ' + g.speed + ' ms a step. ' + state + ' /dino opens the pane.'
132}
133
134export function register(on) {
135 on('session.start', async ($, e, next) => {
136 help = null
137 try {
138 await load($)
139 } catch (error) {
140 $.ui.log('dino could not load its game: ' + error)
141 }
142 try {
143 await $.command.register({ name: 'dino', description: 'Play the dinosaur runner in a pane', argumentHint: '[new | stats]', immediate: true })
144 } catch (error) {
145 $.ui.log('could not register /dino: ' + error)
146 }
147 return next(e)
148 })
149
150 // /clear and /resume reset $.state; the game lives on in the store.
151 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
152 stopTimer()
153 await load($)
154 return next(e)
155 }).catch(async ($, e, next) => next(e))
156
157 on('command.run', { command: 'dino' }, async ($, e) => {
158 const args = e.args.trim().toLowerCase()
159 if (args === 'new') return { text: 'New game. ' + summary(await startNew($)) }
160 if (args === 'stats') {
161 const s = (await read($, game)).stats
162 return { text: 'Games ' + s.games + ' · best ' + s.best }
163 }
164 if (args) return { text: 'Usage: /dino, /dino new, or /dino stats' }
165 // Under claude -p nothing draws, and ui.open still reports the pane as placed:
166 // the surfaces list is the reliable sign.
167 let surfaces = []
168 try {
169 surfaces = await $.session.surfaces()
170 } catch {
171 surfaces = []
172 }
173 if (!surfaces.length) return { text: summary(await read($, game)) }
174 const placed = await $.ui.open({ id: PANE, title: 'Dino', focus: true, closeOnEscape: true, rows: 18 })
175 if (!placed.isPlaced) return { text: summary(await read($, game)) }
176 return {}
177 }).catch(async () => ({ text: 'dino: the command failed.' }))
178
179 // Closing the pane pauses the game.
180 on('ui.close', { id: PANE }, async ($, e, next) => {
181 stopTimer()
182 await change($, (cur) => (cur.seed && !cur.paused ? { ...cur, paused: true } : cur))
183 return next(e)
184 }).catch(async ($, e, next) => next(e))
185
186 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
187 if (e.requestId !== PANE) return next(e)
188 const g = await read($, game)
189 const { Box, Text, Button } = $.ui.resolve(e)
190 if (!g.seed) return Text({ children: ['No game loaded. /dino new starts one.'] })
191 const columns = e.props.bodyColumns || 60
192 const wide = columns >= 50
193 const showHelp = help ?? (wide && g.score === 0 && g.jump < 0)
194 const ahead = nextObstacle(g)
195
196 const status = g.over ? { text: 'Game over at ' + g.score + (g.record ? ', a new best. ' : '. ') + 'Press n for a new game.', color: 'error', bold: true }
197 : g.score === 0 && g.jump < 0 ? { text: 'Press w to jump. The first jump starts the run.', color: 'suggestion' }
198 : g.paused ? { text: 'Paused. Press p, or w, to go on.', color: 'warning' }
199 : ahead && ahead.tall ? { text: 'A tall cactus ahead: jump at the last moment to clear it.', color: 'warning' }
200 : { text: 'Jump the cacti. The ground gets faster as you go.', color: 'suggestion' }
201
202 const header = Box({ flexDirection: 'column', children: [
203 Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
204 Text({ bold: true, children: ['DINO'] }),
205 Text({ children: ['score ', Text({ bold: true, children: [pad(g.score)] })] }),
206 Text({ dimColor: true, children: ['HI ' + pad(g.stats.best) + ' · ' + g.speed + ' ms'] }),
207 ] }),
208 Text({ color: status.color, bold: status.bold, wrap: 'wrap', children: [status.text] }),
209 ] })
210
211 // The lane is three rows of two-column Boxes, the ground row at the bottom.
212 // The dinosaur sits at its height in column DINO_COL; a cactus fills its
213 // column from the ground up to its height. Colors carry what is where, and
214 // the glyphs are plain ASCII so every surface lines them up.
215 const cacti = new Map<number, number>()
216 for (const o of g.obstacles) cacti.set(o.col, Math.max(cacti.get(o.col) ?? 0, heightOf(o)))
217 const cell = (y: number, c: number) => {
218 const dino = c === DINO_COL && y === g.height
219 const cactus = (cacti.get(c) ?? 0) > y
220 const kind = dino ? 'dino' : cactus ? 'cactus' : y === 0 ? 'ground' : 'sky'
221 const backgroundColor = kind === 'dino' ? (g.over ? 'error' : 'success') : kind === 'cactus' ? 'warning' : undefined
222 const glyph = kind === 'dino' ? 'D' : kind === 'cactus' ? '#' : kind === 'ground' ? '.' : ' '
223 return Box({ width: 2, backgroundColor, children: [Text({ bold: kind === 'dino', color: backgroundColor ? 'inverseText' : undefined, dimColor: kind === 'ground', children: [glyph] })] })
224 }
225 const lane = Box({ flexDirection: 'column', borderStyle: 'single', borderColor: g.over ? 'error' : 'inactive', children: [2, 1, 0].map((y) => Box({ flexDirection: 'row', children: Array.from({ length: COLS }, (_, c) => cell(y, c)) })) })
226
227 const controls = Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
228 Button({ key: 'w', label: 'Jump', hotkey: 'w', plain: true, onPress: async () => {
229 const updated = await change($, jump)
230 if (updated.over) {
231 $.ui.toast('The game is over. Press n for a new one.')
232 return
233 }
234 if (!timer) startTimer($, updated)
235 } }),
236 Button({ key: 'pause', label: g.paused ? 'Resume' : 'Pause', hotkey: 'p', plain: true, onPress: async () => {
237 const cur = await read($, game)
238 if (cur.over) {
239 $.ui.toast('The game is over. Press n for a new one.')
240 return
241 }
242 await pause($, !cur.paused)
243 } }),
244 ] })
245
246 const howTo = Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'suggestion', paddingX: 1, children: [
247 Text({ bold: true, children: ['How to play'] }),
248 Text({ wrap: 'wrap', children: ['1. Press w to jump. The lane starts scrolling, and cacti come at you from the right.'] }),
249 Text({ wrap: 'wrap', children: ['2. A small cactus is one cell high and a tall one is two: the tall ones only clear at the top of a jump, so jump late.'] }),
250 Text({ wrap: 'wrap', children: ['3. Every 100 points the ground gets faster. Hitting a cactus ends the run. p pauses.'] }),
251 Text({ dimColor: true, wrap: 'wrap', children: ['Keys need the pane focused: click it if nothing happens. h hides this.'] }),
252 ] })
253
254 const footer = Box({ flexDirection: 'row', columnGap: 3, flexWrap: 'wrap', children: [
255 Button({ key: 'new', label: 'New game', hotkey: 'n', plain: true, onPress: () => startNew($) }),
256 Button({ key: 'help', label: showHelp ? 'Hide help' : 'Help', hotkey: 'h', plain: true, dimColor: true, onPress: () => {
257 help = !showHelp
258 $.ui.invalidate('ui.render')
259 } }),
260 Text({ dimColor: true, children: ['Esc closes and pauses'] }),
261 ] })
262
263 return Box({ flexDirection: 'column', rowGap: 1, children: showHelp ? [header, lane, controls, howTo, footer] : [header, lane, controls, footer] })
264 })
265}
266hooks/game.ts 107 lines1// The rules of the dinosaur runner, pure: no `$` here, so the tests and the
2// hooks module share one implementation.
3
4import type { Game, Obstacle, Stats } from '../types'
5
6export const COLS = 30
7export const DINO_COL = 4
8/** The dinosaur's height on each tick of a jump. */
9export const ARC = [1, 2, 2, 2, 1]
10export const START_SPEED = 120
11export const MIN_SPEED = 60
12/** Every RAMP points the ticks get SPEED_STEP ms faster, down to MIN_SPEED. */
13export const RAMP = 100
14export const SPEED_STEP = 10
15/** Ticks between cacti: never fewer than a jump plus a breath, so every one can be cleared. */
16export const MIN_GAP = ARC.length + 3
17export const MAX_GAP = 14
18
19function hash(s: string): number {
20 let h = 2166136261
21 for (let i = 0; i < s.length; i++) {
22 h ^= s.charCodeAt(i)
23 h = Math.imul(h, 16777619)
24 }
25 return h >>> 0
26}
27
28function rng(seed: number): () => number {
29 let a = seed
30 return () => {
31 a = (a + 0x6d2b79f5) | 0
32 let t = Math.imul(a ^ (a >>> 15), 1 | a)
33 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
34 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
35 }
36}
37
38// The spacing and shape of cactus n, deterministic from the seed.
39export function gapAfter(seed: number, n: number): number {
40 const r = rng(hash(seed + ':gap:' + n))
41 return MIN_GAP + Math.floor(r() * (MAX_GAP - MIN_GAP + 1))
42}
43
44export function isTall(seed: number, n: number): boolean {
45 return rng(hash(seed + ':tall:' + n))() < 0.4
46}
47
48export const heightOf = (o: Obstacle) => (o.tall ? 2 : 1)
49
50export function speedFor(score: number): number {
51 return Math.max(MIN_SPEED, START_SPEED - Math.floor(score / RAMP) * SPEED_STEP)
52}
53
54export function newGame(seed: number, stats: Stats): Game {
55 return { seed, height: 0, jump: -1, obstacles: [], nextIn: gapAfter(seed, 0), spawned: 0, score: 0, paused: true, over: false, record: false, speed: START_SPEED, stats }
56}
57
58// Jump. The dinosaur leaves the ground at once and lands after the arc; a
59// press in the air does nothing but resume a paused game. The first jump
60// starts the game.
61export function jump(g: Game): Game {
62 if (g.over) return g
63 if (g.jump >= 0) return g.paused ? { ...g, paused: false } : g
64 return { ...g, jump: 0, height: ARC[0], paused: false }
65}
66
67// One tick: the dinosaur moves along its arc, every cactus walks one column
68// left, a new one appears when its turn comes, and a cactus taller than the
69// dinosaur is high at the dinosaur's column ends the game. Surviving scores a
70// point and may speed the clock up.
71export function tick(g: Game): Game {
72 if (g.over || g.paused) return g
73 const jumpNext = g.jump >= 0 ? g.jump + 1 : -1
74 const jumpIdx = jumpNext >= ARC.length ? -1 : jumpNext
75 const height = jumpIdx >= 0 ? ARC[jumpIdx] : 0
76 let obstacles = g.obstacles.map((o) => ({ ...o, col: o.col - 1 })).filter((o) => o.col >= 0)
77 let nextIn = g.nextIn - 1
78 let spawned = g.spawned
79 if (nextIn <= 0) {
80 obstacles = [...obstacles, { col: COLS - 1, tall: isTall(g.seed, spawned) }]
81 spawned++
82 nextIn = gapAfter(g.seed, spawned)
83 }
84 const hit = obstacles.some((o) => o.col === DINO_COL && heightOf(o) > height)
85 if (hit) return { ...g, jump: jumpIdx, height, obstacles, nextIn, spawned, over: true, paused: true }
86 const score = g.score + 1
87 const record = g.record || score > g.stats.best
88 const stats: Stats = { ...g.stats, best: Math.max(g.stats.best, score) }
89 return { ...g, jump: jumpIdx, height, obstacles, nextIn, spawned, score, record, stats, speed: speedFor(score) }
90}
91
92// The nearest cactus still ahead of the dinosaur, for the status line.
93export function nextObstacle(g: Game): Obstacle | undefined {
94 return g.obstacles.filter((o) => o.col >= DINO_COL).sort((a, b) => a.col - b.col)[0]
95}
96
97export function isGame(x: unknown): x is Game {
98 if (!x || typeof x !== 'object') return false
99 const g = x as Record<string, unknown>
100 const stats = g.stats as Record<string, unknown> | undefined
101 const obstacle = (o: unknown) => !!o && typeof o === 'object' && Number.isInteger((o as Obstacle).col) && (o as Obstacle).col >= 0 && (o as Obstacle).col < COLS && typeof (o as Obstacle).tall === 'boolean'
102 return typeof g.seed === 'number' && Number.isInteger(g.height) && (g.height as number) >= 0 && (g.height as number) <= 2 && Number.isInteger(g.jump) && (g.jump as number) >= -1 && (g.jump as number) < ARC.length &&
103 Array.isArray(g.obstacles) && g.obstacles.every(obstacle) && Number.isInteger(g.nextIn) && Number.isInteger(g.spawned) && Number.isInteger(g.score) &&
104 typeof g.paused === 'boolean' && typeof g.over === 'boolean' && typeof g.record === 'boolean' && Number.isInteger(g.speed) &&
105 !!stats && typeof stats === 'object' && ['games', 'best'].every((k) => Number.isInteger(stats[k]))
106}
107types/index.d.ts 37 lines1export type Obstacle = {
2 /** Column on the lane, 0 at the left; the cactus walks left one column a tick. */
3 col: number
4 /** Two cells high: only the top of a jump clears it. */
5 tall: boolean
6}
7export type Stats = { games: number; best: number }
8export type Game = {
9 /** Seed of this game's cactus spacing; 0 until a game exists. */
10 seed: number
11 /** The dinosaur's height above the ground, 0 to 2. */
12 height: number
13 /** Which tick of the jump arc the dinosaur is on; -1 on the ground. */
14 jump: number
15 obstacles: Obstacle[]
16 /** Ticks until the next cactus appears at the right edge. */
17 nextIn: number
18 /** Cacti spawned so far; also the spacing counter. */
19 spawned: number
20 /** Ticks survived. */
21 score: number
22 /** Not moving: a fresh game, or paused with p or by closing the pane. */
23 paused: boolean
24 over: boolean
25 /** This game has beaten the best score it started with. */
26 record: boolean
27 /** Milliseconds between ticks; shrinks as the score grows. */
28 speed: number
29 stats: Stats
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 dino: { game: Game }
35 }
36}
37