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.

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 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.
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.
Rex acts out each step of the agent's work, tagged with what it is: [reading cart.ts], [running npm test].
| The agent is | Rex |
|---|---|
| thinking | wanders about |
| reading or searching | walks to a fern and grazes |
| editing or writing | stomps on the spot |
| running a command | roars |
| on the web | wanders while a pterodactyl flies laps overhead |
| starting a subagent | lays an egg; the hatchling trails Rex until its helper is done, then walks off |
A test run is a Bash command such as npm test, pytest, go test ./... or npx vitest run.
2 failed or FAIL) brings the meteor in. It's tagged [tests failed 1x], then [tests failed 2x], closer each time.[tests pass].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.
/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./plugin.The park draws only in the terminal. On the desktop the spinner is left alone.
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.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.$.store.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.
MIT. Rex wears the coral of Clawd, the Claude Code mascot.
hooks/register.tsx 140 lines1import 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}
140hooks/canvas.ts 135 lines1// 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}
135hooks/park.ts 323 lines1// 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