Chrome's offline dino game, played by Clawd above the prompt while Claude works

The Chrome dino game, played by Clawd, right above your Claude Code prompt — while Claude works.

<sub>Rendered from the game's own drawing code, with an autopilot at the keys.</sub>
Clawd Run is a mod for Claude Code. Whenever Claude is working on something, Clawd shows up in the band above the prompt. Press space and he's off: jumping cacti, ducking birds, getting faster. When Claude finishes, the corner flips from ✻ Claude is working to ✓ Claude is done, so you can finish your run or get back to work.
In Claude Code, in a terminal, type:
/plugin install clawd-run --marketplace tomas-schmieder/clawd-run
Answer y to add the marketplace, then pick a scope (user puts it in every session). Or from a shell:
claude plugin marketplace add tomas-schmieder/clawd-run
claude plugin install clawd-run@clawd-mods
Mods (plugins of function hooks) are an early-access Claude Code feature; Clawd Run was built on Claude Code 2.1.293.
Clawd appears whenever Claude is working. With the prompt box empty:
| Key | Does |
|---|---|
| <kbd>space</kbd> | start, jump, restart, resume |
| <kbd>s</kbd> or <kbd>j</kbd> | duck (mid-jump: drop fast) |
| <kbd>p</kbd> | pause / resume |
| anything else | pauses the game and types as usual, so you never type and play at once |
The [ Pause ] button in the corner pauses too, and resuming counts down 3-2-1 first. Click the game to hand it the keyboard and the arrow keys work as well; <kbd>Esc</kbd> gives the keyboard back to the prompt. (From the prompt itself the arrows walk your history and <kbd>Esc</kbd> interrupts Claude, so the game leaves them alone.)
/clawd-run opens the game any time, even while Claude is idle./clawd-run off stops it showing up while Claude works; /clawd-run on brings it back.Obstacles are random every run, as in Chrome: cacti in groups of up to three, and from about 40 seconds in, birds at three heights: jump the low ones, duck the middle ones, and don't jump into the high ones. Your best score is kept between sessions.
AbovePrompt render hook draws a Client: a small module that runs on the terminal's own frame clock (about 30 fps) and draws every frame itself.▘ ▝ ▀ ▖ ▌ … █). That makes Clawd the Claude Code logo pixel for pixel, lets everything move in half-cell steps, and makes collisions exact: a crash is two sprites sharing a pixel.prompt.edit hook takes space, s and p from the empty prompt box (the box stays empty), counts them into session state, and the band hands the counts to the game. The game posts back how the run is going, so the band stays open until your run is over.claude plugin validate .
claude plugin test . # includes an autopilot that survives 40 three-minute runs, at every pace
claude --plugin-dir . # a session running your working copy
| File | What |
|---|---|
hooks/register.tsx | the hooks: band, prompt keys, /clawd-run, scores |
hooks/game.tsx | the Client: the loop, input and drawing on the terminal |
hooks/clawd.ts | the game: sprites, physics, spawning, collisions, rasterizing |
hooks/keys.ts | which key does what |
types/index.d.ts | the shared types and the session state's contract |
hooks/register.tsx 150 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { GameProps } from '../types'
5import { promptPress, readPost } from './keys'
6
7const presses = atom({ plugin: 'clawd-run', key: 'presses' } as const, { jump: 0, duck: 0, pause: 0 })
8const phase = atom({ plugin: 'clawd-run', key: 'phase' } as const, 'idle')
9const isOpen = atom({ plugin: 'clawd-run', key: 'isOpen' } as const, false)
10const isWorking = atom({ plugin: 'clawd-run', key: 'isWorking' } as const, false)
11const isEnabled = atom({ plugin: 'clawd-run', key: 'isEnabled' } as const, true)
12const isFaulted = atom({ plugin: 'clawd-run', key: 'isFaulted' } as const, false)
13const best = atom({ plugin: 'clawd-run', key: 'best' } as const, 0)
14
15export const register: Register = on => {
16 on('session.start', async ($, e, next) => {
17 await $.command.register({
18 name: 'clawd-run',
19 description: 'Play Clawd Run above the prompt now; on or off to show it while Claude works',
20 argumentHint: '[on|off]',
21 immediate: true,
22 })
23 const [storedBest, storedEnabled] = await Promise.all([$.store.get('best'), $.store.get('isEnabled')])
24 if (typeof storedBest === 'number') {
25 await update($, best, n => Math.max(n, storedBest))
26 }
27 if (typeof storedEnabled === 'boolean') {
28 await update($, isEnabled, () => storedEnabled)
29 }
30
31 return next(e)
32 })
33
34 on('turn.start', async ($, e, next) => {
35 await update($, isWorking, () => true)
36
37 return next(e)
38 })
39
40 on('turn.complete', async ($, e, next) => {
41 if (e.agentId === undefined) {
42 await update($, isWorking, () => false)
43 }
44
45 return next(e)
46 })
47
48 // The game's keys in the empty prompt box play and go no further, so the
49 // box stays empty; any other edit mid-run pauses the game and goes through.
50 on('prompt.edit', async ($, e, next) => {
51 const [enabled, working, open, current] = await Promise.all([
52 read($, isEnabled),
53 read($, isWorking),
54 read($, isOpen),
55 read($, phase),
56 ])
57 const press = promptPress(e.key?.key, e.text, { isEnabled: enabled, isShown: working || open, phase: current })
58 if (press === undefined) {
59 return next(e)
60 }
61 await update($, presses, counts => ({ ...counts, [press.action]: counts[press.action] + 1 }))
62
63 return press.isConsumed ? { text: e.text, cursor: e.cursor } : next(e)
64 })
65
66 on('command.run', { command: 'clawd-run' }, async ($, e) => {
67 const arg = (e.args ?? '').trim().toLowerCase()
68 if (arg === 'off') {
69 await update($, isEnabled, () => false)
70 await update($, isOpen, () => false)
71 await $.store.set('isEnabled', false)
72 return { text: 'Clawd Run is off. /clawd-run on brings it back.' }
73 }
74 if (arg !== '' && arg !== 'on') {
75 return { text: 'Usage: /clawd-run to play now, /clawd-run on or off to show it while Claude works.' }
76 }
77 await update($, isEnabled, () => true)
78 await update($, isFaulted, () => false)
79 await $.store.set('isEnabled', true)
80 if (arg === 'on') {
81 return { text: 'Clawd Run is on: it shows above the prompt while Claude works.' }
82 }
83 await update($, isOpen, () => true)
84
85 return { text: 'Clawd is waiting above the prompt. Press space to play.' }
86 })
87
88 on('ui.message', async ($, e) => {
89 const post = e.element === 'clawd' ? readPost(e.data) : undefined
90 if (post === undefined) {
91 return {}
92 }
93 if (post.phase === 'done') {
94 await update($, isOpen, () => false)
95 await update($, phase, () => 'idle')
96 return {}
97 }
98 // A run keeps the band open past the turn until the game says it is done.
99 // Posts coalesce within a frame, so an end may arrive without its start.
100 await update($, phase, () => post.phase)
101 if (post.phase !== 'idle') {
102 await update($, isOpen, () => true)
103 }
104 if (post.phase === 'over' && post.score > (await read($, best))) {
105 await update($, best, () => post.score)
106 await $.store.set('best', post.score)
107 }
108
109 return {}
110 })
111
112 // The game failed on the surface: say so once and leave it out from here on.
113 on('ui.fault', async ($, e, next) => {
114 if (e.element === 'clawd') {
115 $.ui.log(`Clawd Run stopped: ${e.reason}. /clawd-run starts it again.`)
116 await update($, isFaulted, () => true)
117 }
118
119 return next(e)
120 })
121
122 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
123 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
124 return next(e)
125 }
126 const [enabled, faulted, open, pressed, top] = await Promise.all([
127 read($, isEnabled),
128 read($, isFaulted),
129 read($, isOpen),
130 read($, presses),
131 read($, best),
132 ])
133 if (!enabled || faulted || e.props.hasSurvey || !(e.props.isWorking || open)) {
134 return next(e)
135 }
136 const { Client } = $.ui.resolve(e)
137 const props: GameProps = {
138 isWorking: e.props.isWorking,
139 jump: pressed.jump,
140 duck: pressed.duck,
141 pause: pressed.pause,
142 best: top,
143 columns: e.props.bodyColumns,
144 maxRows: e.props.maxRows,
145 }
146
147 return <Client key="clawd" module="./game.tsx" width={e.props.bodyColumns} props={props} />
148 })
149}
150hooks/keys.ts 76 lines1import type { Phase, Post } from '../types'
2
3/** What a press does: `jump` also starts, restarts and resumes; `pause` only ever pauses. */
4export type Action = 'jump' | 'duck' | 'pause'
5
6export type Situation = { isEnabled: boolean; isShown: boolean; phase: Phase }
7
8/** A prompt edit's part in the game, and whether the key is the game's alone. */
9export type PromptPress = { action: Action; isConsumed: boolean }
10
11const isSpace = (key: string | undefined) => key === ' ' || key === 'space'
12
13/**
14 * What an edit of the prompt box does to the game, if anything.
15 *
16 * Over an empty box, space plays (starts, jumps, resumes) and, mid-run, s or
17 * j ducks and p pauses: those keys are the game's and go no further. Any
18 * other edit mid-run pauses the game and goes through, so typing and playing
19 * never happen at once. (An empty box walks its history with the arrows and
20 * never raises them as edits, so they are no use here; Esc interrupts Claude.)
21 */
22export function promptPress(key: string | undefined, draft: string, situation: Situation): PromptPress | undefined {
23 if (!situation.isEnabled || !situation.isShown) {
24 return undefined
25 }
26 const isEmpty = draft === ''
27 if (isEmpty && (isSpace(key) || (situation.phase === 'paused' && key === 'p'))) {
28 return { action: 'jump', isConsumed: true }
29 }
30 if (situation.phase !== 'running') {
31 return undefined
32 }
33 if (isEmpty && (key === 's' || key === 'j')) {
34 return { action: 'duck', isConsumed: true }
35 }
36
37 return { action: 'pause', isConsumed: isEmpty && key === 'p' }
38}
39
40/** What a key does while the game itself has the focus (after a click). */
41export function focusedAction(key: string): Action | undefined {
42 switch (key) {
43 case ' ':
44 case 'space':
45 case 'up':
46 case 'return':
47 case 'w':
48 case 'k':
49 return 'jump'
50 case 'down':
51 case 's':
52 case 'j':
53 return 'duck'
54 case 'p':
55 return 'pause'
56 default:
57 return undefined
58 }
59}
60
61/** The game's post, checked: it came from code, so it is input, not a fact. */
62export function readPost(data: unknown): Post | undefined {
63 if (typeof data !== 'object' || data === null || !('phase' in data)) {
64 return undefined
65 }
66 const { phase } = data
67 if (phase === 'idle' || phase === 'running' || phase === 'paused' || phase === 'done') {
68 return { phase }
69 }
70 if (phase === 'over' && 'score' in data && typeof data.score === 'number' && data.score >= 0) {
71 return { phase, score: Math.floor(data.score) }
72 }
73
74 return undefined
75}
76hooks/game.tsx 143 lines1import type { ClientModule, ClientSurface, TextProps } from 'claude-code'
2
3import type { GameProps, Presses } from '../types'
4import { act, canPlay, draw, FRAME_MS, newGame, step, type Game, type Style } from './clawd'
5import { focusedAction, type Action } from './keys'
6
7// The game's own loop, on the drawing thread: it ticks on the surface's frame
8// clock and draws every frame itself, so the hooks module only hands it the
9// presses taken from the prompt and hears back how a run went.
10
11/** One instance's game, kept by reference; `frame` only makes each redraw a new state. */
12type Instance = { game: Game; props: GameProps; seen: Presses; posted: string; frame: number }
13
14type Local = { instance: Instance; frame: number }
15
16type Surface = ClientSurface<Local>
17
18const STYLES: Record<Style, TextProps> = {
19 plain: {},
20 clawd: { color: 'claude' },
21 cactus: { color: 'success' },
22 bird: { color: 'warning' },
23 cloud: { color: 'subtle', dimColor: true },
24 ground: { color: 'subtle' },
25 quiet: { color: 'subtle' },
26 loud: { bold: true },
27 claude: { color: 'claude' },
28 done: { color: 'success', bold: true },
29}
30
31// Room at the top right for `[ Resume ]` and a space before it.
32const BUTTON_COLUMNS = 11
33
34const ClawdRun: ClientModule<GameProps, Local> = (props, surface) => {
35 const instance = surface.state?.instance ?? mount(props, surface)
36 instance.props = props
37 const { game } = instance
38 const columns = columnsOf(surface, props)
39 const isPlayable = canPlay(columns, props.maxRows)
40
41 // Presses taken from the prompt box arrive as counts that went up.
42 for (const action of ['pause', 'jump', 'duck'] as const) {
43 if (props[action] > instance.seen[action]) {
44 act(game, action, isPlayable)
45 }
46 }
47 instance.seen = { jump: props.jump, duck: props.duck, pause: props.pause }
48
49 const { Box, Button, Text } = surface.elements
50 const hasButton = game.phase === 'running' || game.phase === 'paused'
51 const lines = draw(game, {
52 columns,
53 isWorking: props.isWorking,
54 isPlayable,
55 hudInset: hasButton ? BUTTON_COLUMNS : 0,
56 })
57
58 return (
59 <Box flexDirection="column">
60 {lines.map((line, i) => (
61 <Box>
62 {line.map(run => (
63 <Text {...STYLES[run.style]}>{run.text}</Text>
64 ))}
65 {i === 0 && hasButton && (
66 <Button
67 key="pause"
68 label={game.phase === 'paused' ? 'Resume' : 'Pause'}
69 dimColor
70 onPress={() => control(instance, surface, 'pause')}
71 />
72 )}
73 </Box>
74 ))}
75 </Box>
76 )
77}
78
79export default ClawdRun
80
81function mount(props: GameProps, surface: Surface): Instance {
82 const instance: Instance = {
83 game: newGame(props.best),
84 props,
85 seen: { jump: props.jump, duck: props.duck, pause: props.pause },
86 posted: '',
87 frame: 0,
88 }
89 surface.setState({ instance, frame: instance.frame })
90
91 surface.every(FRAME_MS, () => {
92 const context = { columns: columnsOf(surface, instance.props), isWorking: instance.props.isWorking }
93 const isChanged = step(instance.game, FRAME_MS / 1000, context)
94 report(instance, surface)
95 if (isChanged) {
96 redraw(instance, surface)
97 }
98 })
99
100 // Once a click gives the game the focus, keys come straight here (Esc hands
101 // the focus back to the prompt), and a click on the field jumps; the top
102 // row is the scores' and the button's.
103 surface.onKey(key => {
104 const action = focusedAction(key.key)
105 if (action !== undefined) {
106 control(instance, surface, action)
107 }
108 })
109 surface.onPointer(event => {
110 if (event.type === 'down' && event.y > 0) {
111 control(instance, surface, 'jump')
112 }
113 })
114
115 return instance
116}
117
118/** A press made on the game itself, where p and the button resume as well as pause. */
119function control(instance: Instance, surface: Surface, action: Action): void {
120 const { game, props } = instance
121 const isResume = action === 'pause' && game.phase === 'paused'
122 act(game, isResume ? 'jump' : action, canPlay(columnsOf(surface, props), props.maxRows))
123 redraw(instance, surface)
124}
125
126function redraw(instance: Instance, surface: Surface): void {
127 instance.frame += 1
128 surface.setState({ instance, frame: instance.frame })
129}
130
131/** Tells the hooks module when a run starts, pauses or ends, and when the band may close. */
132function report(instance: Instance, surface: Surface): void {
133 const { game } = instance
134 const phase = game.isDone ? 'done' : game.phase
135 if (phase === instance.posted) {
136 return
137 }
138 instance.posted = phase
139 surface.post(phase === 'over' ? { phase, score: game.score } : { phase })
140}
141
142const columnsOf = (surface: Surface, props: GameProps) => surface.columns || props.columns
143hooks/clawd.ts 719 lines1// Clawd Run: Chromium's offline dino game, played by Clawd above the prompt.
2//
3// The scene is drawn in half-cell pixels: each terminal cell holds 2×2 of
4// them and is painted with the quadrant glyph that matches (▘ ▝ ▀ ▖ ▌ … █),
5// so Clawd below is the Claude Code logo pixel for pixel and everything moves
6// in half-cell steps. Physics, spawning and scoring follow Chromium's dino
7// (BSD-3, via github.com/wayou/t-rex-runner), scaled to a terminal: there is
8// no key-up there, so a jump is always a full one and a duck lasts a moment
9// rather than for as long as the key is held.
10
11import type { Phase } from '../types'
12import type { Action } from './keys'
13
14// Sprites, as rows of `#` (set) and `.` (clear), top row first. ------------
15
16type Sprite = readonly string[]
17
18const widthOf = (sprite: Sprite) => sprite[0]?.length ?? 0
19
20const mirror = (sprite: Sprite): Sprite => sprite.map(row => [...row].reverse().join(''))
21
22// Clawd: the logo's two rows of body, eyes as holes, and legs that here reach
23// the ground and take turns as he runs. In the air they tuck up as in the logo.
24const EYES_OPEN = ['...############...', '...##.######.##...']
25const EYES_SHUT = ['...############...', '...############...']
26const EYES_WIDE = ['...############...', '...##..####..##...']
27const TORSO = ['.################.', '...############...']
28const LEGS_DOWN = ['....#.#....#.#....', '....#.#....#.#....']
29const LEGS_LEFT = ['....#.#....#.#....', '....#......#......']
30const LEGS_RIGHT = ['....#.#....#.#....', '......#......#....']
31const LEGS_TUCKED = ['....#.#....#.#....', '..................']
32
33const CLAWD_STAND: Sprite = [...EYES_OPEN, ...TORSO, ...LEGS_DOWN]
34const CLAWD_BLINK: Sprite = [...EYES_SHUT, ...TORSO, ...LEGS_DOWN]
35const CLAWD_LEFT: Sprite = [...EYES_OPEN, ...TORSO, ...LEGS_LEFT]
36const CLAWD_RIGHT: Sprite = [...EYES_OPEN, ...TORSO, ...LEGS_RIGHT]
37const CLAWD_AIR: Sprite = [...EYES_OPEN, ...TORSO, ...LEGS_TUCKED]
38const CLAWD_CRASHED: Sprite = [...EYES_WIDE, ...TORSO, ...LEGS_DOWN]
39const CLAWD_CRASHED_AIR: Sprite = [...EYES_WIDE, ...TORSO, ...LEGS_TUCKED]
40
41// Ducking, he flattens and spreads his arms: four pixels tall, under a bird.
42const DUCK_BODY = ['..##############..', '..###.######.###..', '##################']
43const CLAWD_DUCK_LEFT: Sprite = [...DUCK_BODY, '...#........#.....']
44const CLAWD_DUCK_RIGHT: Sprite = [...DUCK_BODY, '.....#........#...']
45
46const CACTUS_SMALL: Sprite = ['..##..', '#.##.#', '######', '..##..']
47const CACTUS_LARGE: Sprite = ['..##.#', '#.##.#', '#.####', '####..', '..##..', '..##..']
48const CACTUS_SMALL_FLIPPED = mirror(CACTUS_SMALL)
49const CACTUS_LARGE_FLIPPED = mirror(CACTUS_LARGE)
50
51// A bird flying left, wings up and wings down; its bottom row is the lowest
52// a wing reaches.
53const BIRD_UP: Sprite = ['.....##...', '.##...##..', '#########.', '..######..', '..........']
54const BIRD_DOWN: Sprite = ['..........', '.##.......', '#########.', '..######..', '.....##...']
55
56const CLOUD: Sprite = ['....###..##...', '..##########..', '##############']
57
58// The field. -----------------------------------------------------------------
59
60export const FRAME_MS = 33
61
62/** Rows the running game takes above the prompt: the field and the ground. */
63export const FULL_ROWS = 9
64
65export const MIN_COLUMNS = 40
66
67const MAX_COLUMNS = 140
68const FIELD_ROWS = FULL_ROWS - 1
69const IDLE_ROWS = 3
70const CLAWD_X = 6
71const HIT_INSET = 2 // Clawd's arm tips brush past things.
72
73// Chromium counts its own pixels per 60 Hz frame, its "pace": 6 at the start,
74// 13 at most. One of its pixels is CHROMIUM_PX of ours across (Clawd is 18
75// wide where the T-rex is 44), and the ground runs at 3/4 of its speed.
76const CHROMIUM_PX = 18 / 44
77const START_PACE = 6
78const MAX_PACE = 13
79const START_SPEED = 110
80const ACCELERATION = 0.06
81const SCORE_COEFFICIENT = 0.025
82
83// A jump rises APEX pixels and lasts AIR_TIME at the starting pace, a little
84// less as the pace climbs, so that it never outruns the gaps.
85const APEX = 9
86const AIR_TIME = 0.72
87const DROP_GRAVITY = 3
88const DUCK_TIME = 0.6
89const JUMP_BUFFER = 0.15
90const STEPS_PER_SECOND = 12
91
92const CLEAR_TIME = 3
93const RESTART_DELAY = 0.6
94const LINGER = 4
95const IDLE_TIMEOUT = 20
96const PAUSE_TIMEOUT = 120
97const RESUME_TIME = 0.9
98const REVEAL_TIME = 0.4
99const FLASH_TIME = 1.5
100
101type Kind = 'small' | 'large' | 'bird'
102
103const KINDS: Record<Kind, { chromiumWidth: number; minGap: number; minPace: number; multiplePace: number }> = {
104 small: { chromiumWidth: 17, minGap: 120, minPace: 0, multiplePace: 4 },
105 large: { chromiumWidth: 25, minGap: 120, minPace: 0, multiplePace: 7 },
106 bird: { chromiumWidth: 46, minGap: 150, minPace: 8.5, multiplePace: Infinity },
107}
108const KIND_NAMES: Kind[] = ['small', 'large', 'bird']
109const GAP_COEFFICIENT = 0.6
110const MAX_GAP_COEFFICIENT = 1.5
111const GAP_SCALE = 1.3 // Full jumps only, so a little more room than Chromium leaves.
112const MAX_GROUP = 3
113const MAX_DUPLICATION = 2
114const CACTUS_SPACING = 1
115
116// A bird's lowest wing, in pixels off the ground: jump it, duck it, or stay put.
117const BIRD_LIFTS = [2, 4, 7]
118const BIRD_DRIFT = 0.8
119const BIRD_FLAPS = 6
120
121const MAX_CLOUDS = 6
122const CLOUD_SPEED = 0.07
123const CLOUD_LIFTS = [9, 10, 11, 12, 13]
124
125const GROUND_LENGTH = 600
126const SPINNER = '·✢✳✶✻✽'
127
128// The game. ------------------------------------------------------------------
129
130type Obstacle = {
131 kind: Kind
132 x: number
133 size: number
134 lift: number
135 isFlipped: boolean
136 drift: number
137 gap: number
138 hasFollower: boolean
139}
140
141type Cloud = { x: number; lift: number; gap: number }
142
143export type Game = {
144 phase: Phase
145 clock: number
146 runTime: number
147 pace: number
148 y: number
149 vy: number
150 isAirborne: boolean
151 isDropping: boolean
152 duckLeft: number
153 bufferLeft: number
154 distance: number
155 score: number
156 best: number
157 flashAt: number
158 crashAt: number
159 quietFor: number
160 isDone: boolean
161 hasWorked: boolean
162 resumeLeft: number
163 obstacles: Obstacle[]
164 history: Kind[]
165 clouds: Cloud[]
166 groundX: number
167 ground: string
168}
169
170export type Context = { columns: number; isWorking: boolean }
171
172/** What drawing needs besides: whether a run can start here, and room kept at the top right. */
173export type DrawContext = Context & { isPlayable: boolean; hudInset: number }
174
175const LONG_AGO = -1000
176
177export function newGame(best: number): Game {
178 return {
179 phase: 'idle',
180 clock: 0,
181 runTime: 0,
182 pace: START_PACE,
183 y: 0,
184 vy: 0,
185 isAirborne: false,
186 isDropping: false,
187 duckLeft: 0,
188 bufferLeft: 0,
189 distance: 0,
190 score: 0,
191 best,
192 flashAt: LONG_AGO,
193 crashAt: LONG_AGO,
194 quietFor: 0,
195 isDone: false,
196 hasWorked: false,
197 resumeLeft: 0,
198 obstacles: [],
199 history: [],
200 clouds: [{ x: 90, lift: 11, gap: 60 }],
201 groundX: 0,
202 ground: makeGround(),
203 }
204}
205
206export const canPlay = (columns: number, maxRows: number) =>
207 columns >= MIN_COLUMNS && maxRows >= FULL_ROWS
208
209/**
210 * A press: space starts a run, jumps, or resumes after a count of three;
211 * ↓ ducks, or drops out of a jump; pause pauses, and stops a count.
212 */
213export function act(game: Game, action: Action, isPlayable: boolean): void {
214 if (game.phase === 'paused') {
215 if (action === 'pause') {
216 game.resumeLeft = 0
217 } else if (action === 'jump' && game.resumeLeft === 0) {
218 game.resumeLeft = RESUME_TIME
219 }
220 return
221 }
222 if (game.phase === 'running') {
223 if (action === 'pause') {
224 game.phase = 'paused'
225 game.resumeLeft = 0
226 } else if (action === 'jump') {
227 if (game.isAirborne) {
228 game.bufferLeft = JUMP_BUFFER
229 } else {
230 jump(game)
231 }
232 } else if (game.isAirborne) {
233 game.isDropping = true
234 game.vy = Math.min(game.vy, 0)
235 } else {
236 game.duckLeft = DUCK_TIME
237 }
238 return
239 }
240 if (action !== 'jump' || !isPlayable) {
241 return
242 }
243 if (game.phase === 'over' && game.clock - game.crashAt < RESTART_DELAY) {
244 return
245 }
246 Object.assign(game, {
247 phase: 'running',
248 runTime: 0,
249 pace: START_PACE,
250 distance: 0,
251 score: 0,
252 flashAt: LONG_AGO,
253 quietFor: 0,
254 isDone: false,
255 obstacles: [],
256 history: [],
257 })
258 jump(game)
259}
260
261/** Moves the game on by `dt` seconds; answers whether its drawing changed. */
262export function step(game: Game, dt: number, context: Context): boolean {
263 const before = look(game, context)
264 game.clock += dt
265 if (context.isWorking) {
266 game.quietFor = 0
267 game.isDone = false
268 game.hasWorked = true
269 }
270 if (game.phase === 'paused' && game.resumeLeft > 0) {
271 game.resumeLeft = Math.max(0, game.resumeLeft - dt)
272 if (game.resumeLeft === 0) {
273 game.phase = 'running'
274 }
275 return true
276 }
277 if (game.phase === 'running') {
278 run(game, dt, fieldColumns(context.columns) * 2)
279 return true
280 }
281 if (!context.isWorking) {
282 game.quietFor += dt
283 game.isDone = game.quietFor >= REST_TIMEOUTS[game.phase]
284 }
285
286 return look(game, context) !== before
287}
288
289/** What a resting game's drawing depends on, so a tick redraws only on change. */
290function look(game: Game, context: Context): string {
291 if (game.phase === 'idle') {
292 return `idle ${isBlinking(game)} ${context.isWorking}`
293 }
294
295 return `${game.phase} ${context.isWorking && spinnerAt(game)}`
296}
297
298const REST_TIMEOUTS: Record<Phase, number> = {
299 idle: IDLE_TIMEOUT,
300 running: Infinity,
301 paused: PAUSE_TIMEOUT,
302 over: LINGER,
303}
304
305function jump(game: Game): void {
306 game.duckLeft = 0
307 game.bufferLeft = 0
308 game.isAirborne = true
309 game.isDropping = false
310 game.vy = Math.sqrt(2 * gravityAt(game.pace) * APEX)
311}
312
313const gravityAt = (pace: number) => ((8 * APEX) / AIR_TIME ** 2) * (pace / START_PACE) ** 0.8
314
315const speedAt = (pace: number) => (pace * START_SPEED) / START_PACE
316
317function run(game: Game, dt: number, width: number): void {
318 game.runTime += dt
319 game.pace = Math.min(MAX_PACE, game.pace + ACCELERATION * dt)
320
321 if (game.isAirborne) {
322 const gravity = gravityAt(game.pace) * (game.isDropping ? DROP_GRAVITY : 1)
323 game.y += game.vy * dt - (gravity * dt * dt) / 2
324 game.vy -= gravity * dt
325 if (game.y <= 0) {
326 game.y = 0
327 game.vy = 0
328 game.isAirborne = false
329 game.isDropping = false
330 if (game.bufferLeft > 0) {
331 jump(game)
332 }
333 }
334 }
335 game.bufferLeft = Math.max(0, game.bufferLeft - dt)
336 game.duckLeft = Math.max(0, game.duckLeft - dt)
337
338 const travel = speedAt(game.pace) * dt
339 game.groundX += travel
340 moveClouds(game, travel * CLOUD_SPEED, width)
341 moveObstacles(game, dt, width)
342
343 const before = game.score
344 game.distance += game.pace * 60 * dt
345 game.score = Math.min(99999, Math.floor(game.distance * SCORE_COEFFICIENT))
346 if (Math.floor(game.score / 100) > Math.floor(before / 100)) {
347 game.flashAt = game.clock
348 }
349
350 if (isHit(game)) {
351 game.phase = 'over'
352 game.crashAt = game.clock
353 game.quietFor = 0
354 game.isDone = false
355 game.best = Math.max(game.best, game.score)
356 }
357}
358
359function moveClouds(game: Game, travel: number, width: number): void {
360 for (const cloud of game.clouds) {
361 cloud.x -= travel
362 }
363 game.clouds = game.clouds.filter(cloud => cloud.x + widthOf(CLOUD) > 0)
364 const last = game.clouds.at(-1)
365 if (game.clouds.length < MAX_CLOUDS && (last === undefined || last.x + widthOf(CLOUD) + last.gap < width)) {
366 game.clouds.push({ x: width, lift: pick(CLOUD_LIFTS), gap: 40 + Math.random() * 120 })
367 }
368}
369
370function moveObstacles(game: Game, dt: number, width: number): void {
371 for (const obstacle of game.obstacles) {
372 obstacle.x -= speedAt(game.pace + obstacle.drift) * dt
373 }
374 game.obstacles = game.obstacles.filter(obstacle => obstacle.x + obstacleWidth(obstacle) > 0)
375 if (game.runTime < CLEAR_TIME) {
376 return
377 }
378 const last = game.obstacles.at(-1)
379 if (last === undefined) {
380 game.obstacles.push(spawn(game, width))
381 } else if (!last.hasFollower && last.x + obstacleWidth(last) + last.gap < width) {
382 game.obstacles.push(spawn(game, width))
383 last.hasFollower = true
384 }
385}
386
387/** Chromium's pick: any kind the pace allows, but never three of one in a row. */
388function spawn(game: Game, width: number): Obstacle {
389 const kinds = KIND_NAMES.filter(
390 kind =>
391 game.pace >= KINDS[kind].minPace &&
392 !(game.history.length >= MAX_DUPLICATION && game.history.every(seen => seen === kind)),
393 )
394 const kind = pick(kinds)
395 const config = KINDS[kind]
396 const size = game.pace >= config.multiplePace ? 1 + Math.floor(Math.random() * MAX_GROUP) : 1
397 const minGap =
398 GAP_SCALE * CHROMIUM_PX * (config.chromiumWidth * size * game.pace + config.minGap * GAP_COEFFICIENT)
399 game.history = [kind, ...game.history].slice(0, MAX_DUPLICATION)
400
401 return {
402 kind,
403 x: width,
404 size,
405 lift: kind === 'bird' ? pick(BIRD_LIFTS) : 0,
406 isFlipped: Math.random() < 0.5,
407 drift: kind === 'bird' ? pick([BIRD_DRIFT, -BIRD_DRIFT]) : 0,
408 gap: minGap * (1 + Math.random() * (MAX_GAP_COEFFICIENT - 1)),
409 hasFollower: false,
410 }
411}
412
413function pick<T>(items: readonly T[]): T {
414 const item = items[Math.floor(Math.random() * items.length)]
415 if (item === undefined) {
416 throw new Error('pick from nothing')
417 }
418
419 return item
420}
421
422function obstacleWidth(obstacle: Obstacle): number {
423 if (obstacle.kind === 'bird') {
424 return widthOf(BIRD_UP)
425 }
426
427 return obstacle.size * widthOf(CACTUS_SMALL) + (obstacle.size - 1) * CACTUS_SPACING
428}
429
430type Placed = { sprite: Sprite; x: number; bottom: number }
431
432/** An obstacle's sprites, its birds' wings as they are `runTime` into the run. */
433function obstacleSprites(obstacle: Obstacle, runTime: number): Placed[] {
434 const x = Math.round(obstacle.x)
435 if (obstacle.kind === 'bird') {
436 const isUp = Math.floor(runTime * BIRD_FLAPS) % 2 === 0
437 return [{ sprite: isUp ? BIRD_UP : BIRD_DOWN, x, bottom: obstacle.lift }]
438 }
439 const isSmall = obstacle.kind === 'small'
440
441 return Array.from({ length: obstacle.size }, (_, i) => {
442 const isFlipped = obstacle.isFlipped !== (i % 2 === 1)
443 const sprite = isSmall
444 ? isFlipped ? CACTUS_SMALL_FLIPPED : CACTUS_SMALL
445 : isFlipped ? CACTUS_LARGE_FLIPPED : CACTUS_LARGE
446 return { sprite, x: x + i * (widthOf(CACTUS_SMALL) + CACTUS_SPACING), bottom: 0 }
447 })
448}
449
450function clawdSprite(game: Game): Sprite {
451 if (game.phase === 'over') {
452 return game.isAirborne ? CLAWD_CRASHED_AIR : CLAWD_CRASHED
453 }
454 if (game.phase === 'idle') {
455 return isBlinking(game) ? CLAWD_BLINK : CLAWD_STAND
456 }
457 if (game.isAirborne) {
458 return CLAWD_AIR
459 }
460 const isLeft = Math.floor(game.runTime * STEPS_PER_SECOND) % 2 === 0
461 if (game.duckLeft > 0) {
462 return isLeft ? CLAWD_DUCK_LEFT : CLAWD_DUCK_RIGHT
463 }
464
465 return isLeft ? CLAWD_LEFT : CLAWD_RIGHT
466}
467
468const isBlinking = (game: Game) => game.clock % 3.2 > 3.05
469
470const spinnerAt = (game: Game) => SPINNER[Math.floor(game.clock * 8) % SPINNER.length] ?? '·'
471
472/** Calls `visit` with each set pixel's column and its height off the ground. */
473function eachPixel(sprite: Sprite, x: number, bottom: number, visit: (x: number, lift: number) => void): void {
474 sprite.forEach((row, r) => {
475 for (let c = 0; c < row.length; c++) {
476 if (row[c] === '#') {
477 visit(x + c, bottom + sprite.length - 1 - r)
478 }
479 }
480 })
481}
482
483/** Pixel against pixel, as drawn: a crash is two sprites sharing a pixel. */
484function isHit(game: Game): boolean {
485 const clawd = clawdSprite(game)
486 const width = widthOf(clawd)
487 const own = new Set<number>()
488 eachPixel(clawd, CLAWD_X, Math.round(game.y), (x, lift) => {
489 if (x - CLAWD_X >= HIT_INSET && x - CLAWD_X < width - HIT_INSET) {
490 own.add(lift * 10000 + x)
491 }
492 })
493
494 return game.obstacles.some(obstacle => {
495 if (obstacle.x > CLAWD_X + width || obstacle.x + obstacleWidth(obstacle) < CLAWD_X) {
496 return false
497 }
498 let isTouching = false
499 for (const { sprite, x, bottom } of obstacleSprites(obstacle, game.runTime)) {
500 eachPixel(sprite, x, bottom, (px, lift) => {
501 isTouching ||= own.has(lift * 10000 + px)
502 })
503 }
504 return isTouching
505 })
506}
507
508function makeGround(): string {
509 let ground = ''
510 for (let i = 0; i < GROUND_LENGTH; i++) {
511 const roll = Math.random()
512 ground += roll < 0.02 ? '▁' : roll < 0.035 ? ' ' : '▔'
513 }
514
515 return ground
516}
517
518// Drawing. -------------------------------------------------------------------
519
520export type Style = 'plain' | 'clawd' | 'cactus' | 'bird' | 'cloud' | 'ground' | 'quiet' | 'loud' | 'claude' | 'done'
521
522export type Run = { text: string; style: Style }
523
524export type Line = Run[]
525
526type Cells = { chars: string[]; styles: Style[] }
527
528// Indexed by the four pixels of a cell: upper left 1, upper right 2, lower
529// left 4, lower right 8.
530const QUADRANTS = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'
531
532// What a pixel is, in drawing order: a later ink wins a shared cell's color.
533const INKS: Style[] = ['plain', 'cloud', 'cactus', 'bird', 'clawd']
534const INK_CLOUD = 1
535const INK_CACTUS = 2
536const INK_BIRD = 3
537const INK_CLAWD = 4
538
539type Inked = Placed & { ink: number }
540
541const fieldColumns = (columns: number) => Math.min(columns, MAX_COLUMNS)
542
543const pad = (score: number) => String(score).padStart(5, '0')
544
545export function draw(game: Game, context: DrawContext): Line[] {
546 const columns = fieldColumns(context.columns)
547 if (columns < MIN_COLUMNS) {
548 return [[{ text: 'Clawd Run needs a wider terminal', style: 'quiet' }]]
549 }
550
551 return (game.phase === 'idle' ? drawIdle(game, columns, context) : drawField(game, columns, context)).map(
552 compress,
553 )
554}
555
556/** Before the first run: Clawd on a scrap of ground, and what to press. */
557function drawIdle(game: Game, columns: number, context: DrawContext): Cells[] {
558 const clawd = clawdSprite(game)
559 const lines = rasterize(columns, IDLE_ROWS, [{ sprite: clawd, x: CLAWD_X, bottom: 0, ink: INK_CLAWD }])
560 const besideClawd = (CLAWD_X + widthOf(clawd)) / 2 + 3
561 const ground = blank(columns)
562 put(ground, 0, '▔'.repeat(besideClawd - 2), 'ground')
563 const hint = !context.isPlayable
564 ? `Make the terminal taller to play: Clawd needs ${FULL_ROWS} rows above the prompt`
565 : context.isWorking
566 ? 'Press space to play while Claude works'
567 : 'Press space to play'
568 put(lines[1], besideClawd, hint, 'quiet')
569 if (context.isPlayable) {
570 const best = game.best > 0 ? ` HI ${pad(game.best)}` : ''
571 put(lines[2], besideClawd, `space jumps · s ducks${best}`, 'quiet')
572 }
573
574 return [...lines, ground]
575}
576
577/** A run, or how it ended: sky, obstacles, Clawd, ground, and the scores. */
578function drawField(game: Game, columns: number, context: DrawContext): Cells[] {
579 const placed: Inked[] = [
580 ...game.clouds.map(cloud => ({ sprite: CLOUD, x: Math.round(cloud.x), bottom: cloud.lift, ink: INK_CLOUD })),
581 ...game.obstacles.flatMap(obstacle =>
582 obstacleSprites(obstacle, game.runTime).map(placed => ({
583 ...placed,
584 ink: obstacle.kind === 'bird' ? INK_BIRD : INK_CACTUS,
585 })),
586 ),
587 { sprite: clawdSprite(game), x: CLAWD_X, bottom: Math.round(game.y), ink: INK_CLAWD },
588 ]
589 const lines = rasterize(columns, FIELD_ROWS, placed)
590
591 // The ground unrolls from under Clawd as the first run starts, as in Chromium.
592 const ground = blank(columns)
593 const offset = Math.floor(game.groundX / 2)
594 const reveal = game.phase === 'running' ? Math.min(1, game.runTime / REVEAL_TIME) : 1
595 const shown = Math.round(CLAWD_X / 2 + 10 + (columns - CLAWD_X / 2 - 10) * reveal)
596 for (let cx = 0; cx < shown; cx++) {
597 ground.chars[cx] = game.ground[(offset + cx) % game.ground.length] ?? '▔'
598 ground.styles[cx] = 'ground'
599 }
600
601 const top = lines[0]
602 if (top !== undefined && context.hudInset > 0) {
603 top.chars.length = Math.max(0, columns - context.hudInset)
604 top.styles.length = top.chars.length
605 }
606 drawScores(top, game, context)
607 if (game.phase === 'over') {
608 center(lines[2], 'G A M E O V E R', 'loud')
609 center(lines[4], 'space to retry', 'quiet')
610 }
611 if (game.phase === 'paused' && game.resumeLeft > 0) {
612 center(lines[3], String(Math.ceil((game.resumeLeft / RESUME_TIME) * 3)), 'loud')
613 } else if (game.phase === 'paused') {
614 center(lines[2], 'P A U S E D', 'loud')
615 center(lines[4], 'space to resume', 'quiet')
616 }
617
618 return [...lines, ground]
619}
620
621/** The top right corner: whether Claude is still working, the best, the score. */
622function drawScores(line: Cells | undefined, game: Game, context: Context): void {
623 const parts: Run[] = []
624 if (context.isWorking) {
625 parts.push({ text: spinnerAt(game), style: 'claude' }, { text: ' Claude is working ', style: 'quiet' })
626 } else if (game.hasWorked) {
627 parts.push({ text: '✓ Claude is done', style: 'done' }, { text: ' ', style: 'plain' })
628 }
629 if (game.best > 0) {
630 parts.push({ text: `HI ${pad(game.best)} `, style: 'quiet' })
631 }
632 const sinceFlash = game.clock - game.flashAt
633 const isFlashedOut = sinceFlash < FLASH_TIME && Math.floor(sinceFlash * 4) % 2 === 0
634 parts.push({ text: isFlashedOut ? ' ' : pad(game.score), style: 'plain' })
635
636 const length = parts.reduce((sum, part) => sum + [...part.text].length, 0)
637 let column = line === undefined ? 0 : line.chars.length - length - 1
638 for (const part of parts) {
639 put(line, column, part.text, part.style)
640 column += [...part.text].length
641 }
642}
643
644function rasterize(columns: number, rows: number, placed: Inked[]): Cells[] {
645 const width = columns * 2
646 const height = rows * 2
647 const pixels = new Uint8Array(width * height)
648 for (const { sprite, x, bottom, ink } of placed) {
649 eachPixel(sprite, x, bottom, (px, lift) => {
650 const py = height - 1 - lift
651 if (px >= 0 && px < width && py >= 0 && py < height) {
652 pixels[py * width + px] = ink
653 }
654 })
655 }
656
657 return Array.from({ length: rows }, (_, cy) => {
658 const line = blank(columns)
659 for (let cx = 0; cx < columns; cx++) {
660 const at = cy * 2 * width + cx * 2
661 const quarters = [pixels[at] ?? 0, pixels[at + 1] ?? 0, pixels[at + width] ?? 0, pixels[at + width + 1] ?? 0]
662 const mask = quarters.reduce((sum, ink, i) => sum + (ink > 0 ? 1 << i : 0), 0)
663 line.chars[cx] = QUADRANTS[mask] ?? ' '
664 line.styles[cx] = INKS[Math.max(...quarters)] ?? 'plain'
665 }
666 return line
667 })
668}
669
670function blank(columns: number): Cells {
671 return { chars: Array<string>(columns).fill(' '), styles: Array<Style>(columns).fill('plain') }
672}
673
674function put(line: Cells | undefined, column: number, text: string, style: Style): void {
675 if (line === undefined) {
676 return
677 }
678 ;[...text].forEach((char, i) => {
679 const cx = column + i
680 if (cx >= 0 && cx < line.chars.length) {
681 line.chars[cx] = char
682 line.styles[cx] = style
683 }
684 })
685}
686
687function center(line: Cells | undefined, text: string, style: Style): void {
688 if (line !== undefined) {
689 put(line, Math.floor((line.chars.length - [...text].length) / 2), text, style)
690 }
691}
692
693/** A line of cells as runs of one style each; blanks join whatever run they are in. */
694function compress({ chars, styles }: Cells): Line {
695 const runs: Run[] = []
696 let text = ''
697 let style: Style = 'plain'
698 let isBlank = true
699 chars.forEach((char, i) => {
700 if (char === ' ') {
701 text += char
702 return
703 }
704 const next = styles[i] ?? 'plain'
705 if (next !== style) {
706 if (!isBlank) {
707 runs.push({ text, style })
708 text = ''
709 }
710 style = next
711 }
712 text += char
713 isBlank = false
714 })
715 runs.push({ text, style })
716
717 return runs
718}
719types/index.d.ts 37 lines1/** Where a run stands, as the game reports it to the hooks module. */
2export type Phase = 'idle' | 'running' | 'paused' | 'over'
3
4/** Presses taken from the prompt box, counted; the game acts on each increase. */
5export type Presses = { jump: number; duck: number; pause: number }
6
7/** What the band hands the game (the `Client` in game.tsx). */
8export type GameProps = {
9 isWorking: boolean
10 jump: number
11 duck: number
12 pause: number
13 best: number
14 columns: number
15 maxRows: number
16}
17
18/** What the game posts back: a change of phase, or that it is done and the band may close. */
19export type Post =
20 | { phase: 'idle' | 'running' | 'paused' }
21 | { phase: 'over'; score: number }
22 | { phase: 'done' }
23
24declare module 'claude-code' {
25 interface PluginState {
26 'clawd-run': {
27 presses: Presses
28 phase: Phase
29 isOpen: boolean
30 isWorking: boolean
31 isEnabled: boolean
32 isFaulted: boolean
33 best: number
34 }
35 }
36}
37