Animated pixel scenes above the prompt while Claude works, a random one each time: night drive, rainy window, fish tank, space flight, lo-fi room, campfire…

Animated pixel scenes above the prompt while Claude works. Each time Claude starts working, a scene comes up (a random one, never the same twice in a row) and plays until the turn ends. Everything is drawn pixel by pixel, two pixels per terminal cell, in every terminal; the desktop app gets the same scene as an animated SVG.

| Scene | What it does |
|---|---|
| Night drive | Synthwave: a striped neon sun behind a skyline, grid and streetlights rushing at you, your car |
| Rainy window | Rain on the glass over a city out of focus; it rains harder while tools run |
| Fish tank | Fish and swaying weed; a bubble stream per tool call; food drops when the task is done and the fish swarm to it |
| Space flight | Warp stars, a ringed planet sliding by; the engine burns hotter the longer Claude works |
| Lo-fi room | A desk lamp, steaming coffee, a cat on the sill, rain outside; code scrolls on the monitor while Claude edits |
| Campfire | Pines under the stars; the fire grows on long turns and throws a shower of sparks when tests pass |
| Ocean sunset | Waves, gulls, the sun's reflection shimmering; a sailboat crosses over the turn |
| Matrix rain | Green code raining down |
| Usage weather | A village whose sky follows your 5-hour usage: clear, cloudy, rain, a thunderstorm near the limit |
git push, a check stamp on commit.| Command | Does | |
|---|---|---|
/scenes | Lists the scenes | |
/scenes <name> | Plays that one every time (/scenes fish, /scenes lofi) | |
/scenes random | A random one each time (the default) | |
| `/scenes crab on\ | off` | Shows or hides the crab |
/scenes off / /scenes on | Stops or starts the scenes |
A scene is one file in hooks/scenes/ exporting { id, name, draw(canvas, phase, act) }: draw a frame onto the canvas for phase (0 to 1 over a 2.4-second loop) and what Claude is doing (act). Add it to SCENES in hooks/scenes/index.ts.
hooks/register.tsx 241 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Burst, Tool } from './engine'
5import { LOOP_MS, encodeCells, toCells } from './engine'
6import { SCENES, composeFrame, desktopSvg, findScene, pickScene, sceneById } from './scenes'
7
8const FRAME_MS = 70
9const ROWS = 10
10const KEY = 'scenes'
11/** After a turn ends the scene stays a moment: food falls, the crab eats, a heart pops. */
12const LINGER_MS = 1800
13
14type Mode = 'random' | 'pinned' | 'off'
15
16const showing = atom({ plugin: 'scenes', key: 'showing' } as const, false)
17const sceneId = atom({ plugin: 'scenes', key: 'sceneId' } as const, 'night-drive')
18
19// The turn as the scenes read it.
20let turnStartedAt = 0
21let finishedAt: number | null = null
22let tool: Tool = 'think'
23let toolCalls = 0
24let bursts: Burst[] = []
25let usagePercent: number | null = null
26let fed = { day: '', count: 0 }
27let mode: Mode = 'random'
28let pinned: string | null = null
29let withCrab = true
30let lastScene: string | null = null
31
32let band: { requestId: string; columns: number; rows: number } | null = null
33let animation: { cancel: () => void } | null = null
34let hideTimer: { cancel: () => void } | null = null
35
36function today(now: number): string {
37 const date = new Date(now)
38 return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
39}
40
41function activity(now: number): Activity {
42 return {
43 elapsedMs: now - turnStartedAt,
44 finishingMs: finishedAt === null ? null : now - finishedAt,
45 tool,
46 toolCalls,
47 usagePercent,
48 hour: new Date(now).getHours(),
49 fedToday: fed.day === today(now) ? fed.count : 0,
50 bursts,
51 now,
52 }
53}
54
55/** What a tool call is, as the crab acts it out. */
56function toolOf(name: string, command: string): Tool {
57 if (name === 'Read' || name === 'NotebookRead') return 'read'
58 if (name === 'Edit' || name === 'Write' || name === 'NotebookEdit' || name === 'MultiEdit') return 'edit'
59 if (name === 'Grep' || name === 'Glob' || name === 'LS' || name === 'ToolSearch') return 'search'
60 if (name === 'WebFetch' || name === 'WebSearch') return 'web'
61 if (name === 'Agent' || name === 'Task') return 'agent'
62 if (name === 'Bash') return TEST_COMMAND.test(command) ? 'test' : 'bash'
63 return 'think'
64}
65
66const TEST_COMMAND = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\b(jest|vitest|pytest|mocha|rspec|phpunit)\b|\b(go|cargo|deno|bun)\s+test\b|\bclaude\s+plugin\s+test\b|\bmake\s+test\b/
67
68async function paint($: EngineInterface) {
69 if (band === null) return
70 const now = await $.clock.now()
71 const scene = sceneById(await read($, sceneId)) ?? SCENES[0]!
72 const { requestId, columns, rows } = band
73 const phase = ((now - turnStartedAt) % LOOP_MS) / LOOP_MS
74 const cells = encodeCells(toCells(composeFrame(scene, columns, rows * 2, phase, activity(now), withCrab)))
75 await $.ui.blit({ requestId, key: KEY, columns, rows, cells }).catch(() => undefined)
76}
77
78async function hide($: EngineInterface) {
79 animation?.cancel()
80 animation = null
81 band = null
82 await update($, showing, () => false)
83}
84
85async function readUsage($: EngineInterface) {
86 const usage = await $.session.usage().catch(() => null)
87 const window = usage?.rateLimits.find(limit => limit.kind === 'five_hour')
88 usagePercent = window ? window.percentUsed : null
89}
90
91async function savePrefs($: EngineInterface) {
92 await $.store.set('prefs', { mode, pinned, withCrab })
93}
94
95function listText(current: string): string {
96 const how = mode === 'off' ? 'Scenes are off.' : mode === 'pinned' ? `Pinned to ${sceneById(pinned ?? '')?.name ?? pinned}.` : 'A random scene each time Claude works.'
97 return [
98 `${how} The crab is ${withCrab ? 'on' : 'off'}.`,
99 '',
100 ...SCENES.map(scene => `${scene.id === (mode === 'pinned' ? pinned : current) ? '●' : '○'} ${scene.id.padEnd(14)}${scene.name}${scene.id === 'weather' ? ' (follows your 5-hour usage)' : ''}`),
101 '',
102 '/scenes <name> pins one · /scenes random · /scenes crab on|off · /scenes off · /scenes on',
103 ].join('\n')
104}
105
106export const register: Register = on => {
107 on('session.start', async ($, e, next) => {
108 // Not awaited: registering makes Claude Code re-describe every command, which would hold startup.
109 // Typed as /scenes in the terminal; the command file is what the desktop app lists in its menu.
110 void $.command.register({
111 name: 'scenes',
112 description: 'Pixel scenes while Claude works: list, pin one, random, crab, off',
113 argumentHint: '[<scene>|random|crab on|off|off|on]',
114 }).catch(() => undefined)
115 const prefs = (await $.store.get('prefs')) as { mode?: Mode; pinned?: string | null; withCrab?: boolean } | undefined
116 if (prefs?.mode) mode = prefs.mode
117 if (prefs?.pinned !== undefined) pinned = prefs.pinned
118 if (prefs?.withCrab !== undefined) withCrab = prefs.withCrab
119 const stored = (await $.store.get('fed')) as { day: string; count: number } | undefined
120 if (stored) fed = stored
121 void readUsage($)
122
123 return next(e)
124 })
125
126 on('session.measure', async ($, e, next) => {
127 void readUsage($)
128 return next(e)
129 })
130
131 // Claude starts working: a scene comes up, random or the pinned one.
132 on('turn.start', async ($, e, next) => {
133 if (mode !== 'off') {
134 hideTimer?.cancel()
135 const now = await $.clock.now()
136 turnStartedAt = now
137 finishedAt = null
138 tool = 'think'
139 toolCalls = 0
140 bursts = bursts.filter(burst => now - burst.at < 4000)
141 const scene = mode === 'pinned' && pinned && sceneById(pinned) ? sceneById(pinned)! : pickScene(activity(now), lastScene, Math.random())
142 lastScene = scene.id
143 await update($, sceneId, () => scene.id)
144 await update($, showing, () => true)
145 animation?.cancel()
146 animation = $.clock.every(FRAME_MS, () => void paint($))
147 }
148 return next(e)
149 })
150
151 // Each tool call: the crab acts it out; tests, commits and pushes set off a burst.
152 on('tool.call', async ($, e, next) => {
153 const command = e.tool === 'Bash' ? String((e as { command?: string }).command ?? '') : ''
154 tool = toolOf(e.tool, command)
155 toolCalls += 1
156 const result = await next(e)
157 const failed = 'deny' in result || ('isError' in result && result.isError === true)
158 const now = await $.clock.now()
159 if (e.tool === 'Bash') {
160 if (TEST_COMMAND.test(command)) bursts.push({ kind: failed ? 'tests-fail' : 'tests-pass', at: now })
161 else if (!failed && /\bgit\s+push\b/.test(command)) bursts.push({ kind: 'push', at: now })
162 else if (!failed && /\bgit\s+commit\b/.test(command)) bursts.push({ kind: 'commit', at: now })
163 }
164 tool = 'think'
165 return result
166 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
167
168 // The turn ends: the crab eats (a heart for today), the scene lingers a moment, then goes.
169 on('turn.complete', async ($, e, next) => {
170 if (await read($, showing)) {
171 const now = await $.clock.now()
172 finishedAt = now
173 fed = { day: today(now), count: (fed.day === today(now) ? fed.count : 0) + 1 }
174 void $.store.set('fed', fed)
175 hideTimer = $.clock.after(LINGER_MS, () => void hide($))
176 }
177 return next(e)
178 })
179
180 on('command.run', { command: ['scenes', 'scenes:scenes'] }, async ($, e) => {
181 const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
182 const current = await read($, sceneId)
183 if (verb === '' || verb === 'list') return { text: listText(current) }
184 if (verb === 'off') {
185 mode = 'off'
186 await savePrefs($)
187 await hide($)
188 return { text: 'Scenes off.' }
189 }
190 if (verb === 'on' || verb === 'random') {
191 mode = 'random'
192 await savePrefs($)
193 return { text: 'A random scene each time Claude works.' }
194 }
195 if (verb === 'crab') {
196 withCrab = rest[0] !== 'off'
197 await savePrefs($)
198 return { text: withCrab ? 'The crab is back.' : 'The crab is resting.' }
199 }
200 const scene = findScene([verb, ...rest].join(' '))
201 if (!scene) return { text: `No scene "${[verb, ...rest].join(' ')}". Run /scenes to see them.` }
202 mode = 'pinned'
203 pinned = scene.id
204 await savePrefs($)
205 return { text: `Pinned: ${scene.name} plays each time Claude works. /scenes random to mix them again.` }
206 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
207
208 // The band above the prompt, only while Claude works (and a moment after). Whatever else draws there stays above it.
209 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
210 if (!(await read($, showing)) || e.props.hasSurvey) return next(e)
211 const { Box, Raster, Svg } = $.ui.resolve(e)
212 const scene = sceneById(await read($, sceneId)) ?? SCENES[0]!
213 const rows = Math.max(3, Math.min(ROWS, e.props.maxRows))
214 const columns = Math.max(20, e.props.bodyColumns)
215 const now = await $.clock.now()
216 const act = activity(now)
217 const above = await next(e)
218
219 if (e.surface === 'desktop' && Svg) {
220 const svg = desktopSvg(scene, columns, rows * 2, act, withCrab)
221 return (
222 <Box flexDirection="column">
223 {above}
224 <Svg key={`${KEY}-${scene.id}`} source={svg} height={rows * 18} alt={scene.name} isInteractive />
225 </Box>
226 )
227 }
228 if (e.surface !== 'terminal' || !Raster) return above
229
230 band = { requestId: e.requestId, columns, rows }
231 const phase = ((now - turnStartedAt) % LOOP_MS) / LOOP_MS
232 return (
233 <Box flexDirection="column">
234 {above}
235 <Raster key={KEY} columns={columns} rows={rows} cells={encodeCells(toCells(composeFrame(scene, columns, rows * 2, phase, act, withCrab)))} />
236 </Box>
237 )
238 })
239}
240
241hooks/engine.ts 182 lines1/**
2 * The drawing engine every scene shares: a canvas of pixels, color helpers, sprites, and the two ways a canvas
3 * reaches the screen (half-block cells in the terminal, an animated SVG on the desktop). Nothing is loaded:
4 * every pixel is computed.
5 */
6
7/** What Claude is doing, as the scenes and the crab read it. */
8export type Tool = 'think' | 'read' | 'edit' | 'search' | 'bash' | 'test' | 'web' | 'agent'
9
10export type Burst = { kind: 'tests-pass' | 'tests-fail' | 'push' | 'commit'; at: number }
11
12export type Activity = {
13 /** Milliseconds since the turn started. */
14 elapsedMs: number
15 /** Milliseconds since the turn ended (the scene lingers a moment to finish); null while it runs. */
16 finishingMs: number | null
17 tool: Tool
18 /** Tool calls in this turn. */
19 toolCalls: number
20 /** The 5-hour limit, percent used; null when unknown. */
21 usagePercent: number | null
22 /** The local hour, 0 to 23. */
23 hour: number
24 /** Tasks finished today: the crab's hearts. */
25 fedToday: number
26 bursts: Burst[]
27 now: number
28}
29
30export const NO_ACTIVITY: Activity = {
31 elapsedMs: 0, finishingMs: null, tool: 'think', toolCalls: 0, usagePercent: null, hour: 12, fedToday: 0, bursts: [], now: 0,
32}
33
34export class Canvas {
35 readonly colors: Uint32Array
36 constructor(readonly width: number, readonly height: number, background = 0) {
37 this.colors = new Uint32Array(width * height).fill(background)
38 }
39 put(x: number, y: number, color: number) {
40 x = Math.round(x)
41 y = Math.round(y)
42 if (x >= 0 && x < this.width && y >= 0 && y < this.height) this.colors[y * this.width + x] = color
43 }
44 get(x: number, y: number): number {
45 return this.colors[Math.max(0, Math.min(this.height - 1, y)) * this.width + Math.max(0, Math.min(this.width - 1, x))]!
46 }
47 fill(x0: number, y0: number, x1: number, y1: number, color: number) {
48 for (let y = Math.max(0, Math.round(y0)); y < Math.min(this.height, Math.round(y1)); y++) {
49 for (let x = Math.max(0, Math.round(x0)); x < Math.min(this.width, Math.round(x1)); x++) this.colors[y * this.width + x] = color
50 }
51 }
52 /** A vertical gradient over rows y0..y1, in fixed steps. */
53 gradient(y0: number, y1: number, stops: Stops, steps = 12) {
54 for (let y = y0; y < y1; y++) this.fill(0, y, this.width, y + 1, ramp(stops, (y - y0) / Math.max(1, y1 - y0 - 1), steps))
55 }
56 disc(cx: number, cy: number, radius: number, color: number | ((x: number, y: number) => number | null)) {
57 for (let y = Math.floor(cy - radius); y <= Math.ceil(cy + radius); y++) {
58 for (let x = Math.floor(cx - radius); x <= Math.ceil(cx + radius); x++) {
59 if ((x - cx) ** 2 + (y - cy) ** 2 > radius * radius) continue
60 const value = typeof color === 'number' ? color : color(x, y)
61 if (value !== null) this.put(x, y, value)
62 }
63 }
64 }
65 /** A line that stays unbroken however steep. */
66 line(x0: number, y0: number, x1: number, y1: number, color: number) {
67 const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1)
68 for (let step = 0; step <= steps; step++) this.put(x0 + ((x1 - x0) * step) / steps, y0 + ((y1 - y0) * step) / steps, color)
69 }
70 /** Pixel art: one string per row, each character a color from `palette`; others are see-through. */
71 sprite(art: readonly string[], palette: Record<string, number>, x: number, y: number, flip = false) {
72 const width = Math.max(...art.map(row => row.length))
73 art.forEach((row, dy) => {
74 for (let dx = 0; dx < row.length; dx++) {
75 const color = palette[row[dx]!]
76 if (color !== undefined) this.put(x + (flip ? width - 1 - dx : dx), y + dy, color)
77 }
78 })
79 }
80}
81
82export type Stops = [number, string][]
83
84export const hex = (value: string) => parseInt(value.slice(1), 16)
85
86export function mix(a: number, b: number, t: number): number {
87 const k = Math.max(0, Math.min(1, t))
88 const channel = (shift: number) => Math.round(((a >> shift) & 255) + (((b >> shift) & 255) - ((a >> shift) & 255)) * k)
89 return (channel(16) << 16) | (channel(8) << 8) | channel(0)
90}
91
92export function ramp(stops: Stops, t: number, steps = 12): number {
93 const at = Math.round(Math.max(0, Math.min(1, t)) * steps) / steps
94 for (let index = 1; index < stops.length; index++) {
95 const [a, from] = stops[index - 1]!
96 const [b, to] = stops[index]!
97 if (at <= b) return mix(hex(from), hex(to), b > a ? (at - a) / (b - a) : 0)
98 }
99 return hex(stops[stops.length - 1]![1])
100}
101
102/** A stable hash in 0..1, so things sit in the same place on every frame. */
103export function noise(seed: number): number {
104 let x = (Math.imul(seed | 0, 374761393) ^ 0x5bd1e995) | 0
105 x = Math.imul(x ^ (x >>> 13), 1274126177)
106 return ((x ^ (x >>> 16)) >>> 0) / 0x100000000
107}
108
109/** x wrapped into 0..1. */
110export const wrap = (x: number) => ((x % 1) + 1) % 1
111
112const DEFAULT_COLOR = 0x01000000
113const UPPER_HALF = 0x2580
114const FULL = 0x2588
115
116/** A canvas twice as tall as `rows` as Raster cells: each a half block, its top pixel over its bottom one. */
117export function toCells(canvas: Canvas): Uint32Array {
118 const { width, colors } = canvas
119 const rows = Math.floor(canvas.height / 2)
120 const words = new Uint32Array(width * rows * 3)
121 for (let row = 0; row < rows; row++) {
122 for (let column = 0; column < width; column++) {
123 const top = colors[row * 2 * width + column]!
124 const bottom = colors[(row * 2 + 1) * width + column]!
125 const at = (row * width + column) * 3
126 words[at] = top === bottom ? FULL : UPPER_HALF
127 words[at + 1] = top
128 words[at + 2] = top === bottom ? DEFAULT_COLOR : bottom
129 }
130 }
131 return words
132}
133
134/** Frames as an animated SVG: one `<g>` per frame shown in turn, each row drawn as runs of one color. */
135export function toSvg(frames: Canvas[], seconds: number): string {
136 const { width, height } = frames[0]!
137 const groups = frames.map((canvas, frame) => {
138 const runs: string[] = []
139 for (let y = 0; y < height; y++) {
140 let start = 0
141 for (let x = 1; x <= width; x++) {
142 if (x === width || canvas.colors[y * width + x] !== canvas.colors[y * width + start]) {
143 runs.push(`<rect x="${start}" y="${y}" width="${x - start}" height="1" fill="#${canvas.colors[y * width + start]!.toString(16).padStart(6, '0')}"/>`)
144 start = x
145 }
146 }
147 }
148 const values = frames.map((_, index) => (index === frame ? 'inline' : 'none')).join(';')
149 return `<g display="${frame === 0 ? 'inline' : 'none'}"><animate attributeName="display" values="${values}" dur="${seconds}s" calcMode="discrete" repeatCount="indefinite"/>${runs.join('')}</g>`
150 })
151 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMax slice" shape-rendering="crispEdges">${groups.join('')}</svg>`
152}
153
154const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
155
156/** Standard padded base64 of the words' little-endian bytes, as RasterProps' `cells` takes. */
157export function encodeCells(words: Uint32Array): string {
158 const bytes = new Uint8Array(words.buffer, words.byteOffset, words.byteLength)
159 let out = ''
160 for (let index = 0; index < bytes.length; index += 3) {
161 const a = bytes[index]!
162 const b = bytes[index + 1]
163 const c = bytes[index + 2]
164 out += ALPHABET[a >> 2]
165 out += ALPHABET[((a & 3) << 4) | ((b ?? 0) >> 4)]
166 out += b === undefined ? '=' : ALPHABET[((b & 15) << 2) | ((c ?? 0) >> 6)]
167 out += c === undefined ? '=' : ALPHABET[c & 63]
168 }
169 return out
170}
171
172/** A scene: draws one frame. `phase` runs 0 to 1 over one loop (LOOP_MS); `act` is what Claude is doing. */
173export type Scene = {
174 id: string
175 name: string
176 /** Only offered when this holds (the weather needs a usage figure). */
177 isAvailable?: (act: Activity) => boolean
178 draw: (canvas: Canvas, phase: number, act: Activity) => void
179}
180
181export const LOOP_MS = 2400
182hooks/scenes/index.ts 55 lines1import { drawBursts } from '../bursts'
2import { drawCrab } from '../crab'
3import { Canvas, LOOP_MS, toSvg } from '../engine'
4import type { Activity, Scene } from '../engine'
5import { campfire } from './campfire'
6import { fishTank } from './fish-tank'
7import { lofiRoom } from './lofi-room'
8import { matrix, matrixRain } from './matrix'
9import { nightDrive } from './night-drive'
10import { oceanSunset } from './ocean-sunset'
11import { rainyWindow } from './rainy-window'
12import { spaceFlight } from './space-flight'
13import { weather } from './weather'
14
15export const SCENES: Scene[] = [nightDrive, rainyWindow, fishTank, spaceFlight, lofiRoom, campfire, oceanSunset, matrix, weather]
16
17export function sceneById(id: string): Scene | undefined {
18 return SCENES.find(scene => scene.id === id)
19}
20
21/** A scene by id or by a loose spelling of its name ("lofi", "fish", "night drive"). */
22export function findScene(asked: string): Scene | undefined {
23 const plain = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, '')
24 const wanted = plain(asked)
25 if (wanted === '') return undefined
26 return SCENES.find(scene => plain(scene.id) === wanted || plain(scene.name) === wanted)
27 ?? (SCENES.filter(scene => plain(scene.id).includes(wanted)).length === 1 ? SCENES.find(scene => plain(scene.id).includes(wanted)) : undefined)
28}
29
30/** A random scene for a new turn: never the one just shown, only those available now. */
31export function pickScene(act: Activity, last: string | null, random: number): Scene {
32 const choices = SCENES.filter(scene => scene.id !== last && (scene.isAvailable?.(act) ?? true))
33 return choices[Math.floor(random * choices.length)] ?? nightDrive
34}
35
36/** One frame: the scene, the matrix sweeping over it while Claude searches, any bursts, and the crab. */
37export function composeFrame(scene: Scene, width: number, height: number, phase: number, act: Activity, withCrab: boolean): Canvas {
38 const canvas = new Canvas(width, height)
39 scene.draw(canvas, phase, act)
40 if (act.tool === 'search' && scene.id !== 'matrix' && act.finishingMs === null) matrixRain(canvas, phase, 0.12)
41 drawBursts(canvas, act)
42 if (withCrab) drawCrab(canvas, phase, act)
43 return canvas
44}
45
46/** The desktop's SVG: the largest scene that stays under its 131072-character limit. */
47export function desktopSvg(scene: Scene, columns: number, height: number, act: Activity, withCrab: boolean): string {
48 let svg = ''
49 for (const [width, frames] of [[Math.min(columns, 80), 8], [64, 6], [48, 5], [40, 4], [32, 3]] as const) {
50 svg = toSvg(Array.from({ length: frames }, (_, frame) => composeFrame(scene, width, height, frame / frames, act, withCrab)), LOOP_MS / 1000)
51 if (svg.length < 120_000) break
52 }
53 return svg
54}
55hooks/bursts.ts 89 lines1import { hex, mix, noise } from './engine'
2import type { Activity, Burst, Canvas } from './engine'
3
4/** How long a burst plays over the scene. */
5export const BURST_MS = 3500
6
7const CONFETTI = [hex('#ff5f7a'), hex('#ffd34d'), hex('#4fd1ff'), hex('#7dff8a'), hex('#c58aff'), hex('#ffffff')]
8const CLOUD = hex('#7a8296')
9const CLOUD_DARK = hex('#5a6276')
10const DROP = hex('#8ab8ff')
11const ROCKET = hex('#e8ecf5')
12const ROCKET_TIP = hex('#ff4a5a')
13const WINDOW = hex('#4fd1ff')
14const FLAME = [hex('#fff3b0'), hex('#ffb33a'), hex('#ff5a2a')]
15const SMOKE = hex('#9a9aa8')
16const STAMP = hex('#3ad17a')
17const STAMP_DARK = hex('#1f8a4c')
18
19/** Tests passed: confetti bursts up from the bottom and flutters down. */
20function confetti(canvas: Canvas, t: number) {
21 const { width, height } = canvas
22 for (let index = 0; index < Math.round(width * 0.7); index++) {
23 const x0 = noise(index * 3 + 1) * width
24 const up = 0.6 + noise(index * 3 + 2) * 0.8
25 // Up fast, then down slowly, swaying.
26 const y = height - (up * height * 1.6 * t - height * 1.1 * t * t)
27 const x = x0 + Math.sin(t * 12 + index) * 2
28 if (t > 0.85 && noise(index + 7) < (t - 0.85) * 6) continue
29 canvas.put(x, y, CONFETTI[index % CONFETTI.length]!)
30 }
31}
32
33/** Tests failed: a grey cloud drifts over and rains. */
34function rainCloud(canvas: Canvas, t: number) {
35 const { width, height } = canvas
36 const x = width * 0.15 + t * width * 0.25
37 const y = height * 0.15
38 for (const [dx, dy, r] of [[0, 1, 2.5], [3, 0, 3.2], [7, 0.5, 3], [10, 1.5, 2.2]] as const) canvas.disc(x + dx, y + dy, r, CLOUD)
39 canvas.fill(x - 2, y + 2, x + 12, y + 3, CLOUD_DARK)
40 for (let index = 0; index < 12; index++) {
41 const fall = (t * 4 + noise(index)) % 1
42 canvas.put(x - 1 + noise(index + 30) * 13, y + 4 + fall * (height - y - 4), DROP)
43 }
44}
45
46/** Pushed: a rocket lifts off from the bottom, flame and smoke behind it. */
47function rocket(canvas: Canvas, t: number) {
48 const { width, height } = canvas
49 const x = Math.round(width * 0.82)
50 const y = height - 2 - t * t * (height + 14)
51 canvas.sprite(['..t..', '.rrr.', '.rwr.', '.rrr.', 'rrrrr', 'r...r'], { t: ROCKET_TIP, r: ROCKET, w: WINDOW }, x - 2, y - 6)
52 for (let step = 0; step < 6; step++) {
53 canvas.put(x, y + step, FLAME[Math.min(2, Math.floor(step / 2))]!)
54 if (step > 1) canvas.put(x + (step % 2 ? 1 : -1), y + step, FLAME[2]!)
55 }
56 for (let puff = 0; puff < 10; puff++) {
57 const age = noise(puff) * t
58 canvas.put(x + (noise(puff + 20) - 0.5) * 8 * age * 3, height - 1 - age * 4, mix(SMOKE, hex('#000000'), age))
59 }
60}
61
62/** Committed: a green check stamps down and fades. */
63function stamp(canvas: Canvas, t: number) {
64 const { width, height } = canvas
65 const size = Math.round(Math.max(4, height * 0.4) * (t < 0.15 ? 1.6 - t * 4 : 1))
66 const cx = Math.round(width * 0.82)
67 const cy = Math.round(height / 2)
68 if (t > 0.8 && Math.floor(t * 30) % 2) return
69 canvas.disc(cx, cy, size, STAMP_DARK)
70 canvas.disc(cx, cy, size - 1, STAMP)
71 canvas.line(cx - size * 0.45, cy, cx - size * 0.1, cy + size * 0.4, hex('#ffffff'))
72 canvas.line(cx - size * 0.1, cy + size * 0.4, cx + size * 0.5, cy - size * 0.4, hex('#ffffff'))
73}
74
75const DRAW: Record<Burst['kind'], (canvas: Canvas, t: number) => void> = {
76 'tests-pass': confetti,
77 'tests-fail': rainCloud,
78 push: rocket,
79 commit: stamp,
80}
81
82/** Every burst still playing, over whatever scene is drawn. */
83export function drawBursts(canvas: Canvas, act: Activity) {
84 for (const burst of act.bursts) {
85 const age = act.now - burst.at
86 if (age >= 0 && age < BURST_MS) DRAW[burst.kind](canvas, age / BURST_MS)
87 }
88}
89hooks/crab.ts 115 lines1import { hex, wrap } from './engine'
2import type { Activity, Canvas } from './engine'
3
4const BODY = hex('#d4826a')
5const EYE = hex('#1b1b1b')
6const PAGE = hex('#f2efe6')
7const COVER = hex('#4e8de0')
8const LAPTOP = hex('#c9ccd3')
9const SCREEN = hex('#7df3ff')
10const SHOVEL = hex('#9aa0a6')
11const HANDLE = hex('#8a6a4a')
12const DIRT = hex('#7a5a3a')
13const PROMPT = hex('#3cff6a')
14const TERMINAL = hex('#1b1b24')
15const GLOBE = hex('#4fb8ff')
16const LAND = hex('#4fd17a')
17const WAVE = hex('#c58aff')
18const BUBBLE = hex('#e8e8f0')
19const BALLS = [hex('#ff5f7a'), hex('#ffd34d'), hex('#4fd1ff')]
20const FOOD = hex('#e8b04a')
21const HEART = hex('#ff4a6a')
22const ZZZ = hex('#bfc8ff')
23
24const CRAB = ['..oooooo..', 'o.oeooeo.o', 'oooooooooo', '..oooooo..', '..o.o.o.o.']
25const SLEEPY = ['..oooooo..', 'o.oxooxo.o', 'oooooooooo', '..oooooo..', '..o.o.o.o.']
26const HEART_ART = ['h.h', 'hhh', '.h.']
27
28/** Late at night the crab gets sleepy. */
29const isSleepy = (hour: number) => hour >= 0 && hour < 6
30
31/**
32 * The companion crab in the scene's bottom-left corner: it does what Claude does (reads, types, digs, runs,
33 * browses, delegates, thinks, juggles on long runs), eats when a task is done, and keeps today's hearts.
34 */
35export function drawCrab(canvas: Canvas, phase: number, act: Activity) {
36 const { height } = canvas
37 const x = 3
38 const bob = Math.floor(phase * 4) % 2
39 const y = height - CRAB.length - 1 - bob
40 const sleepy = isSleepy(act.hour) && act.finishingMs === null
41 canvas.sprite(sleepy ? SLEEPY : CRAB, { o: BODY, e: EYE, x: EYE }, x, y)
42 const front = x + 11
43 const tick = Math.floor(phase * 16)
44
45 // Today's hearts, up to five, above the crab.
46 for (let heart = 0; heart < Math.min(5, act.fedToday); heart++) canvas.sprite(HEART_ART, { h: HEART }, x + heart * 4 - 1, y - 5)
47
48 if (act.finishingMs !== null) {
49 // Task done: a snack, a chomp, a heart popping up.
50 const t = Math.min(1, act.finishingMs / 1500)
51 if (t < 0.7) canvas.sprite(['ff', 'ff'], { f: FOOD }, front - 1 - Math.round(t * 2), y + 1)
52 canvas.sprite(HEART_ART, { h: HEART }, x + 4, y - 2 - Math.round(t * 6))
53 return
54 }
55 if (sleepy) {
56 for (let index = 0; index < 3; index++) {
57 const rise = wrap(phase + index / 3)
58 canvas.put(x + 8 + rise * 6, y - 1 - rise * 6, ZZZ)
59 }
60 }
61 if (act.elapsedMs > 30_000 && act.tool !== 'think') {
62 // A long run: juggling.
63 BALLS.forEach((color, index) => {
64 const angle = (phase * 2 + index / 3) * Math.PI * 2
65 canvas.put(x + 5 + Math.cos(angle) * 4, y - 3 + Math.sin(angle) * 3 - 1, color)
66 })
67 return
68 }
69 switch (act.tool) {
70 case 'read':
71 canvas.sprite(['cpppc', 'cpppc', 'ccccc'], { p: PAGE, c: COVER }, front, y + 1)
72 if (tick % 4 === 0) canvas.put(front + 2, y + 1, COVER)
73 break
74 case 'edit':
75 canvas.sprite(['sssss', 'sssss', 'lllllll'], { s: SCREEN, l: LAPTOP }, front - 1, y + 1)
76 canvas.put(front - 2, y + 2 + (tick % 2), BODY)
77 break
78 case 'search':
79 canvas.line(front, y - 1, front + 3, y + 3, HANDLE)
80 canvas.fill(front + 3, y + 3, front + 5, y + 5, SHOVEL)
81 for (let clod = 0; clod < 3; clod++) {
82 const arc = wrap(phase * 2 + clod / 3)
83 canvas.put(front + 5 + arc * 6, y + 3 - Math.sin(arc * Math.PI) * 5, DIRT)
84 }
85 break
86 case 'bash':
87 case 'test':
88 canvas.fill(front, y, front + 6, y + 4, TERMINAL)
89 canvas.put(front + 1, y + 1, PROMPT)
90 if (tick % 2) canvas.put(front + 2, y + 1, PROMPT)
91 canvas.fill(front + 1, y + 3, front + 2 + (tick % 4), y + 4, PROMPT)
92 break
93 case 'web':
94 canvas.disc(front + 2, y + 2, 2.2, GLOBE)
95 canvas.put(front + 1 + (tick % 4), y + 1, LAND)
96 canvas.put(front + 2 + ((tick + 2) % 3), y + 3, LAND)
97 break
98 case 'agent':
99 for (let ring = 1; ring <= 3; ring++) {
100 if ((tick + ring) % 3 === 0) {
101 canvas.put(x + 5 - ring, y - 1 - ring, WAVE)
102 canvas.put(x + 5 + ring, y - 1 - ring, WAVE)
103 }
104 }
105 break
106 default: {
107 // Thinking: a bubble whose dots fill in turn.
108 canvas.put(x + 9, y - 1, BUBBLE)
109 canvas.put(x + 10, y - 3, BUBBLE)
110 canvas.fill(x + 11, y - 7, x + 18, y - 4, BUBBLE)
111 for (let dot = 0; dot < 3; dot++) if (tick % 4 > dot) canvas.put(x + 12 + dot * 2, y - 6, EYE)
112 }
113 }
114}
115hooks/scenes/campfire.ts 67 lines1import { hex, mix, noise, ramp, wrap } from '../engine'
2import type { Activity, Canvas, Scene, Stops } from '../engine'
3
4const TREE = hex('#0a1410')
5const TREE_FAR = hex('#122119')
6const GROUND = hex('#1a140f')
7const LOG = hex('#5a3418')
8const LOG_END = hex('#8a5a2e')
9const STAR = hex('#d8e6ff')
10const FIRE: Stops = [[0, '#fff3b0'], [0.3, '#ffc845'], [0.6, '#ff7a2a'], [0.85, '#d6322a'], [1, '#6a1a1a']]
11const SPARK = hex('#ffd36a')
12
13/** A campfire in the woods: it grows as the turn goes on, and throws a shower of sparks when tests pass. */
14export const campfire: Scene = {
15 id: 'campfire',
16 name: 'Campfire',
17 draw(canvas: Canvas, phase: number, act: Activity) {
18 const { width, height } = canvas
19 canvas.gradient(0, height, [[0, '#050814'], [0.7, '#0e1430'], [1, '#1a1a3a']], 8)
20 for (let index = 0; index < Math.round(width / 3); index++) {
21 if (wrap(phase + noise(index * 2 + 1)) < 0.8) canvas.put(noise(index * 2 + 2) * width, noise(index * 2 + 3) * height * 0.5, STAR)
22 }
23 canvas.disc(width * 0.85, height * 0.2, 2.2, hex('#f2efd8'))
24
25 // Two rows of pines.
26 for (const [far, color] of [[true, TREE_FAR], [false, TREE]] as const) {
27 for (let index = 0; index < Math.round(width / (far ? 5 : 8)); index++) {
28 const x = noise(index * 9 + (far ? 1 : 2)) * width
29 if (!far && Math.abs(x - width / 2) < width * 0.12) continue
30 const tall = height * (far ? 0.35 : 0.55) * (0.7 + noise(index * 9 + 3) * 0.5)
31 const base = height - 3
32 for (let step = 0; step < tall; step++) {
33 const half = ((tall - step) / tall) * (far ? 2.2 : 3.2)
34 canvas.fill(x - half, base - step, x + half + 1, base - step + 1, color)
35 }
36 }
37 }
38 canvas.fill(0, height - 3, width, height, GROUND)
39
40 // The fire: bigger the longer Claude works, flickering tongues of flame.
41 const cx = width / 2
42 const grow = Math.min(1, 0.35 + act.elapsedMs / 50_000)
43 const tall = Math.round((height - 4) * grow)
44 const halfBase = 2 + grow * 4
45 const glow = halfBase * 3
46 canvas.disc(cx, height - 3, glow, (x, y) => mix(canvas.get(x, y), hex('#ff7a2a'), 0.18 * (1 - Math.hypot(x - cx, y - height + 3) / glow)))
47 for (let column = -Math.ceil(halfBase); column <= Math.ceil(halfBase); column++) {
48 const edge = 1 - Math.abs(column) / (halfBase + 1)
49 const flicker = noise(Math.floor(phase * 30) * 13 + column * 7)
50 const flame = Math.round(tall * edge * (0.6 + flicker * 0.5))
51 for (let step = 0; step < flame; step++) {
52 canvas.put(cx + column + Math.sin(phase * Math.PI * 4 + step * 0.7) * (step / tall), height - 4 - step, ramp(FIRE, step / Math.max(1, flame) * 0.9 + (1 - edge) * 0.2, 8))
53 }
54 }
55 canvas.sprite(['eLLLLLLLLe', '.eLLLLLLe.'], { L: LOG, e: LOG_END }, cx - 5, height - 4)
56
57 // Sparks rising; a shower of them for a few seconds after tests pass.
58 const cheer = act.bursts.some(burst => burst.kind === 'tests-pass' && act.now - burst.at < 4000)
59 const sparks = cheer ? 26 : 6
60 for (let index = 0; index < sparks; index++) {
61 const rise = wrap(phase * (1 + Math.floor(noise(index) * 2)) + noise(index + 50))
62 const x = cx + (noise(index + 90) - 0.5) * (cheer ? width * 0.3 : 6) * rise + Math.sin(rise * 9 + index) * 1.2
63 canvas.put(x, height - 4 - tall * 0.6 - rise * height, mix(SPARK, hex('#ff5a2a'), rise))
64 }
65 },
66}
67hooks/scenes/fish-tank.ts 88 lines1import { hex, noise, wrap } from '../engine'
2import type { Activity, Canvas, Scene } from '../engine'
3
4const SAND = hex('#c9a66b')
5const SAND_DARK = hex('#a8834d')
6const WEED = hex('#2fa86a')
7const WEED_DARK = hex('#1f7a4c')
8const BUBBLE = hex('#bff3ff')
9const FOOD = hex('#e8b04a')
10const CHEST = hex('#7a4a22')
11const GOLD = hex('#ffd34d')
12
13// Each fish faces right: tail at the left, eye near the nose.
14const FISH = [
15 { art: ['o.ooo.', 'ooooeo', 'o.ooo.'], body: hex('#ff8a3d') },
16 { art: ['b..bbb..', 'bbbbbbbb.', '.bbbbbebb', 'bbbbbbbb.', 'b..bbb..'], body: hex('#4fb8ff') },
17 { art: ['y.yyy.', 'yyyyey', 'y.yyy.'], body: hex('#ffd84d') },
18 { art: ['p.ppp.', 'ppppep', 'p.ppp.'], body: hex('#ff6fb5') },
19]
20
21/** A fish tank: fish swim, weed sways, a bubble rises per tool call, and food drops when the task is done. */
22export const fishTank: Scene = {
23 id: 'fish-tank',
24 name: 'Fish tank',
25 draw(canvas: Canvas, phase: number, act: Activity) {
26 const { width, height } = canvas
27 canvas.gradient(0, height, [[0, '#0f5c8a'], [0.6, '#0a3d66'], [1, '#062744']], 8)
28 // Light rays from the surface.
29 for (let ray = 0; ray < 4; ray++) {
30 const x0 = width * (0.15 + ray * 0.24) + Math.sin((phase + ray) * Math.PI * 2) * 2
31 for (let y = 0; y < height * 0.7; y++) canvas.put(x0 + y * 0.4, y, hex('#1676a8'))
32 }
33
34 // Sand, a treasure chest, and weed swaying.
35 for (let x = 0; x < width; x++) {
36 const top = height - 2 - Math.round(noise(Math.floor(x / 6)) * 1.4)
37 canvas.fill(x, top, x + 1, height, (x + top) % 5 === 0 ? SAND_DARK : SAND)
38 }
39 const chestX = Math.round(width * 0.12)
40 canvas.sprite(['.gggg.', 'cccccc', 'cgccgc', 'cccccc'], { c: CHEST, g: GOLD }, chestX, height - 6)
41 for (let index = 0; index < Math.round(width / 12); index++) {
42 const x = noise(index * 13 + 3) * width
43 const tall = 4 + Math.floor(noise(index * 13 + 4) * (height * 0.45))
44 for (let step = 0; step < tall; step++) {
45 const sway = Math.sin((phase + index * 0.13) * Math.PI * 2 + step * 0.5) * (step / tall) * 1.6
46 canvas.put(x + sway, height - 2 - step, step % 3 === 0 ? WEED_DARK : WEED)
47 }
48 }
49
50 // Bubbles: one stream per tool call this turn (up to 8), rising from the chest and the weed.
51 const streams = Math.min(8, 1 + act.toolCalls)
52 for (let stream = 0; stream < streams; stream++) {
53 const x0 = stream === 0 ? chestX + 3 : noise(stream * 17 + 5) * width
54 for (let bubble = 0; bubble < 3; bubble++) {
55 const rise = wrap(phase * 2 + bubble / 3 + noise(stream * 17 + 6))
56 canvas.put(x0 + Math.sin(rise * 9) * 0.8, height - 3 - rise * (height - 3), BUBBLE)
57 }
58 }
59
60 // Food falls when the task is done; the fish swim to it.
61 const feeding = act.finishingMs !== null
62 const foodX = width / 2
63 if (feeding) {
64 for (let flake = 0; flake < 7; flake++) {
65 const fall = Math.min(1, (act.finishingMs! / 1600) * (0.6 + noise(flake) * 0.6))
66 canvas.put(foodX - 4 + noise(flake + 40) * 8, fall * height * 0.6, FOOD)
67 }
68 }
69
70 // The fish.
71 FISH.forEach((fish, index) => {
72 const speed = 1 + (index % 2)
73 const lane = height * (0.25 + noise(index * 7 + 1) * 0.45)
74 const goesRight = index % 2 === 0
75 const swim = wrap((act.elapsedMs / 1000) * speed * 0.07 + noise(index * 7 + 2))
76 let x = goesRight ? swim * (width + 10) - 8 : width + 2 - swim * (width + 10)
77 let y = lane + Math.sin((phase * 2 + index) * Math.PI * 2) * 1.2
78 if (feeding) {
79 const pull = Math.min(1, act.finishingMs! / 1200)
80 x += (foodX - 3 + index * 2 - x) * pull
81 y += (height * 0.45 - y) * pull
82 }
83 const palette: Record<string, number> = { o: fish.body, b: fish.body, y: fish.body, p: fish.body, e: hex('#0b0b14') }
84 canvas.sprite(fish.art, palette, x, y - Math.floor(fish.art.length / 2), !goesRight)
85 })
86 },
87}
88hooks/scenes/lofi-room.ts 98 lines1import { hex, mix, noise, wrap } from '../engine'
2import type { Activity, Canvas, Scene } from '../engine'
3
4const WALL = hex('#2a1f3d')
5const WALL_LOW = hex('#33264a')
6const WINDOW_FRAME = hex('#1a1326')
7const DESK = hex('#5a3a2a')
8const DESK_TOP = hex('#7a5038')
9const MONITOR = hex('#1b1b24')
10const SCREEN = hex('#16324a')
11const CODE = [hex('#7df3ff'), hex('#ff8fd8'), hex('#ffd36b'), hex('#9cf28a')]
12const MUG = hex('#e8e0d0')
13const STEAM = hex('#8d84a8')
14const LAMP = hex('#ffcf7a')
15const CAT = hex('#14101c')
16const CAT_EYE = hex('#c8f26a')
17const PLANT = hex('#4fa86a')
18const POT = hex('#b0603a')
19
20/** A lo-fi room: rain on the window, a cat on the sill, steaming coffee, and code scrolling while Claude edits. */
21export const lofiRoom: Scene = {
22 id: 'lofi-room',
23 name: 'Lo-fi room',
24 draw(canvas: Canvas, phase: number, act: Activity) {
25 const { width, height } = canvas
26 canvas.gradient(0, height, [[0, '#251a38'], [1, '#33264a']], 6)
27
28 // The window: night, city lights, rain.
29 const wx0 = Math.round(width * 0.58)
30 const wx1 = Math.round(width * 0.92)
31 const wy1 = Math.round(height * 0.62)
32 canvas.gradient(1, wy1, [[0, '#0c1030'], [1, '#2a1a4a']], 6)
33 canvas.fill(0, 0, wx0, height, WALL)
34 canvas.fill(wx1, 0, width, height, WALL)
35 canvas.fill(wx0, wy1, wx1, height, WALL_LOW)
36 for (let index = 0; index < Math.round((wx1 - wx0) / 3); index++) {
37 const x = wx0 + 1 + noise(index * 5 + 1) * (wx1 - wx0 - 2)
38 const y = wy1 - 2 - noise(index * 5 + 2) * (wy1 * 0.5)
39 canvas.put(x, y, CODE[index % CODE.length]!)
40 }
41 for (let index = 0; index < Math.round((wx1 - wx0) / 2); index++) {
42 const x = wx0 + 1 + noise(index * 7 + 3) * (wx1 - wx0 - 2)
43 const y = 1 + wrap(noise(index * 7 + 4) + phase * 2) * (wy1 - 2)
44 canvas.put(x, y, hex('#6f86c4'))
45 }
46 canvas.fill(wx0 - 1, 0, wx0, wy1 + 1, WINDOW_FRAME)
47 canvas.fill(wx1, 0, wx1 + 1, wy1 + 1, WINDOW_FRAME)
48 canvas.fill(wx0 - 2, wy1, wx1 + 2, wy1 + 1, DESK_TOP)
49 canvas.fill(Math.round((wx0 + wx1) / 2), 0, Math.round((wx0 + wx1) / 2) + 1, wy1, WINDOW_FRAME)
50
51 // The cat on the sill, its tail swishing.
52 const catX = wx1 - 8
53 canvas.sprite(['c...c.', 'cccc..', 'ecec..', 'cccccc', 'cccccc'], { c: CAT, e: CAT_EYE }, catX, wy1 - 5)
54 const swish = Math.sin(phase * Math.PI * 2) * 1.5
55 canvas.put(catX + 6, wy1 - 2, CAT)
56 canvas.put(catX + 7, wy1 - 3 + swish * 0.5, CAT)
57 canvas.put(catX + 8 + swish * 0.4, wy1 - 4 + swish, CAT)
58
59 // A desk lamp: its shade, a warm cone of light down onto the desk.
60 const lampX = Math.round(width * 0.5)
61 const lampTop = Math.round(height * 0.2)
62 for (let y = lampTop + 3; y < height - 4; y++) {
63 const spread = (y - lampTop - 3) * 0.6 + 2
64 for (let x = Math.round(lampX - spread); x <= Math.round(lampX + spread); x++) canvas.put(x, y, mix(canvas.get(x, y), LAMP, 0.1))
65 }
66 canvas.sprite(['..sss..', '.sssss.', 'sssssss', '...l...'], { s: hex('#c86a3a'), l: LAMP }, lampX - 3, lampTop)
67 canvas.fill(lampX, lampTop + 4, lampX + 1, height - 4, hex('#3a2c22'))
68
69 // The desk, the monitor and its code, the mug, a plant.
70 const deskY = height - 4
71 canvas.fill(0, deskY, wx0 - 3, deskY + 1, DESK_TOP)
72 canvas.fill(0, deskY + 1, wx0 - 3, height, DESK)
73 const mx0 = Math.round(width * 0.2)
74 const mx1 = Math.round(width * 0.46)
75 const my0 = Math.round(height * 0.18)
76 canvas.fill(mx0, my0, mx1, deskY - 1, MONITOR)
77 canvas.fill(mx0 + 1, my0 + 1, mx1 - 1, deskY - 2, SCREEN)
78 canvas.fill(Math.round((mx0 + mx1) / 2) - 1, deskY - 1, Math.round((mx0 + mx1) / 2) + 2, deskY, MONITOR)
79 const lines = deskY - 3 - (my0 + 1)
80 const scroll = act.tool === 'edit' || act.tool === 'bash' ? Math.floor(phase * 12) : 0
81 for (let line = 0; line < lines; line++) {
82 const seed = line + scroll
83 const indent = Math.floor(noise(seed * 3 + 1) * 4)
84 const length = 3 + Math.floor(noise(seed * 3 + 2) * (mx1 - mx0 - 8))
85 const color = CODE[Math.floor(noise(seed * 3 + 3) * CODE.length)]!
86 canvas.fill(mx0 + 2 + indent, my0 + 2 + line, Math.min(mx1 - 2, mx0 + 2 + indent + length), my0 + 3 + line, mix(color, SCREEN, 0.25))
87 }
88 if (Math.floor(phase * 6) % 2 === 0) canvas.put(mx0 + 3, deskY - 3, hex('#ffffff'))
89 const mugX = mx1 + 4
90 canvas.sprite(['mmm.', 'mmmm', 'mmm.'], { m: MUG }, mugX, deskY - 3)
91 for (let puff = 0; puff < 3; puff++) {
92 const rise = wrap(phase + puff / 3)
93 canvas.put(mugX + 1 + Math.sin(rise * 6 + puff) * 1, deskY - 4 - rise * 5, mix(STEAM, WALL, rise))
94 }
95 canvas.sprite(['.p.p.', 'ppppp', '.ppp.', '.ooo.', '.ooo.'], { p: PLANT, o: POT }, mx1 + 10, deskY - 5)
96 },
97}
98hooks/scenes/matrix.ts 35 lines1import { hex, mix, noise, wrap } from '../engine'
2import type { Canvas, Scene } from '../engine'
3
4const HEAD = hex('#d8ffd8')
5const BRIGHT = hex('#3cff6a')
6const DIM = hex('#0f7a2a')
7const BLACK = hex('#020a04')
8
9/** Columns of green code raining down. `density` 0 to 1: a full scene, or a sparse sweep over another scene. */
10export function matrixRain(canvas: Canvas, phase: number, density: number) {
11 const { width, height } = canvas
12 for (let column = 0; column < width; column++) {
13 if (noise(column * 7 + 3) > density) continue
14 const speed = 1 + Math.floor(noise(column * 7 + 4) * 3)
15 const length = 4 + Math.floor(noise(column * 7 + 5) * height * 0.8)
16 const head = wrap(noise(column * 7 + 6) + phase * speed) * (height + length)
17 for (let step = 0; step < length; step++) {
18 const y = Math.floor(head - step)
19 if (y < 0 || y >= height) continue
20 // Glyphs change as they fall: some cells blink out.
21 if (step > 0 && noise(column * 131 + y * 17 + Math.floor(phase * 20)) < 0.18) continue
22 canvas.put(column, y, step === 0 ? HEAD : mix(BRIGHT, DIM, step / length))
23 }
24 }
25}
26
27export const matrix: Scene = {
28 id: 'matrix',
29 name: 'Matrix rain',
30 draw(canvas: Canvas, phase: number) {
31 canvas.fill(0, 0, canvas.width, canvas.height, BLACK)
32 matrixRain(canvas, phase, 0.55)
33 },
34}
35hooks/scenes/night-drive.ts 163 lines1import { hex, mix, noise, ramp } from '../engine'
2import type { Canvas, Scene } from '../engine'
3
4/** Synthwave: a striped neon sun behind a skyline, the grid and streetlights rushing at you, your car. */
5const SKY: [number, string][] = [[0, '#0b0120'], [0.5, '#24063f'], [0.8, '#5a1166'], [0.94, '#b0206f'], [1, '#ff3d8b']]
6const SUN: [number, string][] = [[0, '#fff07a'], [0.45, '#ffb347'], [0.75, '#ff6a5c'], [1, '#ff2a8a']]
7const C = {
8 ground: hex('#0e0122'),
9 grid: hex('#ff2fa8'),
10 gridFar: hex('#3a0a4a'),
11 road: hex('#160829'),
12 edge: hex('#16e0ff'),
13 dash: hex('#ffd23f'),
14 building: hex('#120526'),
15 buildingTop: hex('#5a2a8a'),
16 windows: [hex('#ff8fd8'), hex('#7df3ff'), hex('#ffe08a')],
17 stars: [hex('#ffffff'), hex('#b9f2ff'), hex('#ffc7f0')],
18 lamp: hex('#fff1c1'),
19 pole: hex('#3b2363'),
20 car: hex('#4a2a7a'),
21 carTop: hex('#120a22'),
22 glass: hex('#3d7fc4'),
23 tail: hex('#ff2d4a'),
24 plate: hex('#d9d2ff'),
25}
26
27
28export const nightDrive: Scene = {
29 id: 'night-drive',
30 name: 'Night drive',
31 draw(canvas: Canvas, phase: number) {
32 const { width, height, colors } = canvas
33 const put = (x: number, y: number, color: number) => canvas.put(x, y, color)
34 const horizon = Math.max(4, Math.round(height * 0.55))
35 const centre = Math.floor(width / 2)
36 const span = height - horizon
37
38 const roadHalf = Math.max(8, Math.min(width * 0.2, span * 2.6))
39
40 // Sky and stars, twinkling.
41 for (let y = 0; y < horizon; y++) {
42 const color = ramp(SKY, y / horizon, 14)
43 for (let x = 0; x < width; x++) colors[y * width + x] = color
44 }
45 const stars = Math.round((width * horizon) / 110)
46 for (let index = 0; index < stars; index++) {
47 const x = Math.floor(noise(index * 3 + 1) * width)
48 const y = Math.floor(noise(index * 3 + 2) * (horizon - 3))
49 if ((phase * 2 + noise(index * 3 + 3)) % 1 < 0.75) put(x, y, C.stars[index % 3]!)
50 }
51
52 // The sun, its lower half cut by bands drifting down.
53 const radius = Math.max(3, Math.min(Math.round(horizon * 0.8), Math.round(width * 0.09)))
54 const sunY = horizon - 1
55 const band = Math.max(3, Math.round(radius / 3))
56 for (let y = sunY - radius; y < horizon; y++) {
57 const dy = y - sunY
58 if (dy * dy > radius * radius) continue
59 const below = y - (sunY - Math.round(radius * 0.45))
60 if (below >= 0) {
61 const gap = 1 + Math.floor((below / (radius * 0.45 + 1)) * 2)
62 const offset = (((below - Math.floor(phase * band * 2)) % band) + band) % band
63 if (offset < gap) continue
64 }
65 const half = Math.floor(Math.sqrt(radius * radius - dy * dy))
66 const color = ramp(SUN, (y - (sunY - radius)) / (radius + 1), 8)
67 for (let x = centre - half; x <= centre + half; x++) put(x, y, color)
68 }
69
70 // The skyline: low near the sun, taller toward the sides; its windows flicker.
71 for (let x = 0, index = 0; x < width; index++) {
72 const wide = 3 + Math.floor(noise(index * 7 + 11) * 7)
73 const fromCentre = Math.abs(x + wide / 2 - centre) / (width / 2)
74 const tall = Math.max(1, Math.round(horizon * (0.12 + fromCentre * 0.5) * (0.5 + noise(index * 7 + 12))))
75 for (let y = horizon - tall; y < horizon; y++) {
76 for (let column = x; column < x + wide; column++) put(column, y, y === horizon - tall ? C.buildingTop : C.building)
77 }
78 for (let column = x + 1; column < x + wide - 1; column += 2) {
79 for (let y = horizon - tall + 1; y < horizon - 1; y += 2) {
80 const lit = noise(column * 31 + y * 17)
81 if (lit < 0.3 && (phase + lit * 3) % 1 < 0.85) put(column, y, C.windows[Math.floor(lit * 10) % 3]!)
82 }
83 }
84 x += wide + (noise(index * 7 + 13) < 0.3 ? 1 : 0)
85 }
86
87 /** A line down the ground that stays unbroken however steep: each row fills the run from the row above. */
88 const slope = (xAt: (s: number) => number, color: number) => {
89 let previous: number | null = null
90 for (let y = horizon; y < height; y++) {
91 const x = Math.round(xAt((y - horizon + 1) / span))
92 const low = previous === null ? x : Math.min(x, previous + Math.sign(x - previous))
93 const high = previous === null ? x : Math.max(x, previous + Math.sign(x - previous))
94 for (let column = low; column <= high; column++) put(column, y, color)
95 previous = x
96 }
97 }
98
99 // Ground and grid: rows one pixel thick at every depth, rushing toward you; columns converging.
100 for (let y = horizon; y < height; y++) {
101 const s = (y - horizon + 1) / span
102 for (let x = 0; x < width; x++) colors[y * width + x] = C.ground
103 const depth = 1 / s
104 const perPixel = 1.2 / (s * s * span)
105 if (perPixel < 0.45 && (((depth * 1.2 - phase * 2) % 1) + 1) % 1 < perPixel * 1.05) {
106 const color = mix(C.gridFar, C.grid, Math.round(Math.min(1, s * 1.8) * 6) / 6)
107 for (let x = 0; x < width; x++) colors[y * width + x] = color
108 }
109 }
110 // A few grid lines converging on the road's vanishing point.
111 for (let k = 1; k <= 6; k++) {
112 for (const side of [-1, 1]) slope(s => centre + side * (roadHalf * s + k * width * 0.11 * s), C.gridFar)
113 }
114
115 // The road, with cyan edges and yellow dashes in the middle.
116 for (let y = horizon; y < height; y++) {
117 const s = (y - horizon + 1) / span
118 const half = roadHalf * s
119 const left = Math.round(centre - half)
120 const right = Math.round(centre + half)
121 for (let x = left; x <= right; x++) put(x, y, C.road)
122 if ((((1 / s) * 1.5 - phase * 3) % 1 + 1) % 1 < 0.5) {
123 put(centre, y, C.dash)
124 if (s > 0.6) put(centre - 1, y, C.dash)
125 }
126 }
127
128 slope(s => centre - roadHalf * s - 1, C.edge)
129 slope(s => centre + roadHalf * s + 1, C.edge)
130
131 // Streetlights on both sides, coming toward you.
132 for (let k = 0; k < 3; k++) {
133 const u = (k + phase * 3) / 3 % 1
134 const s = u * u
135 if (s < 0.08) continue
136 const y = horizon + Math.round(s * (span - 1))
137 for (const side of [-1, 1]) {
138 const x = Math.round(centre + side * roadHalf * 1.4 * s)
139 const tall = Math.max(2, Math.round(height * 0.6 * s))
140 for (let py = y - tall; py <= y; py++) put(x, py, C.pole)
141 put(x - side, y - tall, C.lamp)
142 put(x, y - tall, C.lamp)
143 }
144 }
145
146 // Your car from behind: a small sprite, roof, taillights, plate and tires, with a small bounce.
147 const car = [
148 '...gggggg...',
149 '..gggggggg..',
150 'tttbbbbbbttt',
151 'bbbbbppbbbbb',
152 '.dd......dd.',
153 ]
154 const paint: Record<string, number> = { g: C.glass, t: C.tail, b: C.car, p: C.plate, d: C.carTop }
155 if (span >= car.length) {
156 const left = centre - 6
157 const top = height - car.length - (phase * 8 % 1 < 0.5 ? 0 : 1) + 1
158 car.forEach((line, row) => [...line].forEach((pixel, column) => paint[pixel] !== undefined && put(left + column, top + row, paint[pixel]!)))
159 }
160
161 },
162}
163hooks/scenes/ocean-sunset.ts 68 lines1import { hex, mix, noise, ramp, wrap } from '../engine'
2import type { Activity, Canvas, Scene, Stops } from '../engine'
3
4const SKY: Stops = [[0, '#2a1650'], [0.45, '#8a2f6a'], [0.8, '#ff7a4a'], [1, '#ffc46a']]
5const SUN: Stops = [[0, '#fff3b0'], [1, '#ff9a3a']]
6const SEA = hex('#1a2a5a')
7const SEA_DARK = hex('#101a3e')
8const WAVE = hex('#3a5a9a')
9const SHIMMER = [hex('#ffd36a'), hex('#ff9a4a'), hex('#ff6a5a')]
10const CLOUD = hex('#ff9a8a')
11const SAIL = hex('#fff2e0')
12const HULL = hex('#2a1420')
13const BIRD = hex('#2a1430')
14
15/** An ocean sunset: waves rolling in, the sun's reflection shimmering, gulls, and a sailboat crossing over the turn. */
16export const oceanSunset: Scene = {
17 id: 'ocean-sunset',
18 name: 'Ocean sunset',
19 draw(canvas: Canvas, phase: number, act: Activity) {
20 const { width, height } = canvas
21 const horizon = Math.round(height * 0.5)
22 canvas.gradient(0, horizon, SKY, 10)
23
24 // Clouds drifting slowly, the sun on the horizon.
25 for (let index = 0; index < 3; index++) {
26 const x = wrap(noise(index * 4 + 1) + (act.elapsedMs / 1000) * 0.01 * (index + 1)) * (width + 30) - 15
27 const y = horizon * (0.2 + index * 0.18)
28 const length = 10 + noise(index * 4 + 2) * 16
29 canvas.fill(x, y, x + length, y + 1, mix(CLOUD, hex('#5a2a6a'), index * 0.2))
30 canvas.fill(x + 3, y - 1, x + length - 4, y, mix(CLOUD, hex('#ffffff'), 0.2))
31 }
32 const sunX = width * 0.5
33 const sunR = Math.max(3, horizon * 0.55)
34 canvas.disc(sunX, horizon, sunR, (x, y) => (y < horizon ? ramp(SUN, (y - (horizon - sunR)) / sunR, 6) : null))
35
36 // Gulls.
37 for (let index = 0; index < 3; index++) {
38 const x = wrap(noise(index + 7) + (act.elapsedMs / 1000) * 0.05) * width
39 const y = horizon * (0.25 + noise(index + 9) * 0.4)
40 const flap = Math.floor(phase * 8 + index) % 2
41 canvas.put(x - 1, y - flap, BIRD)
42 canvas.put(x, y, BIRD)
43 canvas.put(x + 1, y - flap, BIRD)
44 }
45
46 // The sea: rows of waves rolling toward you, the sun's reflection breaking on them.
47 for (let y = horizon; y < height; y++) {
48 const depth = (y - horizon) / (height - horizon)
49 canvas.fill(0, y, width, y + 1, mix(SEA_DARK, SEA, depth))
50 const spacing = 4 + depth * 10
51 for (let x = 0; x < width; x++) {
52 const crest = wrap(x / spacing / 3 + Math.sin(y * 1.7) + phase * (1 + Math.floor(depth * 2)))
53 if (crest < 0.18) canvas.put(x, y, WAVE)
54 }
55 const spread = sunR * (0.6 + depth * 1.2)
56 for (let x = sunX - spread; x < sunX + spread; x++) {
57 if (noise(Math.floor(x) * 31 + y * 7 + Math.floor(phase * 10)) < 0.35) canvas.put(x, y, SHIMMER[(y + Math.floor(x)) % 3]!)
58 }
59 }
60
61 // A sailboat crossing over the turn: about a minute a crossing, rocking on the swell.
62 const across = wrap(act.elapsedMs / 60_000 + 0.15)
63 const boatX = across * (width + 16) - 12
64 const rock = Math.round(Math.sin(phase * Math.PI * 2))
65 canvas.sprite(['....s....', '...ss....', '..sss....', '.ssss.s..', '...m..ss.', 'hhhhhhhhh', '.hhhhhhh.'], { s: SAIL, m: HULL, h: HULL }, boatX, horizon + 1 + rock - 5)
66 },
67}
68hooks/scenes/rainy-window.ts 75 lines1import { hex, mix, noise, wrap } from '../engine'
2import type { Activity, Canvas, Scene } from '../engine'
3
4const NIGHT = hex('#0a0d24')
5const LIGHTS = [hex('#ff5fa2'), hex('#5fd7ff'), hex('#ffcf5f'), hex('#9a7bff'), hex('#ff8a4f')]
6const STREAK = hex('#8fb4e8')
7const STREAK_DIM = hex('#3a4f80')
8const DROP = hex('#cfe3ff')
9const FRAME = hex('#1a1426')
10const FRAME_EDGE = hex('#2e2440')
11const SILL = hex('#3a2c22')
12
13/** A rainy window at night: soft neon city lights behind glass, rain streaking down, drops sliding. */
14export const rainyWindow: Scene = {
15 id: 'rainy-window',
16 name: 'Rainy window',
17 draw(canvas: Canvas, phase: number, act: Activity) {
18 const { width, height } = canvas
19 canvas.gradient(0, height, [[0, '#070a1c'], [0.6, '#10163a'], [1, '#1c1840']], 8)
20
21 // The city behind the glass: dark towers with lit windows.
22 for (let x = 0, index = 0; x < width; index++) {
23 const wide = 4 + Math.floor(noise(index * 7 + 1) * 8)
24 const tall = Math.round(height * (0.3 + noise(index * 7 + 2) * 0.55))
25 canvas.fill(x, height - tall, x + wide, height, hex('#0c0f26'))
26 for (let wx = x + 1; wx < x + wide - 1; wx += 2) {
27 for (let wy = height - tall + 1; wy < height - 1; wy += 2) {
28 if (noise(wx * 13 + wy * 7) < 0.22) canvas.put(wx, wy, mix(LIGHTS[(wx + wy) % LIGHTS.length]!, hex('#0c0f26'), 0.45))
29 }
30 }
31 x += wide + 1
32 }
33
34 // Bokeh: big soft lights out of focus, breathing.
35 for (let index = 0; index < Math.round(width / 6); index++) {
36 const x = noise(index * 5 + 1) * width
37 const y = height * 0.2 + noise(index * 5 + 2) * height * 0.7
38 const color = LIGHTS[Math.floor(noise(index * 5 + 3) * LIGHTS.length)]!
39 const breathe = 0.5 + 0.5 * Math.sin((phase + noise(index * 5 + 4)) * Math.PI * 2)
40 const radius = 1.5 + noise(index * 5 + 5) * 2
41 canvas.disc(x, y, radius, (px, py) => mix(canvas.get(px, py), color, (0.18 + 0.22 * breathe) * (1 - Math.hypot(px - x, py - y) / (radius + 0.5))))
42 }
43
44 // Rain on the glass: long slanted streaks, more and faster while tools run.
45 const isBusy = act.tool !== 'think'
46 const streaks = Math.round(width * (isBusy ? 0.45 : 0.25))
47 for (let index = 0; index < streaks; index++) {
48 const speed = 1 + Math.floor(noise(index * 3 + 7) * 2) + (isBusy ? 1 : 0)
49 const x0 = noise(index * 3 + 8) * (width + height * 0.4)
50 const y = wrap(noise(index * 3 + 9) + phase * speed) * (height + 10) - 8
51 const length = 4 + Math.floor(noise(index * 3 + 10) * 5)
52 for (let step = 0; step < length; step++) {
53 canvas.put(x0 - (y + step) * 0.3, y + step, mix(STREAK_DIM, STREAK, step / length))
54 }
55 }
56
57 // Drops on the glass sliding down, a bright glint on each.
58 for (let index = 0; index < Math.round(width / 7); index++) {
59 const x = noise(index * 11 + 21) * width
60 const slide = wrap(noise(index * 11 + 22) + act.elapsedMs / 5000)
61 const y = slide * slide * height
62 canvas.put(x, y, DROP)
63 canvas.put(x, y + 1, mix(DROP, NIGHT, 0.4))
64 canvas.put(x, y - 1, mix(STREAK_DIM, NIGHT, 0.3))
65 }
66
67 // The window: one bar down the middle and the sill.
68 const bar = Math.round(width * 0.62)
69 canvas.fill(bar - 1, 0, bar + 1, height - 2, FRAME)
70 canvas.fill(bar, 0, bar + 1, height - 2, FRAME_EDGE)
71 canvas.fill(0, height - 2, width, height - 1, FRAME_EDGE)
72 canvas.fill(0, height - 1, width, height, SILL)
73 },
74}
75