SLOPSHOPPER

scenes

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…

newbandguardcommandtimer
★ 3v0.1.0MITupdated 2026-10-15omeerdvrn/o-mods/mods/scenes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · scenes
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /scenes ⎿ scenes: A random scene each time Claude works. The crab is on. ⎿ scenes: ⎿ scenes: ○ night-drive Night drive ⎿ scenes: ○ rainy-window Rainy window ⎿ scenes: ○ fish-tank Fish tank ⎿ scenes: ○ space-flight Space flight ⟨Claude Code's own drawing⟩ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀████▀██▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀█████████████▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀████▀██▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀█████████████▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
README

scenes

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.

A pixel night-drive scene with a neon sun plays above the prompt while Claude works. Later a fish tank with a small crab and swimming fish appears.

Scenes

SceneWhat it does
Night driveSynthwave: a striped neon sun behind a skyline, grid and streetlights rushing at you, your car
Rainy windowRain on the glass over a city out of focus; it rains harder while tools run
Fish tankFish and swaying weed; a bubble stream per tool call; food drops when the task is done and the fish swarm to it
Space flightWarp stars, a ringed planet sliding by; the engine burns hotter the longer Claude works
Lo-fi roomA desk lamp, steaming coffee, a cat on the sill, rain outside; code scrolls on the monitor while Claude edits
CampfirePines under the stars; the fire grows on long turns and throws a shower of sparks when tests pass
Ocean sunsetWaves, gulls, the sun's reflection shimmering; a sailboat crosses over the turn
Matrix rainGreen code raining down
Usage weatherA village whose sky follows your 5-hour usage: clear, cloudy, rain, a thunderstorm near the limit

Over any scene

  • Matrix rain sweeps over the scene while Claude searches (Grep, Glob).
  • Bursts: confetti when tests pass, a rain cloud when they fail, a rocket on git push, a check stamp on commit.
  • The companion crab in the corner acts out what Claude does: reads a book (Read), types on a laptop (Edit), digs (Grep), runs a terminal (Bash), spins a globe (web), sends radio waves (subagents), thinks in a bubble, and juggles on long runs. When a task finishes it eats, and a heart joins today's row; late at night it is sleepy.

Commands

CommandDoes
/scenesLists the scenes
/scenes <name>Plays that one every time (/scenes fish, /scenes lofi)
/scenes randomA random one each time (the default)
`/scenes crab on\off`Shows or hides the crab
/scenes off / /scenes onStops or starts the scenes

Adding a scene

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.

Source 15 files
hooks/register.tsx 241 lines
1import { 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
241
hooks/engine.ts 182 lines
1/**
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
182
hooks/scenes/index.ts 55 lines
1import { 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}
55
hooks/bursts.ts 89 lines
1import { 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}
89
hooks/crab.ts 115 lines
1import { 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}
115
hooks/scenes/campfire.ts 67 lines
1import { 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}
67
hooks/scenes/fish-tank.ts 88 lines
1import { 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}
88
hooks/scenes/lofi-room.ts 98 lines
1import { 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}
98
hooks/scenes/matrix.ts 35 lines
1import { 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}
35
hooks/scenes/night-drive.ts 163 lines
1import { 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}
163
hooks/scenes/ocean-sunset.ts 68 lines
1import { 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}
68
hooks/scenes/rainy-window.ts 75 lines
1import { 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