Clawd acts out what Claude is doing above the prompt, or runs an endless-runner course: switch with /runner

A Claude Code mod that draws above your prompt while Claude works. Pick an act:
Neither act uses any tokens: the hooks only observe and draw. (Only /clawd <message> makes one small Haiku call.)
In Claude Code:
/plugin marketplace add ArjunChavan2/clawd-sunset-surfer-mod
/plugin install clawd-mods@clawd-mods
/reload-plugins
| Command | Effect |
|---|---|
/runner | Toggle between Clawd and the sunset surfer (remembered across sessions) |
/runner clawd / /runner runner | Pick one |
/runner scores | Show your personal top 5 runs (distance per turn; kept locally, never uploaded) |
/runner reset | Clear your scores |
/clawd pet feed wave dance | Interact with Clawd (Clawd act only) |
/clawd <message> | Chat in a speech bubble (one small Haiku call) |
Needs a terminal with 24-bit color for the runner's gradients (iTerm2, Ghostty, Kitty, WezTerm). It draws only in the terminal and Desktop Code tab, not the VS Code chat panel or claude -p.
claude plugin validate .
claude plugin test .
The runner is hooks/world.ts (a pure function of the tick, so it's testable); the band and /runner switch are in hooks/register.tsx.
Built on clawd-actor by Brian Huang (MIT): the Clawd scenes and /clawd chat are his. The runner and the switch are additions; upstream's update check was removed. Fan-made, not affiliated with Anthropic. MIT, see LICENSE.
hooks/register.tsx 234 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import { bubble, clean, holdFrames, parseClawd, persona, HELP } from './chat'
5import { ACT_FRAMES, FRAME_MS, frameRows, H, hold, type Mode as Scene, type Shown } from './scenes'
6import { frameRows as runnerRows, H as RUN_H, MAX_STAGE as RUN_STAGE, FRAME_MS as RUN_MS } from './world'
7import type { Band } from '../types'
8import { board, cleanBoard, isBest, record } from './scores'
9
10const MAX_STAGE = 60 // widest the wander roams, however wide the terminal
11const frame = atom({ plugin: 'clawd-mods', key: 'frame' } as const, 0)
12// The turn's show, kept in $.state too: a hot reload (/reload-plugins) loses module variables, not state
13const band = atom({ plugin: 'clawd-mods', key: 'band' } as const, 'clawd' as Band) // which act draws: Clawd, or the runner
14const show = atom({ plugin: 'clawd-mods', key: 'show' } as const, { isTurnRunning: false, turnBase: 0, seed: -1, played: 0 })
15
16// The spinner's word, read while it draws. State cannot be written during a render,
17// so it lives here; the frame timer's redraws pick it up.
18let word = 'Thinking'
19let mode: Scene = 'thinking'
20let isTurnRunning = false
21let isRunner = false // the band's act, mirrored from $.store for the timers
22let scores: number[] = [] // the runner's personal best distances, highest first, kept in $.store
23let turnBase = 0 // the frame the turn started on: acts count from it
24let seed = 0 // shuffles the session's rotation of scenes
25let played = 0 // rotation acts earlier turns got through
26let shown: Shown | undefined // the scene on stage, held for its act
27// What /clawd set going: a scene played over the show from frame `from`, and what Clawd says, for `left` more frames
28let reaction: { scene: string; say?: string; left: number; from: number } | undefined
29let asked = 0 // /clawd commands so far: a reply to any but the latest is dropped
30const running: { id: string; tool: string }[] = [] // tools in flight, newest last
31
32const REACT_FRAMES = Math.round(6000 / FRAME_MS) // how long a /clawd action plays
33const WAIT_FRAMES = Math.round(15_000 / FRAME_MS) // the most Clawd ponders before a reply
34
35/** A finished runner turn's distance goes on the personal board; a new best gets a toast. */
36const recordRun = async ($: Engine, m: number) => {
37 if (m < 10) return // a blink of a turn is not a run
38 const best = isBest(scores, m)
39 scores = record(scores, m)
40 await $.store.set('scores', scores)
41 if (best) $.ui.toast(`New best: ${m}m`)
42}
43
44/** Writes the turn's show through to $.state, for the module a reload brings in. */
45const keep = ($: Engine) => update($, show, () => ({ isTurnRunning, turnBase, seed, played }))
46
47/** Moves the show on a frame, which redraws the band. */
48const tick = ($: Engine) => update($, frame, f => f + 1)
49
50/** Plays `scene` from its start over the show for `frames` frames, Clawd saying `say`; redraws at once, even between turns. */
51const react = async ($: Engine, scene: string, say?: string, frames = REACT_FRAMES) => {
52 reaction = { scene, say, left: frames, from: (await read($, frame)) + 1 }
53 return tick($)
54}
55
56/** What Claude is up to, for Clawd's system prompt. */
57const status = () => {
58 if (!isTurnRunning) return 'Claude is idle, waiting for the user.'
59 const tool = running[running.length - 1]?.tool
60 return `Claude is working (the spinner says "${word}"${tool ? `, the ${tool} tool is running` : ''}); you are acting out the "${shown?.scene ?? 'think'}" scene.`
61}
62
63/** Asks Haiku for Clawd's answer and puts it in the bubble; Claude's own conversation never sees either side. */
64const reply = async ($: Engine, text: string, now: string, id: number) => {
65 let say = 'Hmm, my words got lost on the way. Try again?'
66 try {
67 const r = await $.model.complete({ model: 'haiku', system: persona(now), prompt: text, maxTokens: 150, timeoutMs: 15_000 })
68 if (r.isAnswered && clean(r.text)) say = clean(r.text)
69 } catch {
70 // the engine would not send it (a blocked model, say): Clawd still answers, with the line above
71 }
72 if (id === asked) await react($, 'say', say, holdFrames(say)) // else a newer /clawd took the stage meanwhile
73}
74
75export const register: Register = on => {
76 // A timer must start where work may outlive the dispatch: session.start (fires again on reload).
77 // It ticks all session long but only writes, and so redraws, while a turn runs.
78 on('session.start', async ($, e, next) => {
79 const r = await next(e)
80 // a reload mid-turn picks the show up where it was; a new session starts one
81 ;({ isTurnRunning, turnBase, seed, played } = await read($, show))
82 if (seed < 0) {
83 seed = Math.floor(await $.clock.now()) % 9973
84 await keep($)
85 }
86 isRunner = (await $.store.get('band')) === 'runner'
87 scores = cleanBoard(await $.store.get('scores'))
88 await update($, band, () => (isRunner ? 'runner' : 'clawd'))
89 $.clock.every(FRAME_MS, () => {
90 const isReacting = reaction !== undefined
91 if (reaction && --reaction.left <= 0) reaction = undefined
92 // a reaction redraws between turns too, one last time as it ends so the stage goes
93 return !isRunner && (isTurnRunning || isReacting) ? tick($) : undefined
94 })
95 // the runner is tuned to its own, faster tick; each timer writes only in its own act
96 $.clock.every(RUN_MS, () => (isRunner && isTurnRunning ? tick($) : undefined))
97 // last, so the timers run whatever happens here; immediate: /clawd runs mid-turn instead of waiting
98 await $.command.register({ name: 'runner', description: 'Switch the band between Clawd and the runner', argumentHint: '[clawd|runner|scores|reset]', immediate: true })
99 await $.command.register({ name: 'clawd', description: 'Pet, feed or wave at Clawd, or say something to it', argumentHint: '[pet|feed|wave|dance|shift|message]', immediate: true })
100 return r
101 })
102
103 // Answers in Clawd's bubble only: no transcript line, so nothing of it reaches Claude
104 on('command.run', { command: 'clawd' }, async ($, e) => {
105 if (isRunner) {
106 $.ui.toast('Clawd is on a break: /runner brings him back')
107 return {}
108 }
109 const id = ++asked
110 const ask = parseClawd(e.args)
111 if ('help' in ask) await react($, 'wave', HELP)
112 else if ('action' in ask) await react($, ask.action)
113 else {
114 await react($, 'ponder', '· · ·', WAIT_FRAMES)
115 const now = status()
116 $.clock.after(1, () => reply($, ask.chat, now, id)) // on a timer: the prompt never waits on the model
117 }
118 return {}
119 })
120
121 // Answers in a toast only: no transcript line, so nothing of it reaches Claude
122 on('command.run', { command: 'runner' }, async ($, e) => {
123 const arg = String(e.args ?? '').trim().toLowerCase()
124 if (arg === 'scores') {
125 $.ui.toast(`Best runs: ${board(scores)}`)
126 return {}
127 }
128 if (arg === 'reset') {
129 scores = []
130 await $.store.set('scores', scores)
131 $.ui.toast('Scores cleared')
132 return {}
133 }
134 const next: Band = arg === 'runner' || arg === 'clawd' ? arg : isRunner ? 'clawd' : 'runner'
135 isRunner = next === 'runner'
136 await update($, band, () => next)
137 await $.store.set('band', next)
138 $.ui.toast(isRunner ? 'Band: the runner' : 'Band: Clawd')
139 return {}
140 })
141
142 on('turn.start', async ($, e, next) => {
143 isTurnRunning = true
144 turnBase = await read($, frame)
145 running.length = 0
146 await keep($)
147 return next(e)
148 })
149
150 // While a tool runs Clawd acts it out; back to the rotation once it returns
151 on('tool.call', async ($, e, next) => {
152 running.push({ id: e.tool_use_id, tool: e.tool })
153 try {
154 return await next(e)
155 } finally {
156 const i = running.findIndex(r => r.id === e.tool_use_id)
157 if (i >= 0) running.splice(i, 1)
158 }
159 })
160
161 on('turn.complete', async ($, e, next) => {
162 if (e.agentId) return next(e) // a subagent's turn (a workflow's agents too): the main turn runs on
163 if (isRunner && isTurnRunning) await recordRun($, (await read($, frame)) - turnBase)
164 isTurnRunning = false
165 played += Math.floor(((await read($, frame)) - turnBase) / ACT_FRAMES) // acts after the opener
166 await keep($)
167 return next(e)
168 })
169
170 // Observe only: remember what the spinner says, let the engine draw it as usual
171 on('ui.render', { component: 'Spinner' }, ($, e, next) => {
172 word = e.props.message ?? e.props.word
173 mode = e.props.mode
174 return next(e)
175 })
176
177 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
178 // read before any early return: the engine tracks the atoms a render reads and redelivers on
179 // their writes — a gated return that reads nothing unregisters the frame dependency, and the
180 // band would never redraw on its own again (the whole-turn blackout)
181 const t = await read($, frame)
182 const act = await read($, band)
183 if (act === 'runner') {
184 if (e.props.hasSurvey || !e.props.isWorking || !isTurnRunning || e.props.maxRows < RUN_H) return next(e)
185 const stage = Math.min(e.props.bodyColumns, RUN_STAGE)
186 const rest = await next({ ...e, props: { ...e.props, maxRows: e.props.maxRows - RUN_H } })
187 const ui = $.ui.resolve(e)
188 return (
189 <ui.Box flexDirection="column">
190 <ui.Box flexDirection="column" height={RUN_H}>
191 {runnerRows(t - turnBase, stage, seed + turnBase, scores[0] ?? 0).map(runs => (
192 <ui.Text>{runs.map(r => <ui.Text color={r.color} backgroundColor={r.bg}>{r.text}</ui.Text>)}</ui.Text>
193 ))}
194 </ui.Box>
195 {rest}
196 </ui.Box>
197 )
198 }
199 // isWorking is the engine's own word on whether a turn runs — but not the turn itself: a
200 // command's busy blink (a /reload-plugins, a plugin hot-reload's repaint) turns it on with no
201 // turn started, and the band would flash Clawd for the one frame before the command ends.
202 // isTurnRunning follows the turn; a /clawd reaction plays past both.
203 if (e.props.hasSurvey || ((!e.props.isWorking || !isTurnRunning) && !reaction) || e.props.maxRows < H) return next(e)
204
205 const width = Math.min(e.props.bodyColumns, MAX_STAGE)
206 // the bubble needs its two border rows besides the text: left out when the band has no room for one line
207 const room = Math.min(3, e.props.maxRows - H - 2)
208 const said = reaction?.say && room > 0 ? bubble(reaction.say, width, room) : []
209 // keep whatever other band mods draw here, in the rows Clawd leaves them
210 const below = await next({ ...e, props: { ...e.props, maxRows: e.props.maxRows - H - said.length } })
211
212 const { Box, Text } = $.ui.resolve(e)
213 shown = hold(shown, turnBase, { word, mode, tick: t - turnBase, seed, played, tool: running[running.length - 1]?.tool })
214 // a reaction counts its own frames, so a move like shift opens on its first one
215 const rows = reaction ? frameRows(word, mode, Math.max(0, t - reaction.from), width, reaction.scene) : frameRows(word, mode, t, width, shown.scene)
216
217 return (
218 <Box flexDirection="column">
219 {said.map(line => (
220 <Text color="white">{line}</Text>
221 ))}
222 <Box flexDirection="column" height={H}>
223 {rows.map(runs => (
224 <Text>
225 {runs.map(r => (r.color ? <Text color={r.color} backgroundColor={r.bg}>{r.text}</Text> : r.text))}
226 </Text>
227 ))}
228 </Box>
229 {below}
230 </Box>
231 )
232 })
233}
234hooks/chat.ts 80 lines1// /clawd: what the person said, how Clawd answers, and the speech bubble it answers in.
2// Pure functions only, like scenes.ts.
3
4import { FRAME_MS } from './scenes.ts'
5
6/** `/clawd <word>` actions: free and instant, no model call. Each names the scene it plays. */
7export const ACTIONS: Record<string, string> = { pet: 'pet', feed: 'feed', wave: 'wave', hi: 'wave', hello: 'wave', dance: 'disco', shift: 'shift', 閃身步: 'shift' }
8export const HELP = 'Try /clawd pet, feed, wave, dance or shift, or just say something to me!'
9const MAX_ASK = 500 // characters of a message passed on to the model
10const MAX_LINES = 3 // of text in the bubble
11
12export type Ask = { help: true } | { action: string } | { chat: string }
13
14/** A lone action word plays that action; anything else is a chat message. */
15export const parseClawd = (args: string): Ask => {
16 const text = args.trim()
17 if (!text) return { help: true }
18 const word = text.toLowerCase()
19 const action = Object.hasOwn(ACTIONS, word) ? ACTIONS[word] : undefined // not what every object inherits
20 return action ? { action } : { chat: text.slice(0, MAX_ASK) }
21}
22
23/** The text with control characters (escape sequences included) and runs of whitespace made single spaces. */
24export const clean = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim()
25
26// Characters a terminal draws two columns wide: CJK, Hangul, full-width forms
27const WIDE = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/
28// and none: combining marks (an accent typed apart, Thai and Hindi vowel marks) sit on the character before
29const ZERO = /[\p{Mn}\p{Me}]/u
30/** Columns the text takes on a terminal. */
31export const cells = (text: string) => [...text].reduce((n, ch) => n + (ZERO.test(ch) ? 0 : WIDE.test(ch) ? 2 : 1), 0)
32
33/** Words, single wide characters (which may break anywhere) and spaces, in order. */
34const tokens = (text: string) => text.match(new RegExp(`${WIDE.source}|[^\\s${WIDE.source.slice(1, -1)}]+|\\s+`, 'g')) ?? []
35
36/** Wraps `text` into at most `maxLines` lines of `width` columns, the last ending in … when it runs over. */
37const wrap = (text: string, width: number, maxLines: number) => {
38 const lines: string[] = []
39 let line = ''
40 for (const token of tokens(text)) {
41 if (/^\s+$/.test(token)) {
42 if (line) line += ' '
43 continue
44 }
45 // a word wider than a whole line is cut to fit
46 const word = cells(token) > width ? [...token].slice(0, width - 1).join('') + '…' : token
47 if (cells(line + word) > width) {
48 lines.push(line.trimEnd())
49 line = word
50 } else line += word
51 }
52 if (line.trim()) lines.push(line.trimEnd())
53 if (lines.length <= maxLines) return lines
54 const last = [...lines[maxLines - 1]]
55 while (cells(last.join('')) > width - 1) last.pop()
56 return [...lines.slice(0, maxLines - 1), last.join('') + '…']
57}
58
59/** The speech bubble Clawd says `text` in: a box `width` columns wide at most, its tail pointing down. */
60export const bubble = (text: string, width: number, maxLines = MAX_LINES): string[] => {
61 const lines = wrap(clean(text), Math.max(8, width - 6), maxLines)
62 const w = Math.max(4, ...lines.map(cells))
63 const pad = (l: string) => l + ' '.repeat(w - cells(l))
64 return [`╭${'─'.repeat(w + 2)}╮`, ...lines.map(l => `│ ${pad(l)} │`), `╰───┬${'─'.repeat(w - 2)}╯`]
65}
66
67/** Frames a reply stays up: long enough to read, 5 to 15 seconds. */
68export const holdFrames = (text: string) => Math.round(Math.min(15_000, 5_000 + cells(text) * 60) / FRAME_MS)
69
70/** Clawd's system prompt; `status` says what Claude is doing right now. */
71export const persona = (status: string) =>
72 [
73 "You are Clawd, Claude Code's small orange pixel-art mascot. You live on a little stage above the",
74 "user's prompt and act out what Claude is doing. Answer the user in one or two short sentences",
75 '(25 words at most): warm, playful, a little silly. Plain text only: no markdown, no emoji.',
76 "Answer in the user's language. You cannot do tasks or run anything: when asked for real work,",
77 'say cheerfully that Claude is the one for that.',
78 `Right now: ${status}`,
79 ].join('\n')
80hooks/scenes.ts 768 lines1// Clawd's stage: a width x H grid of cells. Every scene draws Clawd plus props for one frame,
2// then the whole frame wanders left and right across the stage. The top row is kept for what Clawd
3// wears on its head: scenes draw from the row below it, so a hat still shows when Clawd hops.
4// Pure functions only, so the frames can be checked without the engine.
5
6export const W = 40 // default stage width; the band passes its own
7export const H = 5
8const HEAD = 1 // rows above scene row 0: the hat row
9export const ORANGE = '#D77757'
10const SHADE = '#B35A3E' // the side turned away, as the official animation shades it
11const INK = '#1E1E1E' // eye colour where an eye is drawn on top of the body
12const PURPLE = '#A374F5'
13
14export type Cell = { ch: string; color?: string; bg?: string }
15export type Grid = Cell[][]
16
17// One empty cell shared by the whole grid: it is only ever replaced, never changed
18const SPACE: Cell = { ch: ' ' }
19const blank = (width: number): Grid => Array.from({ length: H }, () => Array(width).fill(SPACE))
20
21/** Paints `s` at (x, y), moved along by the wander; spaces are transparent so props can overlap Clawd's gaps. */
22const put = (g: Grid, x: number, y: number, s: string, color?: string, bg?: string) => {
23 const row = g[y + HEAD]
24 if (!row) return
25 ;[...s].forEach((ch, i) => {
26 const cx = x + stride.dx + i
27 if (ch !== ' ' && cx >= 0 && cx < row.length) row[cx] = bg ? { ch, color, bg } : { ch, color }
28 })
29}
30/** Paints `s` top to bottom from (x, y), a character a row; each is a wide one taking two cells, the second left empty. */
31const tall = (g: Grid, x: number, y: number, s: string, color?: string) => {
32 const cx = x + stride.dx
33 ;[...s].forEach((ch, i) => {
34 const row = g[y + HEAD + i]
35 if (row && cx >= 0 && cx + 1 < row.length) [row[cx], row[cx + 1]] = [{ ch, color }, { ch: '', color }]
36 })
37}
38/** Recolours what is already drawn at (x, y), keeping the glyph. */
39const tint = (g: Grid, x: number, y: number, color: string) => {
40 const c = g[y + HEAD]?.[x + stride.dx]
41 if (c && c.ch !== ' ') c.color = color
42}
43
44// Clawd, 9 columns x 3 rows, as the welcome banner draws it.
45// smile is drawn as blink with two ^ eyes laid over it (see clawd()).
46const TOP = { open: ' ▐▛███▜▌ ', blink: ' ▐█████▌ ', smile: ' ▐█████▌ ', left: ' ▐▜███▜▌ ', right: ' ▐▛███▛▌ ' }
47const MID = { down: '▝▜█████▛▘', up: '▗▟█████▙▖', left: '▗▟█████▛▘', right: '▝▜█████▙▖', flat: '▟███████▙' }
48const LEGS = { stand: ' ▘▘ ▝▝ ', a: ' ▘ ▘▝ ▝ ', b: ' ▘ ▘ ▝ ▝ ', curl: ' ▘▝ ▘▝ ', tuck: ' ' }
49
50type Pose = {
51 x?: number
52 /** 1 hops a row up, -1 squashes a row down (legs out of sight, body spread) */
53 lift?: -1 | 0 | 1
54 eyes?: keyof typeof TOP
55 arms?: keyof typeof MID
56 legs?: keyof typeof LEGS
57 /** which side has turned away from us; the body sways between them on its own */
58 shade?: 'left' | 'right' | 'none'
59 color?: string
60}
61
62// What the wander is doing this frame, `dx` columns along; scenes read it through clawd() and put() so every pose walks along
63const STILL = { dx: 0, moving: false, hop: false, squash: false, dir: 1 }
64const stride = { ...STILL, width: W }
65
66/**
67 * The face when a scene leaves it alone: mostly content ^ ^ eyes, a look around,
68 * a blink or a double blink; while travelling it looks the way it goes.
69 */
70const face = (t: number): keyof typeof TOP => {
71 if (stride.moving) return t % 20 === 0 ? 'blink' : stride.dir > 0 ? 'right' : 'left'
72 const p = t % 56
73 if (p < 14) return p === 7 ? 'blink' : 'open'
74 if (p < 36) return 'smile'
75 if (p < 40) return 'left'
76 if (p < 44) return 'right'
77 return p === 49 || p === 51 ? 'blink' : 'open'
78}
79/** A slow sway: turn one way, face front, turn the other way. */
80const sway = (t: number): NonNullable<Pose['shade']> => {
81 if (stride.moving) return stride.dir > 0 ? 'left' : 'right'
82 const p = t % 16
83 return p < 6 ? 'left' : p < 8 ? 'none' : p < 14 ? 'right' : 'none'
84}
85/** Columns a travelling scene may roam: the stage less Clawd and its trailing props. */
86const roam = (extra: number) => Math.max(4, stride.width - 11 - extra)
87
88const clawd = (g: Grid, t: number, pose: Pose = {}) => {
89 const x = pose.x ?? 2
90 const lift = pose.lift ?? (stride.hop ? 1 : stride.squash ? -1 : 0)
91 const y = 1 - lift
92 const eyes = pose.eyes ?? face(t)
93 const swing = t % 2 === 0 ? 'left' : 'right'
94 const color = pose.color ?? ORANGE
95 const shade = pose.shade ?? sway(t)
96 const costume = color === ORANGE ? wearing : undefined
97 put(g, x, y, TOP[eyes], color)
98 if (eyes === 'smile') {
99 // the happy closed eyes: dark arcs sitting on the body
100 put(g, x + 2, y, '^', INK, color)
101 put(g, x + 6, y, '^', INK, color)
102 }
103 put(g, x, y + 1, MID[lift < 0 ? 'flat' : costume?.arms ?? pose.arms ?? (stride.moving ? swing : 'down')], color)
104 const legs = pose.legs ?? (stride.moving ? walkLegs(t) : shade === 'none' ? 'curl' : 'stand')
105 put(g, x, y + 2, LEGS[legs], color)
106 // turned a little: the far edge of the body falls into shadow
107 if (color === ORANGE && shade !== 'none') {
108 const [top, mid] = shade === 'left' ? [x + 1, x] : [x + 7, x + 8]
109 tint(g, top, y, SHADE)
110 tint(g, mid, y + 1, SHADE)
111 tint(g, shade === 'left' ? x + 1 : x + 7, y + 1, SHADE)
112 }
113 costume?.dress(g, x, y)
114}
115
116// A little Clawd, 5 columns x 2 rows, standing on the ground (or a row up when it hops)
117const MINI_TOP = { open: '▗▛█▜▖', blink: '▗███▖', left: '▗▜█▜▖', right: '▗▛█▛▖' }
118// second row: the top halves close under the eyes, the bottom halves are the legs (out, or tucked in mid-step)
119const MINI_LEGS = { a: '▝▛▀▜▘', b: '▝▜▀▛▘' }
120const MINI_COLORS = ['#6FA8DC', '#E8C547', '#7BC67E', '#E88AB5'] // blue, yellow, green, pink
121const mini = (g: Grid, x: number, color: string, { lift = 0, eyes = 'open', legs = 'a' }: { lift?: number; eyes?: keyof typeof MINI_TOP; legs?: keyof typeof MINI_LEGS } = {}) => {
122 put(g, x, 2 - lift, MINI_TOP[eyes], color)
123 put(g, x, 3 - lift, MINI_LEGS[legs], color)
124}
125
126/** Back-and-forth position over `span` columns. */
127const pingPong = (t: number, span: number) => {
128 const p = t % (span * 2)
129 return p < span ? p : span * 2 - p
130}
131const walkLegs = (t: number): keyof typeof LEGS => (t % 2 === 0 ? 'a' : 'b')
132/** Which way a pingPong(t, span) is heading. */
133const goingRight = (t: number, span: number) => t % (span * 2) < span
134/** Looks and turns toward `right`, with a blink now and then. */
135const facing = (t: number, right: boolean): Pose => ({
136 eyes: t % 20 === 0 ? 'blink' : right ? 'right' : 'left',
137 shade: right ? 'left' : 'right',
138})
139const pick = <T,>(xs: readonly T[], t: number) => xs[t % xs.length]
140
141// ---- Costumes: some scenes dress Clawd for the part. Drawn over Clawd at its x and head row y, so they hop along ----
142
143type Costume = { dress: (g: Grid, x: number, y: number) => void; arms?: keyof typeof MID }
144let wearing: Costume | undefined // the costume this frame's scene puts on the orange Clawd
145
146const HAT = '#3A3A42' // charcoal, not black: a black hat would vanish on a dark terminal
147const HAT_BAND = '#8E8E99'
148const LENS = PURPLE
149const JERSEY = '#3D7BD9'
150const PERM = '#8C62B0'
151// Which quarters of a cell each block fills: a (top left), b (top right), c (bottom left), d (bottom right)
152const QUARTERS: Record<string, string> = { '▘': 'a', '▝': 'b', '▖': 'c', '▗': 'd', '▀': 'ab', '▄': 'cd', '▌': 'ac', '▐': 'bd', '▛': 'abc', '▜': 'abd', '▙': 'acd', '▟': 'bcd', '█': 'abcd' }
153
154/** A hat brim over the top half of each cell from x, keeping the bottom half as drawn, so the eyes peek out under it. */
155const brow = (g: Grid, x: number, y: number, n: number) => {
156 const row = g[y + HEAD]
157 for (let i = 0; i < n; i++) {
158 const c = row?.[x + stride.dx + i]
159 if (!c) continue
160 const q = QUARTERS[c.ch] ?? '' // ^ happy eyes: shut under the brim
161 const [ll, lr] = [q.includes('c'), q.includes('d')]
162 // a cell takes two colours: where only one bottom quarter is drawn, the hat's fills the other (an eye in shadow)
163 row[x + stride.dx + i] = ll && lr ? { ch: '▀', color: HAT, bg: c.bg ?? c.color } : ll || lr ? { ch: ll ? '▖' : '▗', color: c.color, bg: HAT } : { ch: '▀', color: HAT }
164 }
165}
166
167const COSTUMES: Record<string, Costume> = {
168 // detective: a fedora with a band, its brim over the brow, and a magnifying glass held out in the right hand
169 think: {
170 arms: 'right',
171 dress: (g, x, y) => {
172 put(g, x + 3, y - 1, '▃▃▃▃▃', HAT_BAND, HAT)
173 brow(g, x + 1, y, 7)
174 put(g, x, y, '▝', HAT)
175 put(g, x + 8, y, '▀▖', HAT) // the brim tips down on the right
176 put(g, x + 9, y + 1, '▄', LENS) // the handle, from the hand
177 put(g, x + 10, y, '▗▖', LENS)
178 put(g, x + 10, y + 1, '▚▞', LENS)
179 },
180 },
181 // footballer: a blue number 10 shirt and a white headband
182 kick: {
183 dress: (g, x, y) => {
184 for (let i = 0; i < 9; i++) tint(g, x + i, y + 1, JERSEY)
185 put(g, x + 3, y + 1, '10', 'white', JERSEY)
186 put(g, x + 1, y - 1, '▗▄▄▄▄▄▖', 'white')
187 },
188 },
189 // astronaut: a glass helmet close round the head, the face left clear, and an oxygen tank on its back
190 levitate: {
191 dress: (g, x, y) => {
192 put(g, x, y - 1, '▗▄▄▄▄▄▄▄▖', 'white')
193 put(g, x, y, '▐', 'white')
194 put(g, x + 8, y, '▌', 'white')
195 put(g, x - 2, y + 1, '▐█', GRAY)
196 },
197 },
198}
199// homemaker, at the stove and the oven alike: a big curly perm and a white apron with a heart pocket
200const HOMEMAKER: Costume = {
201 dress: (g, x, y) => {
202 put(g, x - 1, y - 1, '▗▟█▙▟█▙▟█▙▖', PERM)
203 put(g, x - 1, y, '▐█▌', PERM)
204 put(g, x + 7, y, '▐█▌', PERM)
205 put(g, x - 1, y + 1, '▝▘', PERM)
206 put(g, x + 8, y + 1, '▝▘', PERM)
207 put(g, x + 2, y + 1, '▄▄▄▄▄', 'white', ORANGE)
208 put(g, x + 4, y + 1, '♥', MINI_COLORS[3], 'white')
209 put(g, x + 3, y + 2, '▀▀▀', 'white')
210 },
211}
212COSTUMES.cook = HOMEMAKER
213COSTUMES.bake = HOMEMAKER
214Object.assign(COSTUMES, {
215 // wizard: a tall pointed hat and a long white beard
216 magic: {
217 dress: (g, x, y) => {
218 put(g, x, y - 1, '▁▁▃▆█▆▃▁▁', '#5B4FCF')
219 put(g, x + 3, y + 1, '▜█▛', 'white', ORANGE)
220 },
221 },
222 // cowboy: a hat with its brim curled up at both ends and a dent in the crown, a red neckerchief
223 herd: {
224 dress: (g, x, y) => {
225 put(g, x, y - 1, '▙▄▟█▇█▙▄▟', '#A0703C')
226 put(g, x + 3, y + 1, '▀█▀', 'red', ORANGE)
227 },
228 },
229 // painter: a beret tipped to one side and a palette in the other hand
230 sketch: {
231 dress: (g, x, y) => {
232 put(g, x + 2, y - 1, '▗▄▟▄▄▄▖', '#D04848')
233 put(g, x - 2, y + 1, '∙', '#56B6C2', WOOD)
234 put(g, x - 1, y + 1, '∙', '#E5C07B', WOOD)
235 },
236 },
237 // professor: a mortarboard, its tassel hanging down beside the head
238 chalk: {
239 dress: (g, x, y) => {
240 put(g, x, y - 1, '▀▀█████▀▀', HAT)
241 put(g, x + 8, y, '▌', 'yellow')
242 },
243 },
244 // DJ: headphones, the band over the head and a cup on each side
245 disco: {
246 dress: (g, x, y) => {
247 put(g, x, y - 1, '▗▄▄▄▄▄▄▄▖', 'white')
248 put(g, x, y, '█', 'magenta')
249 put(g, x + 8, y, '█', 'magenta')
250 },
251 },
252 // clown: a red nose, tufts of blue hair on both sides and a frilled collar
253 juggle: {
254 dress: (g, x, y) => {
255 put(g, x + 4, y, '●', 'red', ORANGE)
256 put(g, x - 1, y - 1, '▗▄', 'cyan')
257 put(g, x + 8, y - 1, '▄▖', 'cyan')
258 put(g, x - 1, y, '▐█', 'cyan')
259 put(g, x + 8, y, '█▌', 'cyan')
260 ;['yellow', 'magenta', 'cyan', 'magenta', 'yellow'].forEach((c, i) => put(g, x + 2 + i, y + 1, '▀', c, ORANGE))
261 },
262 },
263} satisfies Record<string, Costume>)
264export const DRESSED = Object.keys(COSTUMES)
265
266type Scene = (g: Grid, t: number) => void
267
268const GRAY = 'gray'
269const BOARD = '#2E4A3B'
270const WOOD = '#8B5A2B'
271// What Clawd works out on the blackboard, two lines a go (8 columns each)
272const LESSONS: [string, string][] = [['a+b=c', 'x^2+1=y'], ['f(x)=?', ' = 42'], ['1+1=2', 'Q.E.D.'], ['if (ok)', ' ship!']]
273const scenes: Record<string, Scene> = {
274 idle: (g, t) => {
275 // a little hop, then it lands with a squash and settles
276 const p = t % 24
277 clawd(g, t, { lift: p === 10 ? 1 : p === 11 || p === 12 ? -1 : 0, eyes: p >= 9 && p <= 16 ? 'smile' : undefined })
278 // a little one copies the hop a beat late
279 mini(g, 12, MINI_COLORS[0], { lift: p === 12 ? 1 : 0, eyes: p % 12 === 5 ? 'blink' : 'left' })
280 },
281
282 think: (g, t) => {
283 clawd(g, t, { arms: t % 16 < 8 ? 'down' : 'right' })
284 const bubble = ['·', '· ∘', '· ∘ ○', '· ∘ ○ ?', '· ∘ ○ !'][Math.floor(t / 3) % 5]
285 put(g, 11, 0, bubble, GRAY)
286 },
287
288 cook: (g, t) => {
289 clawd(g, t, { arms: t % 4 < 2 ? 'left' : 'right' }) // stirring
290 put(g, 12, 1, pick([' ~ ', ' ~ ≈ ', '≈ ~ ≈', ' ≈ ~ '], t), GRAY)
291 put(g, 12, 2, '▄▄▄▄▄', 'white')
292 put(g, 12, 3, '▀███▀', 'white')
293 put(g, 11, 2, t % 4 < 2 ? '\\' : '|', 'yellow') // spoon
294 },
295
296 bake: (g, t) => {
297 clawd(g, t, { arms: t % 10 < 5 ? 'right' : 'down' })
298 put(g, 12, 1, '┌────┐', 'white')
299 put(g, 12, 2, '│ │', 'white')
300 put(g, 13, 2, pick(['▒░▒░', '░▒░▒'], t), 'red')
301 put(g, 12, 3, '└────┘', 'white')
302 put(g, 14, 0, pick(['~', ' ~', '~ ~'], Math.floor(t / 2)), GRAY)
303 },
304
305 brew: (g, t) => {
306 clawd(g, t, { arms: 'right' })
307 put(g, 12, 1, pick([' ≈ ', '≈ ', ' ≈'], Math.floor(t / 2)), GRAY)
308 put(g, 12, 2, '╭─╮╮', 'white')
309 put(g, 12, 3, '╰─╯ ', 'white')
310 put(g, 13, 2, '▀', 'yellow')
311 },
312
313 walk: (g, t) => {
314 // two little ones in a row behind it; at the end they all about-face, so the little ones lead the way back
315 const right = goingRight(t, roam(12))
316 const x = 13 + pingPong(t, roam(12))
317 clawd(g, t, { x, legs: walkLegs(t), arms: t % 2 ? 'left' : 'right', ...facing(t, right) })
318 const eyes = right ? 'right' : 'left'
319 mini(g, x - 6, MINI_COLORS[1], { eyes, legs: t % 2 ? 'a' : 'b' })
320 mini(g, x - 12, MINI_COLORS[2], { eyes, legs: t % 2 ? 'b' : 'a' })
321 },
322
323 run: (g, t) => {
324 const x = 1 + pingPong(t * 2, roam(0))
325 const right = goingRight(t * 2, roam(0))
326 clawd(g, t, { x, legs: walkLegs(t), lift: t % 4 === 0 ? 1 : 0, arms: t % 2 ? 'left' : 'right', ...facing(t, right) })
327 put(g, right ? x - 2 : x + 10, 2, '≡', GRAY) // speed lines, behind
328 },
329
330 moonwalk: (g, t) => {
331 const x = 1 + roam(3) - pingPong(t, roam(3))
332 // gliding backwards: it faces the other way from where it goes
333 clawd(g, t, { x, legs: walkLegs(t), arms: 'right', ...facing(t, goingRight(t, roam(3))) })
334 put(g, x + 10, 0, pick(['♪', ' ♫', '♪ ♫'], t), 'cyan')
335 },
336
337 herd: (g, t) => {
338 const x = 1 + pingPong(t, roam(7))
339 clawd(g, t, { x, legs: walkLegs(t), arms: 'up', ...facing(t, goingRight(t, roam(7))) })
340 put(g, x + (goingRight(t, roam(7)) ? 11 : -6), 3, pick(['o o o', ' o o o', 'o o o'], t), 'white') // the flock
341 },
342
343 spin: (g, t) => {
344 clawd(g, t, { x: 2 + (t % 2), arms: pick(['left', 'up', 'right', 'down'] as const, t) })
345 put(g, 12, 0, t % 2 ? '↻' : '↺', 'cyan')
346 put(g, 0, 2, pick(['(', ' ', ')', ' '], t), GRAY)
347 },
348
349 magic: (g, t) => {
350 clawd(g, t, { arms: 'up', lift: t % 8 < 4 ? 1 : 0 })
351 const sparks = ['✦', '✧', '⋆', '✶', '·']
352 for (let i = 0; i < 5; i++) put(g, 11 + ((i * 3 + t) % 9), (i + t) % 3, pick(sparks, t + i), pick(['magenta', 'cyan', 'yellow'], i + t))
353 },
354
355 levitate: (g, t) => {
356 clawd(g, t, { lift: 1, legs: 'tuck', arms: 'up' })
357 put(g, 3, 3, pick(['· ✧ ·', '✧ · ✧'], t), 'cyan')
358 },
359
360 hatch: (g, t) => {
361 // the egg wobbles and cracks, bursts, and a baby Clawd stands in the shell, then hops out beside it
362 const p = t % 48
363 const baby = MINI_COLORS[Math.floor(t / 48) % MINI_COLORS.length]
364 const hatched = p >= 26
365 clawd(g, t, { arms: 'right', lift: p === 27 ? 1 : 0, eyes: hatched ? 'smile' : t % 12 === 0 ? 'blink' : 'right', shade: 'left' })
366 if (!hatched) {
367 const wobble = p >= 6 && p < 24 ? [0, 1, 0, -1][t % 4] : 0
368 put(g, 13 + wobble, 2, p < 12 ? '▗▄▖' : p < 18 ? '▗╱▖' : p < 24 ? '▗╳▖' : '✧ ✧', 'white')
369 put(g, 13 + wobble, 3, '▝▀▘', 'white')
370 if (p >= 24) put(g, 12, 1, '✦ ✦', 'yellow')
371 return
372 }
373 put(g, 13, 3, '▝▀▘', 'white') // the bottom half of the shell stays behind
374 const eyes = p % 7 === 0 ? 'blink' : 'left' // looking up at the big one
375 if (p < 34) mini(g, 12, baby, { lift: 1, eyes })
376 else if (p === 34) mini(g, 14, baby, { lift: 2, eyes })
377 else mini(g, 17, baby, { eyes, lift: p === 40 || p === 42 ? 1 : 0 })
378 },
379
380 grow: (g, t) => {
381 clawd(g, t, { arms: 'right' })
382 const h = Math.floor(t / 4) % 4
383 for (let i = 0; i < h; i++) put(g, 13, 3 - i, i === h - 1 ? '❀' : '│', i === h - 1 ? 'magenta' : 'green')
384 put(g, 12, 3, '▁▁▁', 'yellow')
385 },
386
387 forge: (g, t) => {
388 const hit = t % 4 === 0
389 clawd(g, t, { arms: hit ? 'right' : 'up' })
390 put(g, 11, hit ? 2 : 0, hit ? '╾' : '╤', 'white') // hammer
391 put(g, 12, 3, '▀▀█▀▀', 'white')
392 if (hit) put(g, 12, 1, pick(['* ✶', '✶ *'], t), 'yellow')
393 },
394
395 compute: (g, t) => {
396 clawd(g, t, { arms: t % 2 ? 'left' : 'right' })
397 for (let row = 1; row <= 3; row++) {
398 const bits = Array.from({ length: 8 }, (_, i) => (((i + 1) * 7 + t * (row + 2)) % 3 === 0 ? '1' : '0')).join('')
399 put(g, 12, row, bits, 'green')
400 }
401 },
402
403 type: (g, t) => {
404 clawd(g, t, { arms: t % 2 ? 'left' : 'right' })
405 const keys = '▤▤▤▤▤▤'
406 put(g, 12, 3, keys, GRAY)
407 put(g, 12 + (t % keys.length), 3, '▣', 'white')
408 },
409
410 talk: (g, t) => {
411 clawd(g, t, { arms: t % 6 < 3 ? 'right' : 'down' })
412 put(g, 12, 1, pick(['‹ . ›', '‹ .. ›', '‹ ... ›'], t), 'white')
413 },
414
415 dance: (g, t) => {
416 clawd(g, t, { lift: t % 2 ? 1 : 0, arms: t % 4 < 2 ? 'left' : 'right', legs: walkLegs(t), eyes: t % 16 < 10 ? 'smile' : 'open', shade: t % 4 < 2 ? 'left' : 'right' })
417 put(g, 12, 0, pick(['♪', '♪ ♫', ' ♫ ♪', '♫'], t), 'cyan')
418 // two backup dancers, bouncing on the off-beat
419 mini(g, 12, MINI_COLORS[3], { lift: t % 2 ? 0 : 1, eyes: 'right', legs: t % 4 < 2 ? 'a' : 'b' })
420 mini(g, 18, MINI_COLORS[0], { lift: t % 2 ? 0 : 1, eyes: 'left', legs: t % 4 < 2 ? 'b' : 'a' })
421 },
422
423 disco: (g, t) => {
424 // under a spinning mirror ball: the floor flashes, beams glint around the ball, Clawd and a little one dance
425 const lights = ['magenta', 'cyan', 'yellow', '#7BC67E']
426 for (let i = 0; i < 13; i++) put(g, i * 2, 3, '▁▁', pick(lights, i + Math.floor(t / 2)))
427 const beat = t % 4 < 2
428 clawd(g, t, { lift: beat ? 1 : 0, arms: pick(['up', 'right', 'up', 'left'] as const, Math.floor(t / 2)), eyes: t % 16 < 12 ? 'smile' : 'open', shade: beat ? 'left' : 'right' })
429 mini(g, 20, MINI_COLORS[3], { lift: beat ? 0 : 1, eyes: 'left', legs: beat ? 'a' : 'b' })
430 put(g, 15, 0, pick(['◐', '◓', '◑', '◒'], t), 'white') // the ball
431 // glints, all right of column 11 so none lands on Clawd
432 const glints: [number, number][] = [[13, 0], [17, 0], [12, 1], [18, 1], [14, 2], [16, 1], [12, 2], [18, 2]]
433 for (let k = 0; k < 3; k++) {
434 const [x, y] = glints[(t + k * 3) % glints.length]
435 put(g, x, y, pick(['✦', '✧', '·'], t + k), pick(lights, t + k))
436 }
437 },
438
439 juggle: (g, t) => {
440 clawd(g, t, { arms: t % 2 ? 'left' : 'right', lift: 0 }) // no hop: the balls on row 0 would land on its face
441 // left hand → over the head → right hand and back; row 1 only beside the body
442 const arc: [number, number][] = [[-1, 1], [1, 0], [4, 0], [7, 0], [9, 1], [7, 0], [4, 0], [1, 0]]
443 for (let b = 0; b < 3; b++) {
444 const [dx, y] = arc[(t + b * 3) % arc.length]
445 put(g, 2 + dx, y, 'o', pick(['red', 'yellow', 'cyan'], b))
446 }
447 },
448
449 honk: (g, t) => {
450 clawd(g, t, { arms: 'up', lift: t % 6 < 2 ? 1 : 0 })
451 if (t % 6 < 4) put(g, 12, 1, '< HONK! >', 'yellow')
452 },
453
454 sketch: (g, t) => {
455 clawd(g, t, { arms: t % 2 ? 'right' : 'down' })
456 put(g, 12, 1, '┌──────┐', 'white')
457 put(g, 12, 2, `│${'╱╲'.repeat(3).slice(0, (t % 7))}`.padEnd(7) + '│', 'white')
458 put(g, 12, 3, '└──────┘', 'white')
459 },
460
461 chalk: (g, t) => {
462 // the board stays put; Clawd writes, walks back to admire it, walks up again and wipes it
463 const BX = 16 // board columns 16-25, Clawd writes from 10 columns to its left
464 const p = t % 48
465 const [l1, l2] = pick(LESSONS, Math.floor(t / 48))
466 const wipe = (from: number, to: number) => {
467 for (let y = from + HEAD; y < 3 + HEAD; y++) for (let x = BX; x < Math.min(to, g[y].length); x++) g[y][x] = { ch: ' ', color: 'white', bg: BOARD }
468 }
469 wipe(0, BX + 10)
470 put(g, BX, 3, '▀▀▀▀▀▀▀▀▀▀', WOOD) // chalk tray
471 const n = p < 24 ? p : l1.length + l2.length
472 put(g, BX + 1, 1, l1.slice(0, n), 'white', BOARD)
473 put(g, BX + 1, 2, l2.slice(0, Math.max(0, n - l1.length)), 'white', BOARD)
474 const home = BX - 10
475 if (p < 24) {
476 // scribbling: the hand bobs, chalk taps the board
477 clawd(g, t, { x: home, eyes: t % 12 === 0 ? 'blink' : 'right', arms: t % 2 ? 'right' : 'down', shade: 'left' })
478 put(g, BX - 1, 1 + (t % 2), '·', 'white')
479 } else if (p < 28) {
480 // backs away, still looking at the board
481 clawd(g, t, { x: home - (p - 23), eyes: 'right', legs: walkLegs(t), shade: 'left' })
482 } else if (p < 36) {
483 // admires the work
484 clawd(g, t, { x: home - 4, eyes: p === 28 ? 'open' : 'smile', arms: 'down', lift: p === 31 ? 1 : 0, shade: 'none' })
485 } else if (p < 40) {
486 clawd(g, t, { x: home - 3 + (p - 36), legs: walkLegs(t), ...facing(t, true) })
487 } else {
488 // wipes left to right
489 const ex = BX + (p - 40) * 2
490 wipe(1, Math.min(ex, BX + 10))
491 put(g, Math.min(ex, BX + 8), 1, '▆▆', WOOD, BOARD)
492 clawd(g, t, { x: home, eyes: 'right', arms: t % 2 ? 'right' : 'down', shade: 'left' })
493 }
494 },
495
496 weather: (g, t) => {
497 clawd(g, t, { eyes: 'open', arms: 'up' })
498 // the cloud hangs beside it, so the rain never falls on its face
499 put(g, 12, 0, '☁☁☁', 'white')
500 for (let y = 1; y < H - HEAD; y++) put(g, 12 + ((t + y) % 3), y, '╵', 'cyan')
501 },
502
503 thunder: (g, t) => {
504 clawd(g, t, { arms: t % 4 === 0 ? 'up' : 'down', lift: t % 4 === 0 ? 1 : 0 })
505 put(g, 12, 0, '☁☁', GRAY)
506 if (t % 4 === 0) put(g, 13, 1, 'ϟ', 'yellow')
507 },
508
509 dig: (g, t) => {
510 // tunnels along the ground at a third of walking pace, dirt flying out behind
511 const x = 1 + pingPong(Math.floor(t / 3), roam(2))
512 const soil = '▒▓▒░▓▒▒░▓▒▓░'
513 put(g, 0, 3, Array.from({ length: stride.width }, (_, i) => soil[i % soil.length]).join(''), 'yellow')
514 clawd(g, t, { x, lift: 0, arms: t % 2 ? 'left' : 'right', legs: 'tuck' })
515 put(g, x - 2, 2, pick(['· ', ' ∙', '∙·'], t), 'yellow')
516 },
517
518 kick: (g, t) => {
519 const p = t % 14 // 0-3 wind up, 3 kick, 4-7 ball flies, 7+ celebrate
520 const kicking = p === 3
521 clawd(g, t, { arms: p >= 7 ? 'up' : kicking ? 'right' : 'down', legs: kicking ? 'b' : undefined, lift: p >= 7 && p % 2 ? 1 : 0 })
522 put(g, 18, 1, '┌─', 'white') // goal
523 put(g, 18, 2, '│#', 'white')
524 put(g, 18, 3, '│#', 'white')
525 const bx = p < 4 ? 11 : Math.min(19, 11 + (p - 3) * 2)
526 put(g, bx, p >= 4 && p <= 6 ? 2 : 3, '●', 'white')
527 if (p >= 7) put(g, 12, 0, 'GOAL!', 'yellow')
528 },
529
530 skate: (g, t) => {
531 // glides across the ice on one blade then the other, arms out for balance, frost trailing behind
532 const span = roam(2)
533 const x = 1 + pingPong(t, span)
534 const right = goingRight(t, span)
535 put(g, 0, 3, '─'.repeat(stride.width), 'cyan') // the rink
536 const legs = Math.floor(t / 2) % 2 ? 'a' : 'b'
537 clawd(g, t, { x, lift: 1, arms: t % 8 < 4 ? 'up' : right ? 'right' : 'left', legs, ...facing(t, right) })
538 // a solid blade right under each foot, so it reads apart from the thin ice line
539 put(g, x + 1, 3, '▀▀▀', 'white')
540 put(g, x + 5, 3, '▀▀▀', 'white')
541 put(g, right ? x - 3 : x + 10, 2, pick(['· ', ' · ', ' ·'], t), 'white')
542 },
543
544 campfire: (g, t) => {
545 // two Clawds sit still on either side of the fire, gazing in: a slow blink now and then,
546 // a hand held out to the warmth; only the fire and the eyes move.
547 // With room to spare a little one sits on each outer side, blinking out of step with the big ones.
548 const o = stride.width >= 37 ? 4 : 0
549 const warm = t % 20 < 12
550 // eyes close happily while the hands are warm
551 const eyes = t % 30 < 2 ? 'blink' : warm ? 'smile' : 'open'
552 clawd(g, t, { x: 2 + o, eyes, arms: warm ? 'right' : 'down' })
553 clawd(g, t, { x: 18 + o, eyes, arms: warm ? 'left' : 'down', color: PURPLE })
554 if (o) {
555 mini(g, 0, MINI_COLORS[1], { eyes: t % 30 === 11 || t % 30 === 13 ? 'blink' : 'right' })
556 mini(g, 32, MINI_COLORS[3], { eyes: t % 30 >= 20 && t % 30 < 22 ? 'blink' : 'left' })
557 }
558 put(g, 13 + o, 0, pick([' ·', ' ˙ ', '· ', ' ', ' · '], Math.floor(t / 2)), 'yellow') // sparks
559 put(g, 13 + o, 1, pick([' ▲ ', '▴▲ ', ' ▲▴', ' ▴ '], t), 'yellow')
560 put(g, 13 + o, 2, pick(['▟█▙', '▟▙▙', '▟█▟', '▙█▙'], t), 'red')
561 put(g, 14 + o, 2, '▒', 'yellow') // the hot core
562 put(g, 12 + o, 3, '═╳═╳═', '#8B5A2B') // logs
563 },
564
565 shift: (g, t) => {
566 // 閃身步: calls the move's name, a character at a time, top to bottom behind it, then flashes across
567 // the stage in three frames, leaving afterimages that fade; then the same back the other way
568 const q = t % 22
569 const right = t % 44 < 22
570 const [home, end] = right ? [3, stride.width - 12] : [stride.width - 12, 3]
571 const dir = right ? 1 : -1
572 const at = [home, home + Math.round((end - home) / 3), home + Math.round(((end - home) * 2) / 3)]
573 if (q < 14) tall(g, right ? home - 3 : home + 10, 0, '閃身步'.slice(0, 1 + Math.floor(q / 3)), 'yellow')
574 // an afterimage where it stood each dash frame, darker and thinner as it fades
575 for (let k = 0; k < 3; k++) {
576 const age = q - 12 - k
577 if (age < 0 || age >= 6) continue
578 const [ink, color] = age < 2 ? ['▓', SHADE] : age < 4 ? ['▒', '#8A4A35'] : ['░', '#5E3D33']
579 put(g, at[k], 1, ` ${ink.repeat(7)} `, color)
580 put(g, at[k], 2, ink.repeat(9), color)
581 put(g, at[k], 3, ` ${ink}${ink} ${ink}${ink} `, color)
582 }
583 const eyes = right ? 'right' : 'left'
584 if (q < 12) clawd(g, t, { x: home, ...facing(t, right), arms: q < 6 ? 'down' : 'up' }) // the stance
585 else if (q < 14) clawd(g, t, { x: at[q - 11], eyes, arms: right ? 'right' : 'left', legs: 'tuck', shade: right ? 'left' : 'right' })
586 else if (q < 16) clawd(g, t, { x: q === 14 ? end + dir : end, eyes, lift: -1 }) // lands sliding, with a squash
587 else clawd(g, t, { x: end, eyes: 'smile', arms: q < 19 ? 'up' : 'down', lift: q === 17 ? 1 : 0, shade: 'none' })
588 },
589
590 flow: (g, t) => {
591 clawd(g, t, { lift: t % 6 < 3 ? 1 : 0 })
592 const wave = '∿~'
593 put(g, 0, 3, Array.from({ length: 16 }, (_, i) => wave[(i + t) % 2]).join(''), 'cyan')
594 },
595}
596
597// Reactions to /clawd: played over the show for a few seconds, never part of the rotation
598const reactions: Record<string, Scene> = {
599 pet: (g, t) => {
600 // purrs under the hand: happy eyes, a little bounce, hearts floating up
601 clawd(g, t, { eyes: 'smile', arms: t % 6 < 3 ? 'up' : 'down', lift: t % 8 === 0 ? 1 : 0, shade: 'none' })
602 for (let k = 0; k < 2; k++) {
603 const p = (t + k * 3) % 6
604 put(g, 11 + k * 3 + (p % 2), 2 - Math.floor(p / 2), '♥', pick(['red', 'magenta'], k))
605 }
606 },
607
608 feed: (g, t) => {
609 // a cookie rolls in, gets munched, crumbs fall
610 const p = t % 16
611 const eating = p >= 6
612 clawd(g, t, { eyes: eating ? 'smile' : 'right', arms: eating ? (t % 2 ? 'left' : 'right') : 'down', shade: eating ? 'none' : 'left' })
613 if (!eating) put(g, 21 - p * 2, 2, '●', 'yellow')
614 else {
615 put(g, 11, 2 + (p % 2), pick(['·', '∙'], t), 'yellow') // crumbs
616 if (p < 12) put(g, 12, 1, pick(['nom', 'nom nom'], Math.floor(t / 2)), 'white')
617 }
618 },
619
620 // chatting: it stands still under its speech bubble, thinking, then talking with its hands
621 ponder: scenes.think,
622 say: (g, t) => clawd(g, t, { arms: pick(['down', 'right', 'down', 'left'] as const, Math.floor(t / 3)), eyes: t % 18 === 0 ? 'blink' : 'open', shade: 'none' }),
623
624 wave: (g, t) => {
625 // waves hello with both arms
626 clawd(g, t, { eyes: t % 12 < 9 ? 'smile' : 'open', arms: t % 4 < 2 ? 'up' : 'down', shade: 'none' })
627 put(g, 11, 1, pick([')', ' )', ' )'], t), 'white')
628 put(g, 12, 0, 'hi!', 'yellow')
629 },
630}
631export const REACTION_NAMES = Object.keys(reactions)
632
633// Spinner word → scene, by stem; the first match wins
634const VERBS: [RegExp, string][] = [
635 [/moonwalk/, 'moonwalk'],
636 [/shift|beam|dodg|sidestep|evad|evasi|flit/, 'shift'],
637 [/campfir|bonfir|kindl|smolder|smoulder|stok|crackl|flicker|glow|fire/, 'campfire'],
638 [/skat|glid|slid|skid|coast/, 'skate'],
639 [/kick|dribbl|scor|punt|volley|soccer|footbal/, 'kick'],
640 [/levitat|hyperspac/, 'levitate'],
641 [/thunder/, 'thunder'],
642 [/honk|boop|hullaballoo|shenanigan|tomfool|razzle/, 'honk'],
643 [/juggl/, 'juggle'],
644 [/sketch|doodl|draw/, 'sketch'],
645 [/deciph|elucidat|philosoph|pontificat|deliberat|determin|reason|theoriz|theoris|hypothes|deduc|formulat|scribbl|jott|lectur|teach|explain/, 'chalk'],
646 [/bak|proof|leaven|knead|frost|roast/, 'bake'],
647 [/brew|percolat|ferment|steep/, 'brew'],
648 [/cook|simmer|stew|saut|marinat|flamb|julienn|season|garnish|zest|blanch|carameli|drizzl|whisk|concoct|infus|temper|boil|fry/, 'cook'],
649 [/herd|wrangl|muster/, 'herd'],
650 [/scamper|scurr|skedaddl|gallop|hustl|catapult|swoop|pounc|zoom|dash/, 'run'],
651 [/mosey|schlep|putter|wander|meander|perambul|gallivant|stroll|waddl|lollygag|dilly|zigzag|scuttl/, 'walk'],
652 [/spin|churn|whirr|swirl|whirlpool|twist|reticulat|orbit|cascad|topsy|wibbl|combobul|coalesc|smoosh/, 'spin'],
653 [/conjur|manifest|transmut|transfigur|enchant|prestidig|metamorph|quantum|warp|sublimat|ioniz|crystalli|channel|alchem/, 'magic'],
654 [/hatch|incubat|nest|roost/, 'hatch'],
655 [/germinat|sprout|pollinat|photosynth|cultivat|propagat|unfurl|symbiot|bloom|nucleat/, 'grow'],
656 [/disco|funk|boogi|groov|fiesta|shindig|soiree/, 'disco'],
657 [/vib|jitterbug|shimm|sock|bebop|frolic|choreograph|harmoni|orchestrat|compos|improvis|jam/, 'dance'],
658 [/calculat|comput|crunch|process|infer|synthesi|analy|hash|gitif|quantif/, 'compute'],
659 [/forg|craft|build|construct|tinker|form|creat|generat|actualiz|accomplish|action|effect|embellish|bootstrap|doing|work/, 'forge'],
660 [/mist|nebuli|evaporat|billow|gust|precipitat|cloud/, 'weather'],
661 [/burrow|spelunk|slither|dig/, 'dig'],
662 [/flow|ebb|osmos|undulat|wav/, 'flow'],
663 [/ponder|mus|mull|ruminat|cogitat|cerebrat|consider|contemplat|think|puzzl|envision|imagin|ideat|perus|bloviat|befuddl|flummox|noodl|claud/, 'think'],
664]
665
666export type Mode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
667
668/** Which scene a spinner word acts out; an unknown word falls back to what the turn is doing. */
669export const sceneFor = (word: string, mode: Mode) => {
670 const w = word.toLowerCase().replace(/[^a-z]/g, '')
671 const hit = VERBS.find(([re]) => re.test(w))
672 if (hit) return hit[1]
673 return mode === 'tool-use' || mode === 'tool-input' ? 'type' : mode === 'responding' ? 'talk' : mode === 'thinking' ? 'think' : 'idle'
674}
675
676// Only scenes whose extras belong to Clawd (a bubble, notes, sparks) wander; the rest travel on their own
677// or stand by a pot, a board, an anvil… that must stay put while Clawd works at it
678export const WANDERS = new Set(['idle', 'think', 'talk', 'dance', 'juggle', 'honk', 'levitate', 'magic', 'spin'])
679const PROPS_WIDTH = 23 // the widest scene's Clawd plus props, from column 0 (dance with its backup dancers)
680const PAUSE = 10 // frames spent standing at each end
681
682/** Where the wander puts the frame: stroll right, pause, stroll left, pause; a hop at each turn. */
683export const wander = (t: number, width: number) => {
684 const span = Math.max(0, Math.min(width - PROPS_WIDTH, 30))
685 if (span === 0) return STILL
686 const period = 2 * (span + PAUSE)
687 const p = t % period
688 // each end: hop, land with a squash for two frames, then stand
689 const atEnd = (q: number) => ({ moving: false, hop: q === 0, squash: q === 1 || q === 2 })
690 if (p < span) return { dx: p, moving: true, hop: false, squash: false, dir: 1 }
691 if (p < span + PAUSE) return { dx: span, ...atEnd(p - span), dir: 1 }
692 if (p < 2 * span + PAUSE) return { dx: 2 * span + PAUSE - p, moving: true, hop: false, squash: false, dir: -1 }
693 return { dx: 0, ...atEnd(p - 2 * span - PAUSE), dir: -1 }
694}
695
696/** One frame of the stage, as rows of same-colour runs; `dressed` false leaves the scene's costume off. */
697export const frameRows = (word: string, mode: Mode, t: number, width: number = W, scene?: string, dressed = true) => {
698 const name = scene ?? sceneFor(word, mode)
699 Object.assign(stride, WANDERS.has(name) ? wander(t, width) : STILL, { width })
700 wearing = dressed ? COSTUMES[name] : undefined
701 const g = blank(width)
702 ;(scenes[name] ?? reactions[name] ?? scenes.idle)(g, t)
703 return g.map(row => {
704 const runs: { text: string; color?: string; bg?: string }[] = []
705 for (const c of row) {
706 const last = runs[runs.length - 1]
707 if (last && last.color === c.color && last.bg === c.bg) last.text += c.ch
708 else runs.push({ text: c.ch, color: c.color, bg: c.bg })
709 }
710 return runs
711 })
712}
713
714export const SCENE_NAMES = Object.keys(scenes)
715
716// ---- Which scene plays when: a running tool first, else the spinner word's act, then a rotation ----
717
718/** A running tool's scene, by tool name; any other tool hammers away. */
719const TOOL_SCENES: [RegExp, string][] = [
720 [/^(Bash|PowerShell|Monitor)$/, 'type'],
721 [/^(Read|Grep|Glob|LS)$/, 'think'],
722 [/^(Edit|Write|MultiEdit|NotebookEdit)$/, 'sketch'],
723 [/^(Agent|Task|SendMessage)$/, 'herd'],
724 [/^Web/, 'weather'],
725 [/^(TodoWrite|Skill|ToolSearch)$/, 'juggle'],
726 [/^mcp__/, 'magic'],
727]
728export const sceneForTool = (tool: string) => TOOL_SCENES.find(([re]) => re.test(tool))?.[1] ?? 'forge'
729
730export const FRAME_MS = 140
731export const ACT_MS = 15_000 // each act runs a quarter of a minute
732export const ACT_FRAMES = Math.round(ACT_MS / FRAME_MS)
733const ROTATION = SCENE_NAMES.filter(n => n !== 'idle' && n !== 'type' && n !== 'talk')
734const STEP = [7, 5, 3, 11, 13].find(k => ROTATION.length % k !== 0) ?? 1 // coprime with the length, so it visits all
735
736/**
737 * `played`: rotation acts earlier turns of the session got through, so the rotation picks up where it left off;
738 * `prev`: the scene the act before this one showed
739 */
740export type Cue = { word: string; mode: Mode; tick: number; seed: number; tool?: string; played?: number; prev?: string }
741
742/**
743 * The scene for this frame of a turn. Act 0 opens on the running tool, else the spinner word;
744 * every later act steps through the rotation in an order the session's seed shuffles
745 * (a step coprime with the list's length visits all), whatever tool runs, skipping the opener.
746 */
747export const sceneAt = ({ word, mode, tick, seed, tool, played = 0, prev }: Cue) => {
748 const act = Math.floor(Math.max(0, tick) / ACT_FRAMES)
749 if (act === 0) return tool ? sceneForTool(tool) : sceneFor(word, mode)
750 const n = ROTATION.length
751 const i = seed + (played + act - 1) * STEP
752 // the next one along is neither of its neighbours, as the step is not ±1
753 return ROTATION[i % n] === prev ? ROTATION[(i + 1) % n] : ROTATION[i % n]
754}
755
756export type Shown = { turn: number; act: number; scene: string }
757
758/**
759 * The scene on stage: picked at an act's first frame and held for the whole act, whatever the
760 * tool, the spinner word or the mode do meanwhile. `turn` tells the turns apart (its first frame).
761 */
762export const hold = (shown: Shown | undefined, turn: number, cue: Cue): Shown => {
763 const act = Math.floor(Math.max(0, cue.tick) / ACT_FRAMES)
764 const same = shown?.turn === turn
765 if (same && shown.act === act) return shown
766 return { turn, act, scene: sceneAt({ ...cue, prev: same ? shown.scene : undefined }) }
767}
768hooks/world.ts 202 lines1// A behind-the-runner view drawn as pixels (a half block per cell: two pixels tall) with real perspective:
2// sky, sun, hills, a striped road that streams toward you, haze with distance, and shaded 3D boxes.
3export const H = 10 // terminal rows; the picture is 2H pixels tall
4export const FRAME_MS = 80
5export const MAX_STAGE = 50
6export const ORANGE = '#D77757'
7const S = 12 // ticks between events
8const PLAT = 6 // ticks a platform stays underfoot
9const C = 4 // perspective: a thing d ticks ahead has scale C / (d + C): 1 at the runner, toward 0 at the horizon
10const PH = H * 2
11const HOR = 6 // the horizon's pixel row
12const BOT = PH - 1
13const HS = 8 // pixels per world unit of height at scale 1
14const AHEAD = 40 // how far down the track things are drawn, in ticks
15
16type RGB = [number, number, number]
17export type Run = { text: string; color: string; bg: string }
18type Kind = 'none' | 'jump' | 'slide' | 'platform'
19const KINDS: Kind[] = ['none', 'jump', 'slide', 'platform']
20
21const mix = (a: RGB, b: RGB, t: number): RGB => [0, 1, 2].map(i => Math.round(a[i] + (b[i] - a[i]) * t)) as RGB
22const mul = (c: RGB, k: number): RGB => c.map(v => Math.min(255, Math.round(v * k))) as RGB
23const hex = (c: RGB) => '#' + c.map(v => v.toString(16).padStart(2, '0')).join('')
24const rgb = (h: string): RGB => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)) as RGB
25
26const SKY_TOP: RGB = [16, 8, 48]
27const HAZE: RGB = [214, 104, 130]
28const ROAD: [RGB, RGB] = [[70, 66, 92], [60, 56, 80]]
29const GRASS: [RGB, RGB] = [[46, 30, 84], [38, 24, 72]]
30const LINE: RGB = [235, 225, 210]
31const RUMBLE: [RGB, RGB] = [[220, 70, 80], [235, 235, 240]]
32
33/** A cheap deterministic hash: the same (seed, k) always gives the same course, with no state. */
34const hash = (seed: number, k: number) => {
35 let x = (Math.imul(k + 1, 0x9e3779b1) ^ Math.imul(seed + 7, 0x85ebca6b)) >>> 0
36 x = Math.imul(x ^ (x >>> 15), 0x2c1b3c6d) >>> 0
37 return (x ^ (x >>> 12)) >>> 0
38}
39
40/** What event slot `k` holds, in the lane the autopilot will be in: the first two slots are a clear run-up. */
41export const kindAt = (seed: number, k: number): Kind => (k < 2 ? 'none' : KINDS[hash(seed, k) % 4])
42export const laneAt = (seed: number, k: number) => (k < 2 ? 1 : hash(seed, k + 100) % 3)
43/** A decoy wall in `lane` at slot `k`: never in the autopilot's own lane, there only to make the lane changes make sense. */
44export const hasWall = (seed: number, k: number, lane: number) => k >= 2 && lane !== laneAt(seed, k) && hash(seed, k * 3 + lane + 300) % 2 === 0
45
46/** What the runner is doing at tick `t`: its lane (halfway while changing), height in world units, sliding or not. */
47export const pose = (seed: number, t: number) => {
48 const k = Math.floor(t / S)
49 const ph = t - k * S
50 const cur = laneAt(seed, k)
51 const nxt = laneAt(seed, k + 1)
52 // changes lane over the last ticks of a slot, before the next event's reach starts
53 const lane = ph >= S - 2 ? nxt : ph >= S - 4 ? (cur + nxt) / 2 : cur
54 let up = 0
55 let slide = false
56 let onPlat = false
57 for (let j = k - 1; j <= k + 1; j++) {
58 const d = j * S - t
59 const kind = kindAt(seed, j)
60 if (kind === 'jump' && Math.abs(d) <= 3) up = Math.max(up, 1 - (d / 3.2) ** 2) // an arc over the bar
61 else if (kind === 'slide' && Math.abs(d) <= 2) slide = true
62 else if (kind === 'platform') {
63 if (d <= 0 && d + PLAT > 0) {
64 up = Math.max(up, 0.5)
65 onPlat = true
66 } else if (d === 1) up = Math.max(up, 0.9) // the hop onto it
67 }
68 }
69 return { lane, up, slide, onPlat }
70}
71
72// Clawd from behind: O body, L lit top, D shaded underside; the legs swap on alternate ticks
73const BODY = ['.LLLLLLL.', 'OOOOOOOOO', 'OOOOOOOOO', '.OOOOOOO.', '.DDDDDDD.']
74const LEGS = ['.OO...OO.', '..OO.OO..']
75const SLID = ['.LLLLLLL.', 'OOOOOOOOO', '.DDDDDDD.']
76const PALETTE: Record<string, RGB> = { O: rgb(ORANGE), L: [245, 160, 125], D: [160, 84, 58] }
77
78type Box = { x0: number; x1: number; y0: number; y1: number; dN: number; dF: number; base: RGB; stripe?: RGB; before: boolean }
79
80/** The stage at tick `t` as H rows of runs, each exactly `width` columns. */
81export const frameRows = (t: number, width: number, seed: number, best = 0): Run[][] => {
82 const W = width
83 const cx = Math.floor(W / 2)
84 const f = W * 0.28 // world x to pixels at scale 1: the road (±1.5) fills most of the width
85 const px: RGB[][] = Array.from({ length: PH }, () => Array.from({ length: W }, () => SKY_TOP))
86 const put = (x: number, y: number, c: RGB) => {
87 x = Math.round(x)
88 y = Math.round(y)
89 if (x >= 0 && x < W && y >= 0 && y < PH) px[y][x] = c
90 }
91 const fog = (c: RGB, s: number) => mix(c, HAZE, 0.9 * Math.max(0, Math.min(1, 1 - s)) ** 1.7)
92 const Y = (s: number, h: number) => HOR + s * (BOT - HOR - h * HS) // screen row of height h at scale s
93 const X = (x: number, s: number) => cx + x * f * s
94
95 // sky, sun, hills
96 for (let y = 0; y <= HOR; y++) for (let x = 0; x < W; x++) px[y][x] = mix(SKY_TOP, HAZE, (y / HOR) ** 1.4)
97 for (let y = 0; y <= HOR; y++)
98 for (let x = 0; x < W; x++)
99 if ((x - cx) ** 2 + (HOR - y) ** 2 <= 25) px[y][x] = mix([255, 214, 90], [255, 84, 140], (HOR - y) / 5)
100 for (let x = 0; x < W; x++) {
101 const hill = Math.round(1.6 + 1.3 * Math.sin(x * 0.42) + 1.1 * Math.sin(x * 0.15 + 2))
102 for (let y = HOR - Math.max(0, hill); y <= HOR; y++) px[y][x] = [44, 22, 78]
103 }
104
105 // the road streams toward you: bands are fixed in the world, so they move as t does
106 for (let y = HOR + 1; y <= BOT; y++) {
107 const s = (y - HOR) / (BOT - HOR)
108 const world = C * (1 / s - 1) + t
109 const band = Math.floor(world / 3) % 2
110 const strip = Math.floor(world / 1.5) % 2
111 for (let x = 0; x < W; x++) {
112 const aX = Math.abs((x - cx) / (f * s))
113 let c: RGB
114 if (aX <= 1.5) c = Math.abs(aX - 0.5) * f * s < 0.7 && strip === 0 ? LINE : ROAD[band]
115 else if (aX <= 1.72) c = RUMBLE[strip]
116 else c = GRASS[band]
117 px[y][x] = fog(c, s)
118 }
119 }
120
121 // things on the road, far to near
122 const boxes: Box[] = []
123 const add = (b: Omit<Box, 'before'> & { before?: boolean }) => boxes.push({ before: b.dN > 0.5, ...b })
124 for (let k = Math.floor(t / 6) - 1; k <= Math.floor((t + AHEAD) / 6); k++)
125 for (const side of [-1, 1]) // pylons along both edges: the cheapest cue for speed
126 add({ x0: side * 1.95 - 0.12, x1: side * 1.95 + 0.12, y0: 0, y1: 0.9, dN: k * 6 - t, dF: k * 6 - t + 0.5, base: k % 2 ? [120, 70, 150] : [60, 170, 190] })
127 for (let k = Math.floor(t / S) - 1; k <= Math.floor((t + AHEAD) / S); k++) {
128 const d = k * S - t
129 const kind = kindAt(seed, k)
130 const l = laneAt(seed, k) - 1
131 if (kind === 'jump') add({ x0: l - 0.46, x1: l + 0.46, y0: 0, y1: 0.4, dN: d, dF: d + 0.8, base: [225, 70, 80], stripe: [240, 240, 245] })
132 if (kind === 'platform') add({ x0: l - 0.48, x1: l + 0.48, y0: 0, y1: 0.5, dN: d, dF: d + PLAT, base: [60, 150, 168], before: true })
133 if (kind === 'slide') {
134 const gold: RGB = [235, 190, 80]
135 add({ x0: l - 0.46, x1: l - 0.34, y0: 0, y1: 1.1, dN: d, dF: d + 0.4, base: mul(gold, 0.8) })
136 add({ x0: l + 0.34, x1: l + 0.46, y0: 0, y1: 1.1, dN: d, dF: d + 0.4, base: mul(gold, 0.8) })
137 add({ x0: l - 0.46, x1: l + 0.46, y0: 0.5, y1: 0.9, dN: d, dF: d + 0.4, base: gold })
138 }
139 for (let m = 0; m < 3; m++) if (hasWall(seed, k, m)) add({ x0: m - 1 - 0.46, x1: m - 1 + 0.46, y0: 0, y1: 1, dN: d, dF: d + 1, base: [150, 62, 74] })
140 }
141 boxes.sort((a, b) => b.dN - a.dN)
142
143 const drawBox = (b: Box) => {
144 if (b.dN + C <= 0.4 || b.dF + C <= 0.4) return
145 if (!b.before && b.dN < 0) return // past the camera: gone, not a huge blob over the runner
146 const sN = C / (b.dN + C)
147 const sF = C / (b.dF + C)
148 const top = mul(b.base, 1.35)
149 const side = mul(b.base, 0.6)
150 const plane = b.x1 < 0 ? b.x1 : b.x0 > 0 ? b.x0 : null // the face turned toward the middle of the road
151 if (plane !== null)
152 for (let x = 0; x < W; x++) {
153 const s = (x - cx) / (plane * f)
154 if (s < sF || s > sN) continue
155 for (let y = Math.round(Y(s, b.y1)); y <= Math.round(Y(s, b.y0)); y++) put(x, y, fog(side, s))
156 }
157 for (let y = Math.max(0, Math.floor(Y(sF, b.y1))); y <= Math.min(BOT, Math.ceil(Y(sN, b.y1))); y++) {
158 const s = (y - HOR) / (BOT - HOR - b.y1 * HS)
159 if (s < sF || s > sN) continue
160 for (let x = Math.round(X(b.x0, s)); x <= Math.round(X(b.x1, s)); x++) put(x, y, fog(top, s))
161 }
162 if (sN <= 2.6)
163 for (let x = Math.round(X(b.x0, sN)); x <= Math.round(X(b.x1, sN)); x++) {
164 const c = b.stripe && Math.floor(x / 2) % 2 ? b.stripe : b.base
165 for (let y = Math.round(Y(sN, b.y1)); y <= Math.round(Y(sN, b.y0)); y++) put(x, y, fog(c, sN))
166 }
167 }
168
169 const { lane, up, slide, onPlat } = pose(seed, t)
170 boxes.filter(b => b.before).forEach(drawBox)
171 const rx = Math.round(cx + (lane - 1) * f)
172 const feet = BOT - Math.round(up * HS)
173 if (up > 0.04 && !onPlat)
174 for (let y = BOT - 1; y <= BOT; y++) for (let x = rx - 4; x <= rx + 4; x++) if (x >= 0 && x < W) px[y][x] = mul(px[y][x], 0.45)
175 const sprite = slide ? SLID : [...BODY, LEGS[t % 2]]
176 sprite.forEach((row, i) =>
177 [...row].forEach((ch, j) => PALETTE[ch] && put(rx - 4 + j, feet - (sprite.length - 1 - i), PALETTE[ch])),
178 )
179 boxes.filter(b => !b.before).forEach(drawBox)
180
181 // pack two pixel rows into one cell: the top pixel is the block's colour, the bottom its background
182 const cells = Array.from({ length: H }, (_, r) =>
183 Array.from({ length: W }, (_, x) => ({ ch: '▀', color: hex(px[2 * r][x]), bg: hex(px[2 * r + 1][x]) })),
184 )
185 // the live distance, with the personal best beside it; gold once this run passes it
186 const score = best ? `${t}m best ${best}m` : `${t}m`
187 const ink = best && t > best ? '#ffd23f' : '#ffffff'
188 for (let i = 0; i < score.length && W - score.length + i >= 0; i++) {
189 const c = cells[0][W - score.length + i]
190 cells[0][W - score.length + i] = { ch: score[i], color: ink, bg: c.color }
191 }
192 return cells.map(r => {
193 const runs: Run[] = []
194 for (const c of r) {
195 const last = runs[runs.length - 1]
196 if (last && last.color === c.color && last.bg === c.bg && c.ch === '▀' && last.text.endsWith('▀')) last.text += c.ch
197 else runs.push({ text: c.ch, color: c.color, bg: c.bg })
198 }
199 return runs
200 })
201}
202hooks/scores.ts 10 lines1export const TOP = 5
2
3/** `m` goes on the board: the best TOP distances, highest first. */
4export const record = (list: number[], m: number) => [...list, m].sort((a, b) => b - a).slice(0, TOP)
5export const isBest = (list: number[], m: number) => m > (list[0] ?? 0)
6export const board = (list: number[]) => (list.length ? list.map((m, i) => `${i + 1}. ${m}m`).join(' · ') : 'no runs yet')
7/** What a saved board may hold, whatever the file says: finite positive numbers only. */
8export const cleanBoard = (saved: unknown) =>
9 Array.isArray(saved) ? saved.filter((m): m is number => Number.isFinite(m) && m > 0).sort((a, b) => b - a).slice(0, TOP) : []
10types/index.d.ts 12 lines1export type Frame = number
2/** The turn's show, which a hot reload keeps: whether a turn runs, the frame it started on, the rotation's seed and acts played. */
3export type Show = { isTurnRunning: boolean; turnBase: number; seed: number; played: number }
4/** Which act draws above the prompt. */
5export type Band = 'clawd' | 'runner'
6
7declare module 'claude-code' {
8 interface PluginState {
9 'clawd-mods': { frame: Frame; show: Show; band: Band }
10 }
11}
12