SLOPSHOPPER

clawd-park

A pixel dinosaur park under the spinner: a coral T-rex acts out the agent's work, and every failing test run brings the meteor closer. No model calls.

newspinnerguardcommandtimer
v0.1.0MITupdated 2026-10-04falkoro/clawd-park/plugins/clawd-park
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-park
› 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 › /park ⎿ clawd-park: Clawd Park is open. 1 turn since the last extinction. ⎿ clawd-park: No meteor in sight. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

clawd-park

A Claude Code mod that puts a pixel dinosaur park under the spinner. A coral T-rex acts out what the agent is doing, and every failing test run brings the meteor closer.

A real Claude Code turn: the T-rex under the spinner as Claude runs a failing test three times, the meteor comes in and lands, then Claude reads the code and fixes the bug

A real turn in Claude Code, also as an MP4: three failing test runs bring the meteor down, then Claude finds the bug and fixes it.

It makes no model calls and costs no tokens. Nothing leaves your machine.

Install

In Claude Code:

/plugin marketplace add falkoro/clawd-park
/plugin install clawd-park@clawd-park

The park opens with your next turn. To try a checkout without installing it: claude --plugin-dir plugins/clawd-park.

What happens in the park

Rex acts out each step of the agent's work, tagged with what it is: [reading cart.ts], [running npm test].

The agent isRex
thinkingwanders about
reading or searchingwalks to a fern and grazes
editing or writingstomps on the spot
running a commandroars
on the webwanders while a pterodactyl flies laps overhead
starting a subagentlays an egg; the hatchling trails Rex until its helper is done, then walks off

The meteor

A test run is a Bash command such as npm test, pytest, go test ./... or npx vitest run.

  • A failing run (a non-zero exit, or output like 2 failed or FAIL) brings the meteor in. It's tagged [tests failed 1x], then [tests failed 2x], closer each time.
  • A passing run breaks it up in a shower of sparks: [tests pass].
  • Three failing runs in a row bring it down. Flash, dust, and Rex is a fossil: EXTINCTION: 3 failing test runs in a row.

A run that never happened, because you rejected it or a hook blocked it, doesn't count either way.

The ground keeps score: 41 turns since the last extinction. Every turn that ends without one adds to it, an extinction sets it back to 0, and the count is kept between sessions.

Configure

  • /park shows the count and how close the meteor is.
  • /park off closes the park for the rest of the session, and /park on opens it again.
  • To close it for good, turn off "Show the park" in /plugin.

The park draws only in the terminal. On the desktop the spinner is left alone.

How it works

  1. Watching. tool.call gives the mod each step and its result, turn.start and turn.complete the turn around them. A subagent's own turns don't end the park's.
  2. Drawing. The Spinner render site (ui.render on Spinner) keeps the normal spinner line and adds an 8-row Raster below it. A 50 ms timer draws each frame and repaints the Raster in place with $.ui.blit.
  3. Keeping score. The count is the one thing saved, in the mod's own $.store.

Develop

claude plugin validate plugins/clawd-park --strict
claude plugin test plugins/clawd-park

The tests cover the step and verdict rules, the meteor, the drawing, and the mod against a stubbed engine: the Raster, the frame timer, the count, extinctions, /park and the options. CI runs the same commands.

Ideas and pull requests are welcome: a new step for Rex to act out, a new dinosaur, a bug.

License

MIT. Rex wears the coral of Clawd, the Claude Code mascot.

