Claude Run: an endless-runner game above the prompt. Jump Claude over bugs and duck under hallucination moths.

A one-button endless-runner game for Claude Code. It's like the browser's offline dinosaur game, but you play as a pixel Claude. It stays hidden until you call it up, for example while Claude works through a long prompt. The only move is a jump: jump over bugs and stacks of failing tests, and time it so you don't jump into a hallucination moth.
▄▄▄▄ HI 00412 00187
▀▀▀▀▀▀▀ ▄▄▄▄
█▀██▀█
▀██████▀ ▀▄██▄▀
█▀ ▀█ ▀█▀▀█▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Type this at the prompt of a Claude Code terminal session:
/plugin install prompt-runner --marketplace yurmix/prompt-runner
Answer y to add the marketplace, then press Enter to pick the user scope. The plugin is active right away, in the terminal and in the desktop app's Code tab.
Run /prompt-runner to open the game, even while Claude is working. Run it again, or press Esc, to put the game away and get your prompt back.
Once the game is open, there is one button. It jumps, and it starts the next run after a crash.
Your best score is saved between sessions.
The first time the plugin loads, it binds ctrl+x g to /prompt-runner in ~/.claude/keybindings.json and shows a short notice. Claude Code doesn't use ctrl+x g for anything else. After that, ctrl+x g opens the game and closes it again.
It leaves your file alone if ctrl+x g is already taken, if you've already bound /prompt-runner to another key, or if the file isn't plain JSON (it has comments, for example). It only tries once, so if you remove or change the binding, it stays the way you left it.
To bind it yourself, or to pick another key, add this to ~/.claude/keybindings.json:
{
"bindings": [
{
"context": "Chat",
"bindings": { "ctrl+x g": "command:prompt-runner" }
}
]
}
If the file already has a bindings list, add the block to it. ctrl+d won't work, because Claude Code reserves it for exit.
The game speeds up as you go, and the score flashes at every 100.
The game uses the Claude Code plugin API's Client element, which only the terminal and the Claude desktop app draw. On the mobile app and VS Code, /prompt-runner opens a note saying so instead.
claude plugin validate .
claude plugin test .
hooks/register.tsx is the hooks module. It adds the /prompt-runner command, opens and closes the game's pane, and keeps the best score. hooks/runner.tsx is the game itself, drawn with half-block characters so each terminal cell holds two pixels.
MIT
hooks/register.tsx 125 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const isPlaying = atom({ plugin: 'prompt-runner', key: 'isPlaying' } as const, false)
5const best = atom({ plugin: 'prompt-runner', key: 'best' } as const, 0)
6
7const PANE = 'prompt-runner'
8
9// The shortcut the plugin binds for you, once, in ~/.claude/keybindings.json.
10const KEY = 'ctrl+x g'
11const ACTION = 'command:prompt-runner'
12
13type Block = { context?: unknown; bindings?: Record<string, unknown> }
14
15// Adds KEY -> ACTION to the person's keybindings file, unless the action is
16// bound already or KEY is taken in Chat. A file that is not plain JSON (it has
17// comments, say) is left alone. Resolves whether it wrote the binding.
18async function bindKey($: EngineInterface): Promise<boolean> {
19 const dir =
20 (await $.env.get('CLAUDE_CONFIG_DIR')) ??
21 `${(await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''}/.claude`
22 if (dir === '/.claude') return false
23 const path = `${dir}/keybindings.json`
24
25 let file: { bindings: Block[] } = { bindings: [] }
26 if (await $.fs.exists(path)) {
27 try {
28 const parsed = JSON.parse(await $.fs.read(path)) as { bindings?: unknown }
29 if (!Array.isArray(parsed?.bindings)) return false
30 file = parsed as { bindings: Block[] }
31 } catch {
32 return false
33 }
34 }
35
36 const blocks = file.bindings
37 if (blocks.some(b => Object.values(b?.bindings ?? {}).includes(ACTION))) return false
38 let chat = blocks.find(b => b?.context === 'Chat' && b.bindings && typeof b.bindings === 'object')
39 if (chat?.bindings && KEY in chat.bindings) return false
40 if (!chat) {
41 chat = { context: 'Chat', bindings: {} }
42 blocks.push(chat)
43 }
44 chat.bindings = { ...chat.bindings, [KEY]: ACTION }
45
46 await $.fs.write(path, `${JSON.stringify(file, null, 2)}\n`)
47 return true
48}
49
50export const register: Register = on => {
51 on('session.start', async ($, e, next) => {
52 await $.command.register({
53 name: 'prompt-runner',
54 description: 'Play Claude Run while you wait. Run it again, or press Esc, to put it away',
55 // Runs at once while Claude is working, which is when you want it.
56 immediate: true,
57 })
58 // Bind the shortcut the first time the plugin loads. Only once: if you
59 // remove or change it later, it stays the way you left it.
60 if ((await $.store.get('keyOffered')) !== true) {
61 await $.store.set('keyOffered', true)
62 const bound = await bindKey($).catch(() => false)
63 if (bound) $.ui.toast(`Claude Run: press ${KEY} to play while you wait.`)
64 }
65
66 const saved = Number((await $.store.get('best')) ?? 0)
67 if (Number.isFinite(saved) && saved > 0) {
68 await update($, best, n => Math.max(n ?? 0, saved))
69 }
70
71 return next(e)
72 })
73
74 // /prompt-runner opens the game with the keyboard on it, and closes it if
75 // it is already open. Bind it to a key ("command:prompt-runner") to make
76 // that one press.
77 on('command.run', { command: 'prompt-runner' }, async ($, e) => {
78 if (await read($, isPlaying)) {
79 await $.ui.close({ id: PANE })
80 return { text: 'Claude Run is put away.' }
81 }
82
83 await update($, isPlaying, () => true)
84 await $.ui.open({ id: PANE, title: 'Claude Run', focus: true, closeOnEscape: true, rows: 8 })
85 await $.ui.focus({ requestId: PANE, key: 'game' }).catch(() => ({}))
86
87 return { text: 'Claude Run is open. Space or a tap jumps. Esc puts it away.' }
88 })
89
90 on('ui.close', { id: PANE }, async ($, e, next) => {
91 await update($, isPlaying, () => false)
92 return next(e)
93 })
94
95 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
96 const top = await read($, best)
97 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
98 const { Text } = $.ui.resolve(e)
99 return <Text dimColor>Claude Run plays in the terminal and the desktop app.</Text>
100 }
101 const { Client } = $.ui.resolve(e)
102
103 return (
104 <Client
105 key="game"
106 module="./runner.tsx"
107 width={e.props.bodyColumns}
108 props={{ best: top, columns: e.props.bodyColumns, autoStart: true }}
109 />
110 )
111 })
112
113 // The game posts { score } when a run ends; keep the best one.
114 on('ui.message', async ($, e, next) => {
115 const data = e.data as { score?: unknown } | null
116 const score = typeof data?.score === 'number' ? Math.floor(data.score) : NaN
117 if (Number.isFinite(score) && score > 0 && score > (await read($, best))) {
118 await update($, best, n => Math.max(n ?? 0, score))
119 await $.store.set('best', score)
120 }
121
122 return next(e)
123 })
124}
125hooks/runner.tsx 423 lines1import type { ClientModule, ClientSurface, RenderElement } from 'claude-code'
2
3// Claude Run: a one-button endless runner drawn with half-block characters,
4// so each terminal cell holds two square-ish pixels (top and bottom). The
5// only move is a jump: tap anywhere on touch, or press Space on a keyboard.
6
7type Props = { best?: number; columns?: number; autoStart?: boolean }
8
9type Kind = 'bug' | 'bugs' | 'stack' | 'moth' | 'mothHigh'
10type Obstacle = { kind: Kind; x: number; top: number; w: number; hgt: number }
11type Cloud = { x: number; y: number }
12
13type Game = {
14 mode: 'idle' | 'run' | 'over'
15 t: number
16 overAt: number
17 jump: number // pixels above the ground
18 vy: number
19 speed: number
20 dist: number
21 score: number
22 best: number
23 posted: boolean
24 obs: Obstacle[]
25 clouds: Cloud[]
26 gap: number
27 flash: number
28}
29
30const TICK_MS = 40
31const FIELD_H = 14 // pixels: 7 rows of cells
32const FEET = 11 // the avatar's lowest pixel row when on the ground
33const GROUND = 12
34const PEBBLES = 13
35const AV_X = 4
36const GRAVITY = 0.32
37const JUMP_V = 2.0
38
39// One character per pixel; '.' is empty.
40const COLORS: Record<string, string> = {
41 O: 'claude',
42 K: '#1b1a18',
43 R: 'error',
44 G: '#9b978f',
45 g: '#6e6a63',
46 P: '#a990f0',
47 c: 'subtle',
48 _: 'inactive',
49}
50
51const BODY = ['.OOOOOO.', '.OKOOKO.', 'OOOOOOOO', '.OOOOOO.']
52const BODY_BLINK = ['.OOOOOO.', '.OOOOOO.', 'OOOOOOOO', '.OOOOOO.']
53const BODY_DEAD = ['.OOOOOO.', '.OROORO.', 'OOOOOOOO', '.OOOOOO.']
54const LEGS_A = ['.OO..OO.', '.O....O.']
55const LEGS_B = ['.OO..OO.', '..O..O..']
56const LEGS_AIR = ['.OO..OO.', '........']
57
58const SPRITES: Record<Kind, string[][]> = {
59 bug: [['R.RR.R', '.RRRR.', 'RRRRRR', '.R..R.']],
60 bugs: [['R.RR.R.R.RR.R', '.RRRR...RRRR.', 'RRRRRR.RRRRRR', '.R..R...R..R.']],
61 stack: [['GGGG', 'gggg', 'GGGG', 'gggg', 'GGGG']],
62 moth: [
63 ['P.....P', '.PPPPP.', '..P.P..'],
64 ['.......', 'PPPPPPP', '..P.P..'],
65 ],
66 mothHigh: [
67 ['P.....P', '.PPPPP.', '..P.P..'],
68 ['.......', 'PPPPPPP', '..P.P..'],
69 ],
70}
71const CLOUD = ['..cccc..', 'cccccccc']
72const CLOUD_W = 8
73
74// The one button: Space, however the host names it.
75const BUTTON_KEYS = new Set([' ', 'space'])
76
77function fresh(best: number): Game {
78 return {
79 mode: 'idle',
80 t: 0,
81 overAt: 0,
82 jump: 0,
83 vy: 0,
84 speed: 1.5,
85 dist: 0,
86 score: 0,
87 best,
88 posted: false,
89 obs: [],
90 clouds: [],
91 gap: 0,
92 flash: 0,
93 }
94}
95
96function start(g: Game, width: number): void {
97 g.mode = 'run'
98 g.t = 0
99 g.jump = 0
100 g.vy = 0
101 g.speed = 1.5
102 g.dist = 0
103 g.score = 0
104 g.posted = false
105 g.obs = []
106 g.clouds = [
107 { x: Math.floor(width * 0.3), y: 1 },
108 { x: Math.floor(width * 0.75), y: 3 },
109 ]
110 g.gap = width * 0.6
111 g.flash = 0
112}
113
114function makeObstacle(kind: Kind, x: number): Obstacle {
115 const art = SPRITES[kind][0] ?? []
116 const w = art[0]?.length ?? 0
117 const hgt = art.length
118 // A low moth skims the ground, so jump it like a bug. A high moth flies
119 // over a running Claude but meets a jumping one, so don't press.
120 const top = kind === 'moth' ? FEET - hgt : kind === 'mothHigh' ? 1 : FEET - hgt + 1
121 return { kind, x, top, w, hgt }
122}
123
124function pickKind(g: Game): Kind {
125 const roll = Math.random()
126 if (g.score > 150 && roll < 0.2) return Math.random() < 0.6 ? 'moth' : 'mothHigh'
127 if (g.speed > 2.2 && roll < 0.4) return 'bugs'
128 if (g.score > 40 && roll < 0.65) return 'stack'
129 return 'bug'
130}
131
132function avatarBox(g: Game): [number, number, number, number] {
133 const bottom = Math.round(FEET - g.jump)
134 return [AV_X + 1, bottom - 5, AV_X + 6, bottom]
135}
136
137function hits(g: Game, o: Obstacle): boolean {
138 const [ax0, ay0, ax1, ay1] = avatarBox(g)
139 const ox0 = Math.round(o.x) + 1
140 const ox1 = Math.round(o.x) + o.w - 2
141 const oy0 = o.top
142 const oy1 = o.top + o.hgt - 1
143 return ax0 <= ox1 && ax1 >= ox0 && ay0 <= oy1 && ay1 >= oy0
144}
145
146function jump(g: Game): void {
147 if (g.jump <= 0 && g.vy === 0) g.vy = JUMP_V
148}
149
150function step(g: Game, width: number): void {
151 g.t++
152 if (g.mode !== 'run') return
153
154 // Avatar
155 if (g.vy !== 0 || g.jump > 0) {
156 g.jump += g.vy
157 g.vy -= GRAVITY
158 if (g.jump <= 0) {
159 g.jump = 0
160 g.vy = 0
161 }
162 }
163
164 // World
165 g.speed = Math.min(3.6, 1.5 + g.score / 600)
166 g.dist += g.speed
167 const before = Math.floor(g.score / 100)
168 g.score = Math.floor(g.dist * 0.25)
169 if (Math.floor(g.score / 100) > before) g.flash = 24
170 if (g.flash > 0) g.flash--
171
172 for (const o of g.obs) o.x -= g.speed
173 g.obs = g.obs.filter(o => o.x + o.w > -2)
174 for (const c of g.clouds) {
175 c.x -= g.speed * 0.25
176 if (c.x < -CLOUD_W) {
177 c.x = width + Math.random() * 20
178 c.y = 1 + Math.floor(Math.random() * 3)
179 }
180 }
181
182 g.gap -= g.speed
183 if (g.gap <= 0) {
184 g.obs.push(makeObstacle(pickKind(g), width))
185 g.gap = g.speed * 14 + 10 + Math.random() * (g.speed * 22 + 24)
186 }
187
188 for (const o of g.obs) {
189 if (hits(g, o)) {
190 g.mode = 'over'
191 g.overAt = g.t
192 g.best = Math.max(g.best, g.score)
193 break
194 }
195 }
196}
197
198// Drawing ------------------------------------------------------------------
199
200type Pixels = { width: number; height: number; px: (string | undefined)[] }
201
202function canvas(width: number, height: number): Pixels {
203 return { width, height, px: new Array(width * height).fill(undefined) }
204}
205
206function blit(c: Pixels, art: string[], x: number, y: number): void {
207 const x0 = Math.round(x)
208 for (let r = 0; r < art.length; r++) {
209 const py = y + r
210 if (py < 0 || py >= c.height) continue
211 const line = art[r] ?? ''
212 for (let i = 0; i < line.length; i++) {
213 const color = COLORS[line[i] ?? '.']
214 const px = x0 + i
215 if (!color || px < 0 || px >= c.width) continue
216 c.px[py * c.width + px] = color
217 }
218 }
219}
220
221function avatarArt(g: Game): { art: string[]; y: number } {
222 if (g.mode === 'over') {
223 return { art: [...BODY_DEAD, ...LEGS_AIR], y: Math.round(FEET - g.jump) - 5 }
224 }
225 const blink = g.mode === 'idle' && g.t % 90 < 4
226 const body = blink ? BODY_BLINK : BODY
227 const legs =
228 g.mode === 'idle' ? LEGS_A : g.jump > 0 ? LEGS_AIR : Math.floor(g.t / 3) % 2 ? LEGS_A : LEGS_B
229 return { art: [...body, ...legs], y: Math.round(FEET - g.jump) - 5 }
230}
231
232function drawField(g: Game, width: number): Pixels {
233 const c = canvas(width, FIELD_H)
234 for (const cl of g.clouds) blit(c, CLOUD, cl.x, cl.y)
235 for (let x = 0; x < width; x++) {
236 c.px[GROUND * width + x] = COLORS._
237 if ((Math.imul(x + Math.floor(g.dist), 2654435761) >>> 0) % 19 === 0) c.px[PEBBLES * width + x] = COLORS._
238 }
239 for (const o of g.obs) {
240 const frames = SPRITES[o.kind]
241 blit(c, frames[Math.floor(g.t / 6) % frames.length] ?? [], o.x, o.top)
242 }
243 const a = avatarArt(g)
244 blit(c, a.art, AV_X, a.y)
245 return c
246}
247
248type Run = { text: string; fg?: string; bg?: string }
249
250function rowsOf(c: Pixels): Run[][] {
251 const rows: Run[][] = []
252 for (let y = 0; y < c.height; y += 2) {
253 const runs: Run[] = []
254 for (let x = 0; x < c.width; x++) {
255 const top = c.px[y * c.width + x]
256 const bot = y + 1 < c.height ? c.px[(y + 1) * c.width + x] : undefined
257 let ch = ' '
258 let fg: string | undefined
259 let bg: string | undefined
260 if (top && bot) {
261 if (top === bot) {
262 ch = '█'
263 fg = top
264 } else {
265 ch = '▀'
266 fg = top
267 bg = bot
268 }
269 } else if (top) {
270 ch = '▀'
271 fg = top
272 } else if (bot) {
273 ch = '▄'
274 fg = bot
275 }
276 const last = runs[runs.length - 1]
277 if (last && last.fg === fg && last.bg === bg) last.text += ch
278 else runs.push({ text: ch, fg, bg })
279 }
280 rows.push(runs)
281 }
282 return rows
283}
284
285function pad(n: number): string {
286 return String(Math.max(0, Math.floor(n))).padStart(5, '0')
287}
288
289function draw(g: Game, width: number, surface: ClientSurface<Game>): RenderElement {
290 const { Box, Text } = surface.elements
291
292 const paint = (runs: Run[]): RenderElement[] =>
293 runs.map(r =>
294 r.fg && r.bg ? (
295 <Text color={r.fg} backgroundColor={r.bg}>{r.text}</Text>
296 ) : r.fg ? (
297 <Text color={r.fg}>{r.text}</Text>
298 ) : (
299 <Text>{r.text}</Text>
300 ),
301 )
302
303 if (g.mode === 'idle') {
304 // A compact strip: Claude waiting on a patch of ground, and the hint.
305 const c = canvas(12, 8)
306 for (let x = 0; x < 12; x++) c.px[6 * 12 + x] = COLORS._
307 const a = avatarArt(g)
308 blit(c, a.art, 2, a.y - (FEET - 5))
309 const rows = rowsOf(c)
310 const hint =
311 width >= 70
312 ? 'Tap or press Space to play. One button: jump.'
313 : 'Tap or Space to play and jump'
314 return (
315 <Box flexDirection="row">
316 <Box flexDirection="column">
317 {rows.map(runs => (
318 <Box flexDirection="row">{paint(runs)}</Box>
319 ))}
320 </Box>
321 <Box flexDirection="column" paddingLeft={1}>
322 <Text> </Text>
323 <Text bold color="claude">Claude Run</Text>
324 <Text dimColor wrap="truncate-end">{hint}</Text>
325 <Text dimColor>{g.best > 0 ? `Best ${g.best}` : ' '}</Text>
326 </Box>
327 </Box>
328 )
329 }
330
331 const rows = rowsOf(drawField(g, width))
332 const showScore = g.flash === 0 || Math.floor(g.flash / 4) % 2 === 0
333 const scoreText = `HI ${pad(Math.max(g.best, g.score))} ${showScore ? pad(g.score) : ' '}`
334 const left =
335 g.mode === 'over'
336 ? width >= 60
337 ? 'GAME OVER · tap or press Space to run again'
338 : 'GAME OVER · tap or Space'
339 : 'Tap or Space to jump'
340
341 return (
342 <Box flexDirection="column" width={width}>
343 <Box flexDirection="row" justifyContent="space-between">
344 {g.mode === 'over' ? (
345 <Text bold color="claude" wrap="truncate-end">{left}</Text>
346 ) : (
347 <Text dimColor wrap="truncate-end">{left}</Text>
348 )}
349 <Text color="text">{scoreText}</Text>
350 </Box>
351 {rows.map(runs => (
352 <Box flexDirection="row">{paint(runs)}</Box>
353 ))}
354 </Box>
355 )
356}
357
358const Runner: ClientModule<Props, Game> = (props, surface) => {
359 const width = Math.max(30, surface.columns > 0 ? surface.columns : (props?.columns ?? 60))
360 const known = Math.max(0, props?.best ?? 0)
361
362 let g = surface.state
363 if (g === undefined) {
364 g = fresh(known)
365 const s = surface
366 if (props?.autoStart) start(g, width)
367 s.setState(g)
368
369 // The one button: start, jump, or run again.
370 const press = (): void => {
371 const cur = s.state
372 if (!cur) return
373 const w = Math.max(30, s.columns > 0 ? s.columns : 60)
374 if (cur.mode === 'idle') {
375 start(cur, w)
376 } else if (cur.mode === 'over') {
377 if (cur.t - cur.overAt < 8) return // ignore the press that was mid-flight
378 start(cur, w)
379 } else {
380 jump(cur)
381 }
382 s.setState({ ...cur })
383 }
384
385 s.every(TICK_MS, () => {
386 const cur = s.state
387 if (!cur) return
388 const w = Math.max(30, s.columns > 0 ? s.columns : 60)
389 const wasRunning = cur.mode === 'run'
390 step(cur, w)
391 if (cur.mode === 'run') {
392 s.setState({ ...cur })
393 } else if (wasRunning) {
394 // The run just ended: redraw once and report the score.
395 if (!cur.posted && cur.score > 0) {
396 cur.posted = true
397 s.post({ score: cur.score })
398 }
399 s.setState({ ...cur })
400 } else if (cur.mode === 'idle' && (cur.t % 90 === 0 || cur.t % 90 === 4)) {
401 s.setState({ ...cur }) // blink
402 }
403 })
404
405 s.onKey(ev => {
406 if (ev.ctrl || ev.meta) return
407 if (BUTTON_KEYS.has(ev.key.toLowerCase())) press()
408 })
409
410 s.onPointer(ev => {
411 if (ev.type === 'down') press() // a tap or click anywhere
412 })
413 }
414
415 if (known > g.best) g.best = known
416 return draw(g, width, surface)
417}
418
419export default Runner
420
421// Exposed for tests.
422export const _internal = { fresh, start, step, jump, hits, makeObstacle, FEET }
423types/index.d.ts 14 lines1/** A best score: whole points. */
2export type Score = number
3
4declare module 'claude-code' {
5 interface PluginState {
6 'prompt-runner': {
7 /** True while the game pane (from /prompt-runner) is open. */
8 isPlaying: boolean
9 /** Best score, also kept in $.store across sessions. */
10 best: Score
11 }
12 }
13}
14