SLOPSHOPPER

clawd-run

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

newbandcommandprompt
v0.1.0BSD-3-Clauseupdated 2026-10-07tomas-schmieder/clawd-run
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-run
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /clawd-run ⎿ clawd-run: Clawd is waiting above the prompt. Press space to play. ▣ client module ./game.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./game.tsx
README

Clawd Run

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

Clawd jumping cacti and ducking a bird above the Claude Code prompt, until the corner flips to "Claude is done"

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

Install

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.

Play

Clawd appears whenever Claude is working. With the prompt box empty:

KeyDoes
<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 elsepauses 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.
  • A run keeps going after Claude finishes; the band closes a few seconds after you crash.
  • It needs 9 rows above the prompt and at least 40 columns.

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.

How it works

  • The band. An 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.
  • Half-cell pixels. Each terminal cell holds 2×2 pixels, painted with the quadrant block glyph that matches (▘ ▝ ▀ ▖ ▌ … █). 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.
  • Keys from the prompt. A 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.
  • Chrome's rules. Physics, spawning and scoring follow Chromium's dino: the same pace ramp (6 → 13), gap formula, no-three-in-a-row rule, birds from pace 8.5, and 0.025 points per pixel. The ground runs at ¾ speed for the terminal, and since terminals have no key-up, every jump is a full one and the gaps are a little wider to match.

Development

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
FileWhat
hooks/register.tsxthe hooks: band, prompt keys, /clawd-run, scores
hooks/game.tsxthe Client: the loop, input and drawing on the terminal
hooks/clawd.tsthe game: sprites, physics, spawning, collisions, rasterizing
hooks/keys.tswhich key does what
types/index.d.tsthe shared types and the session state's contract

Credits

  • Gameplay adapted from Chromium's offline T-Rex game (© 2014 The Chromium Authors), via wayou/t-rex-runner, both under BSD-style licenses; see NOTICE.
  • Clawd is the Claude Code mascot. This is an unofficial fan mod, not affiliated with or endorsed by Anthropic.

License

BSD-3-Clause

Source 5 files
hooks/register.tsx 150 lines
1import { 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}
150
hooks/keys.ts 76 lines
1import 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}
76
hooks/game.tsx 143 lines
1import 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
143
hooks/clawd.ts 719 lines
1// 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}
719
types/index.d.ts 37 lines
1/** 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