Source 3 files
hooks/register.tsx 140 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { Canvas } from './canvas'
4import { IMPACT_AT, Park, describe, verdict } from './park'
5
6const FRAME_MS = 50 // 20 frames a second
7const ROWS = 8
8const SAFE_KEY = 'turns-since-extinction'
9
10type Timer = { cancel: () => void }
11
12// The module's own state; a reload starts it over, except the count, which
13// is kept in the store
14let enabled = true
15let park: Park | undefined
16let canvas: Canvas | undefined
17let columns = 78
18let turnActive = false
19let extinctThisTurn = false
20let tick: Timer | undefined
21let helpers = 0
22const mounted = new Set<string>()
23let wasRefused = false
24
25const sized = () => (canvas && canvas.w === columns ? canvas : (canvas = new Canvas(columns, ROWS)))
26
27function save($: EngineInterface) {
28  if (park) $.store.set(SAFE_KEY, park.safeTurns).catch(() => {})
29}
30
31async function frame($: EngineInterface) {
32  if (!park || !enabled || !mounted.size) return
33  park.advance(FRAME_MS / 1000)
34  const c = sized()
35  park.draw(c)
36  const cells = c.base64()
37  for (const id of mounted) {
38    const r = await $.ui.blit({ requestId: id, key: 'park', cells })
39    // Refused while the spinner is hidden, behind a permission prompt say;
40    // a redraw is asked for once, in case it came back without the Raster
41    const isRefused = !!(r && 'deny' in r && r.deny)
42    if (isRefused && !wasRefused) $.ui.invalidate('ui.render')
43    wasRefused = isRefused
44  }
45}
46
47export const register: Register = (on, options) => {
48  enabled = options.enabled !== false
49
50  on('session.start', async ($, e, next) => {
51    try {
52      await $.command.register({
53        name: 'park',
54        description: 'Clawd Park under the spinner: status, on, or off',
55        argumentHint: '[on|off]',
56        immediate: true,
57      })
58    } catch {
59      // Another plugin holds the name; the park still draws
60    }
61    park = new Park(Number(await $.store.get(SAFE_KEY).catch(() => 0)) || 0)
62    return next(e)
63  })
64
65  on('turn.start', ($, e, next) => {
66    park ??= new Park()
67    turnActive = true
68    extinctThisTurn = false
69    park.doing({ kind: 'think', text: 'thinking it over' })
70    tick ??= $.clock.every(FRAME_MS, () => void frame($))
71    return next(e)
72  })
73
74  on('turn.complete', ($, e, next) => {
75    // A helper's loop ends turns of its own
76    if (e.agentId) return next(e)
77    turnActive = false
78    tick?.cancel()
79    tick = undefined
80    mounted.clear()
81    if (park && !extinctThisTurn) {
82      park.safeTurns++
83      save($)
84    }
85    return next(e)
86  })
87
88  on('tool.call', async ($, e, next) => {
89    if (!park) return next(e)
90    const step = describe(e.tool, e as unknown as Record<string, unknown>)
91    park.doing(step)
92    const helper = step.kind === 'agent' ? `helper-${++helpers}` : undefined
93    if (helper) park.hatch(helper)
94    try {
95      const r = await next(e)
96      const v = step.kind === 'test' ? verdict(r) : undefined
97      if (v && park.tested(v === 'fail') === 'impact') {
98        extinctThisTurn = true
99        save($)
100      }
101      return r
102    } finally {
103      if (helper) park.leave(helper)
104    }
105  })
106
107  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
108    if (!enabled || !park || !turnActive || e.surface !== 'terminal') return next(e)
109    columns = Math.max(20, Math.min(512, (e.viewport?.columns ?? 80) - 2))
110    mounted.add(e.requestId)
111    const c = sized()
112    park.draw(c)
113    const { Box, Raster } = $.ui.resolve(e)
114    const spinner = await next(e)
115    return (
116      <Box flexDirection="column">
117        {spinner}
118        <Raster key="park" columns={columns} rows={ROWS} cells={c.base64()} />
119      </Box>
120    )
121  })
122
123  on('command.run', { command: 'park' }, async ($, e) => {
124    const arg = e.args.trim().toLowerCase()
125    if (arg === 'off' || arg === 'on') {
126      enabled = arg === 'on'
127      $.ui.invalidate('ui.render')
128      return { text: enabled ? 'Clawd Park is open.' : 'Clawd Park is closed for this session. `/park on` opens it again; to close it for good, turn "Show the park" off in /plugin.' }
129    }
130    const n = park?.safeTurns ?? 0
131    const f = park?.failures ?? 0
132    return {
133      text: [
134        `Clawd Park is ${enabled ? 'open' : 'closed'}. ${n} turn${n === 1 ? '' : 's'} since the last extinction.`,
135        f ? `The meteor is coming: ${f} failing test run${f === 1 ? '' : 's'} in a row, and it lands at ${IMPACT_AT}.` : 'No meteor in sight.',
136      ].join('\n'),
137    }
138  })
139}
140
hooks/canvas.ts 135 lines
1// A grid of terminal cells and the drawing primitives the park uses.
2// Every cell is three numbers, as a Raster packs them: code point, fg, bg.
3
4export const DEFAULT = 0x01000000 // the terminal's own color
5const KEEP = -1 // leave the cell's current color alone
6
7const SPACE = 0x20
8const UPPER = 0x2580 // ▀
9const LOWER = 0x2584 // ▄
10const FULL = 0x2588 // █
11
12// A color from a number (0xRRGGBB), a '#rrggbb' string, or nothing
13export function color(v: unknown, fallback = KEEP): number {
14  if (v === undefined || v === null) return fallback
15  if (typeof v === 'number') return v === DEFAULT ? DEFAULT : v & 0xffffff
16  return parseInt(String(v).replace('#', ''), 16)
17}
18
19const clamp255 = (x: number) => Math.max(0, Math.min(255, Math.round(x)))
20
21export function mix(a: unknown, b: unknown, k: number): number {
22  const x = color(a, 0), y = color(b, 0)
23  k = Math.max(0, Math.min(1, k))
24  const lerp = (sh: number) => clamp255(((x >> sh) & 255) * (1 - k) + ((y >> sh) & 255) * k)
25  return (lerp(16) << 16) | (lerp(8) << 8) | lerp(0)
26}
27
28// A seeded random number source, so the park moves the same way in tests
29export function seeded(seed: number): () => number {
30  let s = seed >>> 0
31  return () => {
32    s = (s + 0x6d2b79f5) >>> 0
33    let t = Math.imul(s ^ (s >>> 15), 1 | s)
34    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
35    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
36  }
37}
38
39export class Canvas {
40  readonly cells: Uint32Array
41  readonly w: number
42  readonly h: number
43  constructor(w: number, h: number) {
44    this.w = w
45    this.h = h
46    this.cells = new Uint32Array(w * h * 3)
47    this.clear()
48  }
49
50  clear() {
51    for (let i = 0; i < this.cells.length; i += 3) {
52      this.cells[i] = SPACE
53      this.cells[i + 1] = DEFAULT
54      this.cells[i + 2] = DEFAULT
55    }
56  }
57
58  // Low-level write; fg/bg of KEEP leave the cell's color as it is
59  set(x: number, y: number, cp: number, fg: number, bg: number) {
60    x = Math.floor(x)
61    y = Math.floor(y)
62    if (x < 0 || y < 0 || x >= this.w || y >= this.h) return
63    const i = (y * this.w + x) * 3
64    this.cells[i] = cp
65    if (fg !== KEEP) this.cells[i + 1] = fg
66    if (bg !== KEEP) this.cells[i + 2] = bg
67  }
68
69  put(x: number, y: number, ch: string, fg?: unknown, bg?: unknown) {
70    this.set(x, y, ch.codePointAt(0)!, color(fg, DEFAULT), color(bg))
71  }
72
73  text(x: number, y: number, s: string, fg?: unknown, bg?: unknown) {
74    const f = color(fg, DEFAULT), b = color(bg)
75    let cx = Math.floor(x)
76    for (const ch of s) this.set(cx++, y, ch.codePointAt(0)!, f, b)
77  }
78
79  fill(x: number, y: number, w: number, h: number, bg: unknown) {
80    for (let yy = Math.floor(y); yy < y + h; yy++) for (let xx = Math.floor(x); xx < x + w; xx++) this.set(xx, yy, SPACE, KEEP, color(bg))
81  }
82
83  // A half-cell pixel: py counts half rows, so pixels are about square
84  pixel(x: number, py: number, c: unknown) {
85    x = Math.floor(x)
86    py = Math.floor(py)
87    if (x < 0 || py < 0 || x >= this.w || py >= this.h * 2) return
88    const i = ((py >> 1) * this.w + x) * 3
89    const cp = this.cells[i]!, fg = this.cells[i + 1]!, bg = this.cells[i + 2]!
90    let top = bg, bottom = bg
91    if (cp === UPPER) top = fg
92    else if (cp === LOWER) bottom = fg
93    else if (cp === FULL) top = bottom = fg
94    const v = color(c, DEFAULT)
95    if (py & 1) bottom = v
96    else top = v
97    // ▀ paints its top half in fg; with no top color, ▄ keeps the default above
98    const lowerOnly = top === DEFAULT
99    this.cells[i] = lowerOnly && bottom === DEFAULT ? SPACE : lowerOnly ? LOWER : UPPER
100    this.cells[i + 1] = lowerOnly ? bottom : top
101    this.cells[i + 2] = lowerOnly ? DEFAULT : bottom
102  }
103
104  disc(cx: number, pcy: number, r: number, c: unknown) {
105    for (let py = Math.floor(pcy - r); py <= pcy + r; py++) {
106      for (let x = Math.floor(cx - r); x <= cx + r; x++) {
107        const dx = x + 0.5 - cx, dy = py + 0.5 - pcy
108        if (dx * dx + dy * dy <= r * r) this.pixel(x, py, c)
109      }
110    }
111  }
112
113  // Pixel art: each character of a row is one pixel in the palette's color
114  // for it, two pixel rows to a cell; '.' is see-through. py counts half rows.
115  art(x: number, py: number, rows: string[], palette: Record<string, unknown>) {
116    rows.forEach((row, dy) => {
117      for (let dx = 0; dx < row.length; dx++) if (row[dx] !== '.') this.pixel(x + dx, py + dy, palette[row[dx]!] ?? '#ffffff')
118    })
119  }
120
121  // A label on a dark chip: [like this]
122  tag(x: number, y: number, label: string, fg: unknown = 0xc8c8d8) {
123    this.text(x, y, `[${label}]`, fg, 0x1c1c26)
124  }
125
126  base64(): string {
127    const bytes = new Uint8Array(this.cells.buffer)
128    const native = (bytes as unknown as { toBase64?: () => string }).toBase64
129    if (native) return native.call(bytes)
130    let bin = ''
131    for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
132    return btoa(bin)
133  }
134}
135
hooks/park.ts 323 lines
1// Clawd Park: a coral T-rex acting out the agent's work, a baby for each
2// helper it sends, and the meteor that every failing test run brings closer.
3
4import { Canvas, mix, seeded } from './canvas'
5
6export type Kind = 'think' | 'read' | 'edit' | 'run' | 'test' | 'web' | 'agent' | 'other'
7export type Step = { kind: Kind; text: string }
8export type Outcome = 'pass' | 'deflected' | 'closer' | 'impact'
9
10export const IMPACT_AT = 3 // failing test runs in a row that bring the meteor down
11const IMPACT_S = 5
12const DEFLECT_S = 1.5
13const HOP_S = 0.6
14
15// ---- what the agent is doing ----
16
17const base = (p: unknown) => String(p ?? '').split('/').pop() || 'a file'
18const short = (s: unknown, n: number) => {
19  const v = String(s ?? '').replace(/\s+/g, ' ').trim()
20  return v.length > n ? `${v.slice(0, n - 1)}…` : v
21}
22
23// A test run: a runner by name, or `test` as a word of the command
24// (npm test, go test ./..., cargo test, make test, npm run test:unit)
25const TEST = /\b(pytest|jest|vitest|mocha|rspec|phpunit)\b|(^|\s)test(:\S*)?(\s|$)/
26
27export function describe(tool: string, input: Record<string, unknown>): Step {
28  switch (tool) {
29    case 'Bash':
30      return { kind: TEST.test(String(input.command ?? '')) ? 'test' : 'run', text: `running ${short(input.command, 36)}` }
31    case 'Read': return { kind: 'read', text: `reading ${base(input.file_path)}` }
32    case 'Grep': case 'Glob': return { kind: 'read', text: `searching for ${short(input.pattern, 28)}` }
33    case 'Edit': case 'MultiEdit': case 'Write': case 'NotebookEdit':
34      return { kind: 'edit', text: `${tool === 'Write' ? 'writing' : 'editing'} ${base(input.file_path ?? input.notebook_path)}` }
35    case 'WebFetch': return { kind: 'web', text: `fetching ${short(input.url, 30)}` }
36    case 'WebSearch': return { kind: 'web', text: 'searching the web' }
37    case 'Agent': case 'Task': return { kind: 'agent', text: 'hatching a helper' }
38  }
39  return { kind: 'other', text: `using ${tool.startsWith('mcp__') ? tool.split('__')[1] : tool}` }
40}
41
42// A test run failed when it exited non-zero or its output counts failures:
43// "2 failed", "1 failing", or node --test's "ℹ fail 1" ("# fail 1" as TAP),
44// which still shows when the run is piped through grep or tail.
45// One that never ran (refused, rejected at the prompt, timed out) is neither.
46export function verdict(r: { deny?: unknown; isError?: boolean; text?: unknown }): 'pass' | 'fail' | undefined {
47  if (r.deny) return undefined
48  const text = String(r.text ?? '')
49  if (/\bFAIL(ED)?\b|\b[1-9]\d* (failed|failing|failures?)\b|[ℹ#] fail [1-9]/.test(text) || (r.isError && /^Exit code [1-9]/m.test(text))) return 'fail'
50  return r.isError ? undefined : 'pass'
51}
52
53// ---- the sprites, facing right; '.' is see-through ----
54
55const HEAD = ['.XXXXX.', 'XXXXXXX', 'XoXXXXX', 'XXXXXXX', 'XXXwww.', 'XXXXX..']
56const ROAR = ['.XXXXX.', 'XoXXXXX', 'XXXXXXX', 'XXXw.w.', 'XX.....', 'XXXwww.']
57const BODY = ['......XXXX...', 'X....XXXXXXX.', 'XX..XXXXXX.X.', '.XXXXXXXXX...', '...XXXXXXX...']
58const LEGS = ['....XX..XX...', '...XX....XX..']
59const REX = { X: '#d77757', o: '#1e1414', w: '#f4efe6' } // Clawd's coral
60const FOSSIL = { X: '#e8dcc0', o: '#3a3028', w: '#e8dcc0' }
61
62const BABY = ['.......XX', '.......Xo', '......XX.', '.XXXXXXX.', 'XXXXXXXX.']
63const BABY_LEGS = ['..X..X...', '.X....X..']
64const BABY_COLORS = ['#7fc97f', '#6fa8dc', '#b48ead', '#e6c46a']
65const EGG = ['.ss.', 'ssss', 'sdss', 'ssss', '.ss.']
66
67const PTERO = [
68  ['X.......X....', '.XX...XX.....', '..XXXXXXXoXXX', '...X.........'],
69  ['.............', '..XXXXXXXoXXX', '.XX...XX.....', 'X.......X....'],
70]
71const FERN = ['..g..', 'g.g.g', '.ggg.', 'g.g.g', '.ggg.', '..g..', '..b..', '..b..']
72
73const GRASS = '#4b6b2f', EARTH = '#3a2c1e', LABEL = '#e8d8b0', TAG_ALERT = '#ffb070'
74
75const flip = (rows: string[]) => rows.map((r) => [...r].reverse().join(''))
76const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
77
78function rexRows(pose: 'walk' | 'graze' | 'roar', stride: number, chew: boolean, facing: number) {
79  const grid = Array.from({ length: 12 }, () => Array<string>(15).fill('.'))
80  const stamp = (rows: string[], x: number, y: number) =>
81    rows.forEach((r, dy) => [...r].forEach((ch, dx) => { if (ch !== '.') grid[y + dy]![x + dx] = ch }))
82  stamp(BODY, 0, 6)
83  stamp([LEGS[stride % 2]!], 0, 11)
84  if (pose === 'graze') stamp(HEAD, 8, chew ? 5 : 4)
85  else stamp(pose === 'roar' ? ROAR : HEAD, 6, 0)
86  const rows = grid.map((r) => r.join(''))
87  return facing < 0 ? flip(rows) : rows
88}
89
90// The bones: the skull whole, the body as ribs
91const bones = (rows: string[]) => rows.map((r, y) => (y < 6 ? r : r.replace(/X/g, (x, i: number) => (i % 2 ? '.' : x))))
92
93type Baby = { id: string; x: number; born: number; gone?: number; color: string; facing: number; walked: number }
94type Effect = { kind: 'impact' | 'deflect' | 'hop'; at: number; x: number; py: number }
95
96export class Park {
97  safeTurns: number // turns since the last extinction
98  failures = 0 // failing test runs in a row: how close the meteor is
99  babies: Baby[] = []
100  private t = 0
101  private w = 80
102  private step: Step = { kind: 'think', text: 'thinking it over' }
103  private stepAt = 0
104  private meteor?: { x: number; py: number } // in pixels
105  private effect?: Effect
106  private rex = { x: 6, target: 6, facing: 1, walked: 0 }
107  private pteroAt = -Infinity
108  private random = seeded(7)
109  private hatched = 0
110
111  constructor(safeTurns = 0) {
112    this.safeTurns = safeTurns
113  }
114
115  get impacting() {
116    return this.effect?.kind === 'impact'
117  }
118
119  doing(step: Step) {
120    this.step = step
121    this.stepAt = this.t
122    if (step.kind === 'web') this.pteroAt = this.t
123  }
124
125  // A test run finished: a pass breaks up the meteor, a failure brings it closer
126  tested(isFailed: boolean): Outcome {
127    const m = this.meteor ?? { x: this.w - 8, py: 3 }
128    if (!isFailed) {
129      const had = this.failures > 0
130      this.failures = 0
131      this.meteor = undefined
132      if (!this.impacting) this.effect = { kind: had ? 'deflect' : 'hop', at: this.t, ...m }
133      return had ? 'deflected' : 'pass'
134    }
135    if (++this.failures < IMPACT_AT) return 'closer'
136    this.failures = 0
137    this.meteor = undefined
138    this.safeTurns = 0
139    this.effect = { kind: 'impact', at: this.t, ...m }
140    return 'impact'
141  }
142
143  hatch(id: string) {
144    const color = BABY_COLORS[this.hatched++ % BABY_COLORS.length]!
145    this.babies.push({ id, x: this.rex.x - 10 * this.rex.facing, born: this.t, color, facing: this.rex.facing, walked: 0 })
146  }
147
148  leave(id: string) {
149    const b = this.babies.find((b) => b.id === id && b.gone === undefined)
150    if (b) b.gone = this.t
151  }
152
153  private volcanoX() {
154    return this.w >= 60 ? this.w - 24 : undefined
155  }
156
157  private ferns() {
158    const end = (this.volcanoX() ?? this.w) - 6
159    const xs: number[] = []
160    for (let x = 22; x < end; x += 30) xs.push(x)
161    return xs.length ? xs : [Math.max(0, this.w - 6)]
162  }
163
164  advance(dt: number) {
165    this.t += dt
166    const { w, rex } = this
167    if (this.effect && this.t - this.effect.at > (this.impacting ? IMPACT_S : this.effect.kind === 'hop' ? HOP_S : DEFLECT_S)) this.effect = undefined
168    // The meteor drifts in to where its failures put it
169    if (this.failures > 0) {
170      const target = this.failures === 1 ? { x: w - 8, py: 3 } : { x: Math.round(w * 0.62), py: 6 }
171      const m = (this.meteor ??= { x: w + 4, py: -3 })
172      const k = Math.min(1, dt * 1.5)
173      m.x += (target.x - m.x) * k
174      m.py += (target.py - m.py) * k
175    }
176    if (!this.impacting) {
177      // Rex goes where the step happens: to a fern to read, wandering to think
178      const kind = this.step.kind
179      if (kind === 'read') {
180        const fern = this.ferns().reduce((a, b) => (Math.abs(b - rex.x) < Math.abs(a - rex.x) ? b : a))
181        rex.target = fern - 12
182      } else if (kind !== 'edit' && kind !== 'run' && kind !== 'test') {
183        if (Math.abs(rex.target - rex.x) < 0.5 && this.random() < dt / 2) {
184          rex.target = 2 + this.random() * Math.max(4, Math.min(w * 0.55, (this.volcanoX() ?? w) - 18))
185        }
186      }
187      rex.target = clamp(rex.target, 0, w - 15)
188      const d = rex.target - rex.x
189      if (Math.abs(d) > 0.3) {
190        const v = Math.sign(d) * Math.min(Math.abs(d), dt * 8)
191        rex.x += v
192        rex.facing = Math.sign(d)
193        rex.walked += Math.abs(v)
194      } else if (kind === 'read') rex.facing = 1
195    }
196    // Babies trail behind Rex; one whose helper is done walks off
197    let i = 0
198    for (const b of this.babies) {
199      if (this.t - b.born < 0.8) continue
200      const goal = b.gone !== undefined ? (rex.facing > 0 ? -14 : w + 4) : rex.facing > 0 ? rex.x - 11 * ++i : rex.x + 15 + 11 * i++
201      const d = goal - b.x
202      if (Math.abs(d) > 0.3) {
203        const v = Math.sign(d) * Math.min(Math.abs(d), dt * 9)
204        b.x += v
205        b.facing = Math.sign(d)
206        b.walked += Math.abs(v)
207      } else b.facing = rex.facing
208    }
209    this.babies = this.babies.filter((b) => b.gone === undefined || this.t - b.gone < 4)
210  }
211
212  draw(c: Canvas) {
213    this.w = c.w
214    c.clear()
215    const { t, rex, effect } = this
216    const k = effect ? t - effect.at : 0
217    if (effect?.kind === 'impact' && k >= 0.7 && k < 1.1) {
218      c.fill(0, 0, c.w, c.h, mix('#fff4d0', '#ff7a2a', (k - 0.7) / 0.4))
219      return
220    }
221    const ground = c.h - 1
222    const vx = this.volcanoX()
223    if (vx !== undefined) this.drawVolcano(c, vx)
224    for (const x of this.ferns()) c.art(x, ground * 2 - 8, FERN, { g: '#5f9a3a', b: '#6b4a2a' })
225    for (let x = 0; x < c.w; x++) c.put(x, ground, '▀', x % 7 === 3 ? '#5f8a3a' : GRASS, EARTH)
226    const n = this.safeTurns
227    c.text(1, ground, c.w >= 50 ? `${n} turn${n === 1 ? '' : 's'} since the last extinction` : `${n} turns safe`, LABEL, EARTH)
228    const extinct = effect?.kind === 'impact' && k >= 1.1
229    // A pterodactyl flies laps while the agent is on the web
230    if (!extinct && (this.step.kind === 'web' || t - this.pteroAt < 4)) {
231      const px = (((t - this.pteroAt) * 16) % (c.w + 26)) - 13
232      c.art(px, 1 + Math.round(Math.sin(t * 3)), PTERO[Math.floor(t * 5) % 2]!, { X: '#b08060', o: '#1e1414' })
233    }
234    if (!extinct) {
235      for (const b of this.babies) {
236        const py = ground * 2 - 6
237        if (t - b.born < 0.8) c.art(b.x + (Math.floor(t * 10) % 2), py + 1, EGG, { s: '#f0e6cc', d: '#9ab07a' })
238        else {
239          const rows = [...BABY, BABY_LEGS[Math.floor(b.walked / 1.2) % 2]!]
240          c.art(b.x, py, b.facing < 0 ? flip(rows) : rows, { X: b.color, o: '#1e1414' })
241        }
242      }
243    }
244    this.drawRex(c, ground, extinct)
245    if (this.meteor) this.drawMeteor(c, this.meteor.x, this.meteor.py, this.failures === 1 ? 1.6 : 2.6)
246    if (effect?.kind === 'deflect') this.drawSparks(c, effect, k / DEFLECT_S)
247    if (effect?.kind === 'impact') {
248      if (k < 0.7) this.drawMeteor(c, effect.x + (rex.x + 7 - effect.x) * (k / 0.7), effect.py + (ground * 2 - 2 - effect.py) * (k / 0.7), 3, false)
249      else this.drawDust(c, k)
250    }
251  }
252
253  private drawRex(c: Canvas, ground: number, extinct: boolean) {
254    const { t, rex, step, effect } = this
255    const k = effect ? t - effect.at : 0
256    const arrived = Math.abs(rex.target - rex.x) <= 0.3
257    let py = ground * 2 - 12
258    if (effect?.kind === 'hop') py -= Math.round(Math.sin((k / HOP_S) * Math.PI) * 3)
259    if (step.kind === 'edit' && !extinct) py -= Math.floor(t * 4) % 2
260    const roaring = (step.kind === 'run' && t - this.stepAt < 1.2) || effect?.kind === 'deflect'
261    const pose = extinct ? 'walk' : step.kind === 'read' && arrived ? 'graze' : roaring ? 'roar' : 'walk'
262    const stride = arrived ? 0 : Math.floor(rex.walked / 1.5)
263    const rows = rexRows(pose, stride, Math.floor(t * 3) % 2 === 1, rex.facing)
264    c.art(rex.x, py, extinct ? bones(rows) : rows, extinct ? FOSSIL : REX)
265    // Dust kicked up by the stomping that an edit is
266    if (step.kind === 'edit' && !extinct && Math.floor(t * 4) % 2) {
267      c.put(rex.x + 2, ground - 1, '·', '#a89880')
268      c.put(rex.x + 11, ground - 1, '∙', '#a89880')
269    }
270    if (this.impacting) return
271    const label = step.text.slice(0, c.w - 2)
272    c.tag(clamp(Math.round(rex.x + 7 - (label.length + 2) / 2), 0, c.w - label.length - 2), 0, label)
273  }
274
275  private drawMeteor(c: Canvas, x: number, py: number, r: number, tagged = true) {
276    const n = Math.round(r * 3)
277    for (let i = 1; i <= n; i++) c.pixel(x + i, py - i * 0.6, mix('#ff9a3a', '#3a1a10', i / n))
278    c.disc(x, py, r, '#ff7a2a')
279    c.disc(x, py, Math.max(0.8, r - 1), Math.floor(this.t * 8) % 2 ? '#ffe2a0' : '#fff4d0')
280    if (!tagged) return
281    const label = `tests failed ${this.failures}x`
282    c.tag(Math.max(0, Math.round(x - r) - label.length - 3), Math.max(0, Math.floor(py / 2)), label, TAG_ALERT)
283  }
284
285  private drawSparks(c: Canvas, e: Effect, k: number) {
286    for (let i = 0; i < 14; i++) {
287      const a = (i / 14) * Math.PI * 2
288      c.put(e.x + Math.cos(a) * k * 12, Math.floor((e.py + Math.sin(a) * k * 10) / 2), k < 0.5 ? '✦' : '·', mix('#ffe08a', '#ff5a2a', k))
289    }
290    c.tag(clamp(Math.round(e.x) - 8, 0, c.w - 12), 0, 'tests pass', '#7fe08a')
291  }
292
293  private drawVolcano(c: Canvas, x0: number) {
294    const cx = x0 + 11, top = c.h * 2 - 11
295    for (let r = 0; r < 9; r++) {
296      const half = 2 + r * 1.25
297      for (let x = Math.ceil(cx - half); x <= cx + half; x++) {
298        const lava = (r === 0 && Math.abs(x - cx) < 2) || (r > 0 && r < 4 && x === cx + (r % 2))
299        c.pixel(x, top + r, lava ? (r === 0 ? '#ff7a3a' : '#d4552a') : x < cx - half + 1.5 ? '#5c4a40' : '#4a3a34')
300      }
301    }
302    for (let i = 0; i < 3; i++) {
303      const phase = (this.t * 0.4 + i / 3) % 1
304      const y = Math.round(top / 2 - 1 - phase * 3)
305      if (y >= 0) c.put(cx + Math.round(Math.sin(this.t + i * 2) + phase * 3), y, phase < 0.5 ? 'o' : '°', mix('#9a908a', '#3a3632', phase))
306    }
307  }
308
309  // After the flash, dust that settles, and the news
310  private drawDust(c: Canvas, k: number) {
311    const left = 1 - (k - 1.1) / 1.9
312    for (let y = 0; y < c.h - 1; y++) {
313      for (let x = 0; x < c.w; x++) {
314        const v = (Math.sin(x * 0.35 + y * 1.7 + k * 2) + Math.sin(x * 0.13 - k * 1.3 + y)) / 4 + 0.5
315        if (v < left) c.set(x, y, 0x20, -1, mix('#5a4632', '#6b5640', v))
316      }
317    }
318    if (k < 1.6) return
319    const msg = c.w >= 52 ? ` EXTINCTION: ${IMPACT_AT} failing test runs in a row ` : ' EXTINCTION '
320    c.text(Math.max(0, Math.floor((c.w - msg.length) / 2)), 2, msg, TAG_ALERT, 0x1c1c26)
321  }
322}
323