Animated 8-bit wallpapers in the docked side pane: koi pond, mountains, metropolis, neon district.

Animated 8-bit wallpapers for the side pane. /ambient opens it.
| Key | Scene | Moves |
|---|---|---|
| 1 | koi pond | koi, ripples, drifting petals, caustics |
| 2 | mountains at dusk | 4 parallax ridges, pines, clouds, the odd flock |
| 3 | metropolis | office lights, beacons, a train every 18s, river reflection |
| 4 | neon district | rain, hover traffic, flickering signs, wet street |
Keys work after a click on the pane or ctrl+x tab.
| Command | Does | |
|---|---|---|
/ambient | open or close | |
/ambient <scene> | switch: koi, mountains, metropolis, cyberpunk | |
| `/ambient autostart on\ | off` | open at session start. Off by default |
The last scene sticks across restarts.
12 fps while the pane is on screen. Nothing while it's hidden or behind another tab. About 28 KB a frame at 44x40 cells.
No music. A generated lofi loop got as far as $.audio.play; on macOS nothing played, and Linux and Windows terminals have no player. Cut.
Credits: CREDITS.md.
hooks/register.tsx 233 lines1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { AmbientScene } from '../types'
8import { cyberpunk } from './scenes/cyberpunk'
9import { koi } from './scenes/koi'
10import { metropolis } from './scenes/metropolis'
11import { mountains } from './scenes/mountains'
12import { encodeCells, label, Pixels, type Scene } from './screen'
13
14const PANE = 'ambient'
15const TITLE = 'Ambient'
16// Dock floor. Below it the engine seats panes inline, above the prompt.
17const DOCK_COLUMNS = 110
18// ~12fps. Smooth enough for drifting water, half the arcade's blit traffic.
19const TICK_MS = 83
20const WATCH_MS = 250
21// Fallback only, before the pane reports its body height.
22const CHROME_ROWS = 6
23const LABEL_S = 2.5
24
25const SCENES: readonly (Scene & { short: string })[] = [
26 { ...koi, short: 'koi' },
27 { ...mountains, short: 'peaks' },
28 { ...metropolis, short: 'city' },
29 { ...cyberpunk, short: 'neon' },
30]
31
32const ALIASES: Readonly<Record<string, string>> = {
33 pond: 'koi',
34 peaks: 'mountains',
35 city: 'metropolis',
36 neon: 'cyberpunk',
37}
38
39const scene = atom({ plugin: 'ambient', key: 'scene' } as const, 'koi' as AmbientScene)
40
41let shown: Scene = koi
42let size = { columns: 0, rows: 0 }
43let startedAt = 0
44let switchedAt = -LABEL_S
45let isVisible = false
46let placement: 'dock' | 'inline' | undefined
47let isHiddenByWidth = false
48let viewport = { columns: 0, isFullscreen: false }
49
50const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, Math.floor(n)))
51
52const find = (name: string): (typeof SCENES)[number] | undefined => {
53 const wanted = name.trim().toLowerCase()
54 const id = ALIASES[wanted] ?? wanted
55
56 return SCENES.find(one => one.id === id || one.short === id)
57}
58
59const frame = (columns: number, rows: number, t: number): string => {
60 const p = new Pixels(columns, rows)
61 shown.draw(p, t)
62 const cells = p.cells()
63 if (t - switchedAt < LABEL_S) label(cells, columns, rows - 2, 2, shown.title, 0xf3e6d8)
64
65 return encodeCells(cells)
66}
67
68// Timer and press callbacks have no caller to throw to. Failures go to
69// `claude --debug`.
70function report($: EngineInterface, error: unknown) {
71 $.ui.log(`ambient: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
72}
73
74async function seconds($: EngineInterface): Promise<number> {
75 return ((await $.clock.now()) - startedAt) / 1000
76}
77
78async function paint($: EngineInterface) {
79 if (size.columns === 0) return
80 const cells = frame(size.columns, size.rows, await seconds($))
81 await $.ui.blit({ requestId: PANE, key: 'view', columns: size.columns, rows: size.rows, cells })
82}
83
84async function tick($: EngineInterface) {
85 if (isVisible) await paint($)
86}
87
88async function pick($: EngineInterface, next: Scene) {
89 shown = next
90 switchedAt = await seconds($)
91 await update($, scene, () => next.id)
92 await $.store.set('scene', next.id)
93 await paint($)
94}
95
96// Below DOCK_COLUMNS the engine moves the pane above the prompt instead of
97// hiding it. Close it there, reopen once the window is wide again.
98async function watch($: EngineInterface) {
99 const pane = (await $.ui.panes()).find(one => one.id === PANE)
100 isVisible = pane !== undefined && pane.isShown && pane.isPlaced
101
102 if (pane !== undefined && placement === 'inline') {
103 isHiddenByWidth = true
104 placement = undefined
105 size = { columns: 0, rows: 0 }
106 await $.ui.close({ id: PANE })
107 return
108 }
109
110 const isWide = viewport.isFullscreen && viewport.columns >= DOCK_COLUMNS
111 if (pane === undefined && isHiddenByWidth && isWide) {
112 isHiddenByWidth = false
113 await $.ui.open({ id: PANE, title: TITLE })
114 }
115}
116
117// Off by default: no pane opens by itself unless the person turned this on.
118async function autostart($: EngineInterface, value: string): Promise<string> {
119 if (value === 'on' || value === 'off') await $.store.set('autostart', value === 'on')
120 else if (value !== '') return 'Usage: /ambient autostart on|off'
121 return (await $.store.get('autostart')) === true
122 ? 'Ambient opens at session start. /ambient autostart off undoes it.'
123 : 'Ambient opens on /ambient only. /ambient autostart on opens it at session start.'
124}
125
126export const register: Register = on => {
127 on('session.start', async ($, e, next) => {
128 startedAt = await $.clock.now()
129 const stored = await $.store.get('scene')
130 shown = (typeof stored === 'string' ? find(stored) : undefined) ?? koi
131 await update($, scene, () => shown.id)
132 await $.command.register({
133 name: 'ambient',
134 description: 'Open or close the ambient pane; /ambient <scene> switches scene; /ambient autostart on|off',
135 argumentHint: '[koi|mountains|metropolis|cyberpunk|autostart]',
136 })
137 // Opt-in only. Unasked, the engine seats it from 144 columns, 110 once asked for.
138 if ((await $.store.get('autostart')) === true) void $.ui.open({ id: PANE, title: TITLE })
139 $.clock.every(TICK_MS, () => void tick($).catch(error => report($, error)))
140 $.clock.every(WATCH_MS, () => void watch($).catch(error => report($, error)))
141
142 return next(e)
143 })
144
145 on('command.run', { command: 'ambient' }, async ($, e) => {
146 const args = e.args.trim()
147 const [verb = '', value = ''] = args.split(/\s+/)
148 if (verb === 'autostart') return { text: await autostart($, value) }
149 const wanted = args === '' ? undefined : find(args)
150 if (args !== '' && wanted === undefined) {
151 return { text: `No scene called "${args}". Try: ${SCENES.map(one => one.id).join(', ')}.` }
152 }
153
154 const isOpen = (await $.ui.panes()).some(one => one.id === PANE)
155 if (isOpen && wanted === undefined) {
156 isHiddenByWidth = false
157 await $.ui.close({ id: PANE })
158 return { text: 'Ambient closed.' }
159 }
160 if (!e.presentation.isFullscreen) {
161 return { text: 'Ambient needs the fullscreen layout, where panes dock beside the transcript.' }
162 }
163 if (e.presentation.columns < DOCK_COLUMNS) {
164 return { text: `Ambient needs a window of ${DOCK_COLUMNS} columns or more. This one has ${e.presentation.columns}.` }
165 }
166
167 isHiddenByWidth = false
168 await $.ui.open({ id: PANE, title: TITLE })
169 if (wanted !== undefined) await pick($, wanted)
170
171 return { text: `Ambient: ${shown.title}.` }
172 })
173
174 on('ui.close', ($, e, next) => {
175 // Closed by hand: stays closed until /ambient.
176 if (e.id === PANE && e.origin.kind === 'person') isHiddenByWidth = false
177
178 return next(e)
179 })
180
181 // Width probe. The band re-renders on every resize, even with the pane
182 // closed. Returns the engine's band untouched.
183 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
184 if (e.surface === 'terminal' && e.viewport !== undefined) {
185 viewport = { columns: e.viewport.columns, isFullscreen: e.viewport.isFullscreen === true }
186 }
187
188 return next(e)
189 })
190
191 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
192 if (e.surface !== 'terminal') {
193 const { Text } = $.ui.resolve(e)
194 return <Text dimColor>Ambient draws in the terminal.</Text>
195 }
196
197 const { Box, Button, Raster, Text } = $.ui.resolve(e)
198 placement = e.props.placement
199 if (e.viewport !== undefined) {
200 viewport = { columns: e.viewport.columns, isFullscreen: e.viewport.isFullscreen === true }
201 }
202 if (e.props.placement === 'inline') {
203 return <Text dimColor>Ambient hides: the window is too narrow for the dock.</Text>
204 }
205
206 const columns = clamp(e.props.bodyColumns, 1, 512)
207 // The pane reports its body height; the button row under the Raster takes one.
208 const body = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : (e.viewport?.rows ?? 30) - CHROME_ROWS
209 const rows = clamp(body - 1, 8, 256)
210 size = { columns, rows }
211 // Subscribes this render to the pick: a switch relabels the buttons.
212 const current = await read($, scene)
213
214 return (
215 <Box flexDirection="column">
216 <Raster key="view" columns={columns} rows={rows} cells={frame(columns, rows, await seconds($))} />
217 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
218 {SCENES.map((one, i) => (
219 <Button
220 key={`scene-${one.id}`}
221 hotkey={`${i + 1}`}
222 label={one.short}
223 plain
224 dimColor={one.id !== current}
225 onPress={() => void pick($, one).catch(error => report($, error))}
226 />
227 ))}
228 </Box>
229 </Box>
230 )
231 })
232}
233hooks/scenes/cyberpunk.ts 140 lines1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { hash, hash2, mix, Pixels, ramp, type Scene } from '../screen'
5
6const SKY = [0x05030c, 0x10081f, 0x240c38, 0x45114f, 0x6a1a5a] as const
7const TOWER = 0x120c22
8const RIM = 0x2e2150
9const SUN_TOP = 0xffd166
10const SUN_LOW = 0xff3cac
11const CYAN = 0x2de2e6
12const PINK = 0xff3cac
13const YELLOW = 0xf9c80e
14const HAZE = 0x5a1d6b
15const RAIN = 0x6f7fb8
16const STREET = 0x07050d
17const NEONS = [CYAN, PINK, YELLOW] as const
18
19type Tower = { x: number; w: number; top: number; seed: number; neon: number }
20
21const towers = (p: Pixels, ground: number): Tower[] => {
22 const out: Tower[] = []
23 let x = -1
24 let i = 0
25 while (x < p.w + 1) {
26 const w = 5 + Math.floor(hash(i * 5 + 1) * 8)
27 const top = ground - p.h * (0.45 + hash(i * 5 + 2) * 0.45)
28 out.push({ x, w, top, seed: i, neon: NEONS[Math.floor(hash(i * 5 + 3) * NEONS.length)] ?? CYAN })
29 x += w + 1 + Math.floor(hash(i * 5 + 4) * 3)
30 i += 1
31 }
32
33 return out
34}
35
36// A vertical sign: neon cells with dark gaps, so it reads as glyphs.
37const sign = (p: Pixels, x: number, y: number, w: number, h: number, color: number, seed: number, t: number) => {
38 // Flicker: about 3% of 125ms slots go dim.
39 const isDim = hash(seed * 31 + Math.floor(t * 8)) < 0.03
40 const lit = isDim ? mix(color, TOWER, 0.7) : color
41 for (let dy = 0; dy < h; dy++) {
42 for (let dx = 0; dx < w; dx++) {
43 const isGap = hash2(dx + seed * 17, Math.floor(dy / 3) * 5 + (dy % 3)) < 0.3 && dx > 0 && dx < w - 1
44 p.dot(x + dx, y + dy, isGap ? TOWER : lit)
45 }
46 }
47}
48
49export const cyberpunk: Scene = {
50 id: 'cyberpunk',
51 title: 'neon district',
52 draw: (p, t) => {
53 const ground = Math.round(p.h * 0.84)
54
55 for (let y = 0; y < ground; y++) {
56 for (let x = 0; x < p.w; x++) p.px[y * p.w + x] = ramp(SKY, y / ground, x, y)
57 }
58
59 // Synthwave sun: yellow to pink, sliced by gaps that widen towards the bottom.
60 const sunX = p.w * 0.5
61 const sunY = p.h * 0.36
62 const sunR = Math.max(4, p.w * 0.3)
63 for (let y = Math.floor(sunY - sunR); y <= sunY + sunR; y++) {
64 const k = (y - (sunY - sunR)) / (2 * sunR)
65 const gap = k > 0.5 && (y - Math.floor(sunY)) % 4 < 1 + (k - 0.5) * 6
66 if (gap) continue
67 for (let x = Math.floor(sunX - sunR); x <= sunX + sunR; x++) {
68 const dx = x + 0.5 - sunX
69 const dy = y + 0.5 - sunY
70 if (dx * dx + dy * dy <= sunR * sunR) p.dot(x, y, mix(SUN_TOP, SUN_LOW, k))
71 }
72 }
73
74 for (const tower of towers(p, ground)) {
75 // Towers in front of the sun stop below its middle, so it rises over them.
76 const isCentral = Math.abs(tower.x + tower.w / 2 - sunX) < sunR
77 const top = isCentral ? Math.max(tower.top, sunY + sunR * 0.25) : tower.top
78 p.fill(tower.x, tower.x + tower.w, top, ground, TOWER)
79 p.fill(tower.x, tower.x + 1, top, ground, RIM)
80 // Neon strip down one side.
81 const strip = hash(tower.seed * 7) < 0.5 ? tower.x + 1 : tower.x + tower.w - 1
82 p.fill(strip, strip + 1, top + 2, ground, mix(tower.neon, TOWER, 0.35))
83 for (let y = Math.ceil(top) + 2; y < ground; y += 3) {
84 for (let x = tower.x + 2; x < tower.x + tower.w - 2; x += 2) {
85 if (hash2(x + tower.seed * 41, y) < 0.2) p.dot(x, y, hash2(x, y) < 0.5 ? mix(CYAN, TOWER, 0.4) : mix(PINK, TOWER, 0.4))
86 }
87 }
88 if (tower.w >= 7 && hash(tower.seed * 13) < 0.7) {
89 const signY = top + 3 + hash(tower.seed * 11) * (ground - top) * 0.4
90 sign(p, tower.x + 2, signY, 3, Math.max(5, Math.round(p.h * 0.1)), tower.neon, tower.seed, t)
91 }
92 }
93
94 // Haze bands drifting across mid-height.
95 for (let band = 0; band < 2; band++) {
96 const cy = p.h * (0.45 + band * 0.18) + Math.sin(t * 0.2 + band) * 2
97 for (let y = Math.floor(cy - 2); y <= cy + 2; y++) {
98 for (let x = 0; x < p.w; x++) {
99 // Blend, not dither: dithered haze reads as dotted lines.
100 const k = (1 - Math.abs(y - cy) / 3) * 0.3
101 if (y >= 0 && y < ground) p.dot(x, y, mix(p.get(x, y), HAZE, k))
102 }
103 }
104 }
105
106 // Hover traffic in three lanes, both ways.
107 for (let lane = 0; lane < 3; lane++) {
108 const y = Math.round(p.h * (0.28 + lane * 0.14))
109 const dir = lane % 2 === 0 ? 1 : -1
110 for (let car = 0; car < 2; car++) {
111 const speed = 6 + hash(lane * 7 + car) * 8
112 const span = p.w + 10
113 const x = (((hash(lane * 3 + car * 11) * span + dir * t * speed) % span) + span) % span - 5
114 p.fill(x - 1, x + 2, y, y + 1, 0x1a1428)
115 p.dot(dir > 0 ? x + 2 : x - 2, y, CYAN)
116 p.dot(dir > 0 ? x - 2 : x + 2, y, PINK)
117 }
118 }
119
120 // Wet street mirrors the neon.
121 for (let y = ground; y < p.h; y++) {
122 const mirror = ground - (y - ground) - 1
123 const shift = Math.round(Math.sin(y * 1.7 + t * 3) * 1.5)
124 for (let x = 0; x < p.w; x++) {
125 p.px[y * p.w + x] = mix(p.get(x + shift, mirror), STREET, 0.5)
126 }
127 }
128
129 // Rain, slanted, over everything.
130 const drops = Math.round((p.w * p.h) / 45)
131 for (let i = 0; i < drops; i++) {
132 const fall = p.h + 12
133 const y = (hash(i * 3 + 1) * fall + t * (38 + hash(i) * 14)) % fall - 6
134 const x = (((hash(i * 3 + 2) * p.w - y * 0.25) % p.w) + p.w) % p.w
135 p.dot(x, y, mix(RAIN, p.get(x, y), 0.35))
136 p.dot(x + 0.25, y - 1, mix(RAIN, p.get(x, y - 1), 0.65))
137 }
138 },
139}
140hooks/scenes/koi.ts 171 lines1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { hash, mix, noise1, Pixels, ramp, shade, type Scene } from '../screen'
5
6const WATER = [0x082228, 0x0c3139, 0x113f47, 0x185159, 0x22646a] as const
7const PAD = 0x3d8a47
8const PAD_LIGHT = 0x5daf57
9const PAD_DARK = 0x29613a
10const PETAL = 0xf3a6c4
11const CORE = 0xffe08a
12const RIPPLE = 0x5f9ea0
13
14// White, red, black, gold: kohaku, showa, ogon, tancho.
15const VARIETIES = [
16 { base: 0xf2ece4, patch: 0xe5532a, patchAt: 0.45, head: -1 },
17 { base: 0x1e1d20, patch: 0xe5532a, patchAt: 0.5, head: -1 },
18 { base: 0xf0b13a, patch: 0xffd679, patchAt: 0.6, head: -1 },
19 { base: 0xf2ece4, patch: 0xf2ece4, patchAt: 2, head: 0xe23b2b },
20] as const
21
22type Fish = { seed: number; ax: number; ay: number; wx: number; wy: number; px: number; py: number; size: number }
23
24const school = (w: number, h: number): Fish[] => {
25 const count = Math.max(3, Math.min(9, Math.round((w * h) / 700)))
26 return Array.from({ length: count }, (_, i) => ({
27 seed: i * 31 + 7,
28 ax: 0.3 + hash(i * 3 + 1) * 0.12,
29 ay: 0.32 + hash(i * 3 + 2) * 0.1,
30 wx: (0.09 + hash(i * 5 + 3) * 0.08) * (hash(i + 99) < 0.5 ? -1 : 1),
31 wy: 0.07 + hash(i * 5 + 4) * 0.07,
32 px: hash(i * 7 + 5) * Math.PI * 2,
33 py: hash(i * 7 + 6) * Math.PI * 2,
34 size: Math.max(1.1, w / 34) * (0.8 + hash(i * 11 + 8) * 0.45),
35 }))
36}
37
38const at = (f: Fish, t: number, w: number, h: number) => ({
39 x: w * (0.5 + f.ax * Math.sin(f.wx * t + f.px) + 0.06 * Math.sin(2.3 * f.wx * t + f.py)),
40 y: h * (0.5 + f.ay * Math.sin(f.wy * t + f.py) + 0.05 * Math.sin(1.7 * f.wy * t + f.px)),
41})
42
43// Spine by arc length, walking back in time. Sampling by time instead bunches
44// the body up wherever the path slows near a turn.
45const spine = (f: Fish, t: number, w: number, h: number): { x: number; y: number }[] => {
46 const count = Math.round(f.size * 6)
47 const gap = Math.max(0.7, f.size * 0.75)
48 const points = [at(f, t, w, h)]
49 let back = t
50 for (let i = 1; i < count; i++) {
51 const last = points[points.length - 1]!
52 for (let tries = 0; tries < 400; tries++) {
53 back -= 0.04
54 const p = at(f, back, w, h)
55 if (Math.hypot(p.x - last.x, p.y - last.y) >= gap) {
56 points.push(p)
57 break
58 }
59 }
60 }
61
62 return points
63}
64
65const drawFish = (p: Pixels, f: Fish, t: number) => {
66 const body = spine(f, t, p.w, p.h)
67 const kind = VARIETIES[Math.floor(hash(f.seed) * VARIETIES.length)] ?? VARIETIES[0]
68 const n = body.length
69 const radius = (i: number) => f.size * (i < 2 ? 0.75 + i * 0.15 : Math.max(0.3, 1.05 - ((i - 2) / (n - 2)) * 0.8))
70
71 // Shadow on the pond floor first, offset down-right.
72 body.forEach((q, i) => p.disc(q.x + 1.2, q.y + 1.6, radius(i), WATER[0]))
73
74 const tail = body[n - 1]
75 const before = body[Math.max(0, n - 3)]
76 if (tail !== undefined && before !== undefined) {
77 const dx = tail.x - before.x
78 const dy = tail.y - before.y
79 const len = Math.hypot(dx, dy) || 1
80 const wag = Math.sin(t * 5 + f.seed) * f.size * 0.9
81 const fin = mix(kind.base, WATER[2], 0.35)
82 for (const side of [-1, 1]) {
83 p.disc(tail.x + (dx / len) * f.size * 1.2 + (-dy / len) * (side * f.size * 0.7 + wag), tail.y + (dy / len) * f.size * 1.2 + (dx / len) * (side * f.size * 0.7 + wag), f.size * 0.45, fin)
84 }
85 }
86
87 for (let i = n - 1; i >= 0; i--) {
88 const q = body[i]!
89 const isPatch = noise1(i * 0.45 + f.seed, f.seed) > kind.patchAt
90 const color = i <= 1 && kind.head >= 0 ? kind.head : isPatch ? kind.patch : kind.base
91 p.disc(q.x, q.y, radius(i), color)
92 }
93}
94
95const drawPads = (p: Pixels, t: number) => {
96 const count = Math.max(2, Math.round((p.w * p.h) / 900))
97 for (let i = 0; i < count; i++) {
98 const r = Math.max(2, (p.w / 14) * (0.6 + hash(i * 13 + 1) * 0.6))
99 const cx = hash(i * 13 + 2) * p.w + Math.sin(t * 0.08 + i) * 0.6
100 const cy = hash(i * 13 + 3) * p.h + Math.cos(t * 0.06 + i) * 0.6
101 const notch = hash(i * 13 + 4) * Math.PI * 2
102 for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) {
103 for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++) {
104 const dx = x + 0.5 - cx
105 const dy = y + 0.5 - cy
106 const d = Math.hypot(dx, dy)
107 if (d > r) continue
108 const angle = Math.atan2(dy, dx)
109 const off = Math.abs(((angle - notch + Math.PI * 3) % (Math.PI * 2)) - Math.PI)
110 if (off < 0.35 && d > r * 0.15) continue
111 const color = d > r - 1 ? PAD_DARK : dx + dy < -r * 0.4 ? PAD_LIGHT : PAD
112 p.dot(x, y, color)
113 }
114 }
115 if (hash(i * 13 + 5) < 0.35) {
116 p.disc(cx - r * 0.2, cy - r * 0.2, Math.max(1, r * 0.35), PETAL)
117 p.dot(cx - r * 0.2, cy - r * 0.2, CORE)
118 }
119 }
120}
121
122// A ring every 4s from a seeded spot, 2.5s to fade.
123const drawRipples = (p: Pixels, t: number) => {
124 for (let k = Math.floor(t / 4) - 1; k <= Math.floor(t / 4); k++) {
125 const age = t - (k * 4 + hash(k * 3 + 1) * 1.5)
126 if (age < 0 || age > 2.5) continue
127 const cx = hash(k * 3 + 2) * p.w
128 const cy = hash(k * 3 + 3) * p.h
129 const r = 0.5 + age * (p.w / 18)
130 for (let a = 0; a < Math.PI * 2; a += 0.6 / Math.max(1, r)) {
131 const x = cx + Math.cos(a) * r
132 const y = cy + Math.sin(a) * r
133 p.dot(x, y, mix(RIPPLE, p.get(x, y), age / 2.5))
134 }
135 }
136}
137
138const drawPetals = (p: Pixels, t: number) => {
139 for (let i = 0; i < 7; i++) {
140 const x = (hash(i * 5 + 40) * p.w + t * (0.6 + hash(i) * 0.6)) % p.w
141 const y = (hash(i * 5 + 41) * p.h + Math.sin(t * 0.4 + i) * 2 + p.h) % p.h
142 p.dot(x, y, PETAL)
143 }
144}
145
146export const koi: Scene = {
147 id: 'koi',
148 title: 'koi pond',
149 draw: (p, t) => {
150 for (let y = 0; y < p.h; y++) {
151 for (let x = 0; x < p.w; x++) {
152 const v = Math.sin(x * 0.31 + t * 0.6) + Math.sin(y * 0.27 - t * 0.45) + Math.sin((x - y) * 0.17 + t * 0.25)
153 const k = ((v + 3) / 6) ** 2.2
154 p.px[y * p.w + x] = ramp(WATER, k, x, y)
155 }
156 }
157 for (const f of school(p.w, p.h)) drawFish(p, f, t)
158 drawRipples(p, t)
159 drawPads(p, t)
160 drawPetals(p, t)
161 // Vignette: corners a shade deeper, so the pond reads as a pond.
162 for (let y = 0; y < p.h; y++) {
163 for (let x = 0; x < p.w; x++) {
164 const dx = (x / p.w - 0.5) * 2
165 const dy = (y / p.h - 0.5) * 2
166 if (dx * dx + dy * dy > 1.25) p.px[y * p.w + x] = shade(p.px[y * p.w + x] ?? 0, 0.8)
167 }
168 }
169 },
170}
171hooks/scenes/metropolis.ts 105 lines1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { hash, hash2, mix, Pixels, ramp, type Scene } from '../screen'
5
6const SKY = [0x060918, 0x0b1230, 0x16214a, 0x26336a] as const
7const STAR = 0xb8c4e8
8const MOON = 0xe8e4d2
9const CRATER = 0xc4bfa8
10const FAR = 0x1b2242
11const FAR_WINDOW = 0x56689a
12const MID = 0x0f1428
13const WARM = 0xffd27a
14const COOL = 0xbfe3ff
15const BEACON = 0xff4040
16const TRACK = 0x2a3048
17const TRAIN = 0x3a4566
18const TRAIN_WINDOW = 0xfff0b8
19const WATER = 0x050814
20
21type Tower = { x: number; w: number; top: number; seed: number }
22
23// Seeded by pane width: the same pane draws the same city every frame.
24const skyline = (p: Pixels, layer: number, ground: number, low: number, high: number, minW: number, maxW: number): Tower[] => {
25 const towers: Tower[] = []
26 let x = -2
27 let i = 0
28 while (x < p.w + 2) {
29 const seed = layer * 1000 + i
30 const w = minW + Math.floor(hash(seed * 3 + 1) * (maxW - minW + 1))
31 const tall = p.h * (low + hash(seed * 3 + 2) * (high - low))
32 towers.push({ x, w, top: ground - tall, seed })
33 x += w + (hash(seed * 3 + 3) < 0.3 ? 1 : 0)
34 i += 1
35 }
36
37 return towers
38}
39
40export const metropolis: Scene = {
41 id: 'metropolis',
42 title: 'metropolis',
43 draw: (p, t) => {
44 const ground = Math.round(p.h * 0.8)
45
46 for (let y = 0; y < ground; y++) {
47 for (let x = 0; x < p.w; x++) {
48 const twinkle = hash2(x + Math.floor(t * 0.7) * 3, y) < 0.8
49 p.px[y * p.w + x] = y < p.h * 0.45 && hash2(x, y) < 0.01 && twinkle ? STAR : ramp(SKY, y / ground, x, y)
50 }
51 }
52
53 const moonR = Math.max(2, p.w * 0.07)
54 const moonX = p.w * 0.22
55 const moonY = p.h * 0.16
56 p.disc(moonX, moonY, moonR, MOON)
57 p.dot(moonX - moonR * 0.3, moonY - moonR * 0.2, CRATER)
58 p.dot(moonX + moonR * 0.35, moonY + moonR * 0.3, CRATER)
59
60 for (const tower of skyline(p, 1, ground, 0.25, 0.55, 3, 7)) {
61 p.fill(tower.x, tower.x + tower.w, tower.top, ground, FAR)
62 for (let y = Math.ceil(tower.top) + 1; y < ground - 1; y += 2) {
63 for (let x = tower.x + 1; x < tower.x + tower.w - 1; x += 2) {
64 if (hash2(x * 7 + tower.seed, y) < 0.18) p.dot(x, y, FAR_WINDOW)
65 }
66 }
67 // Beacons on the tallest towers, blinking out of phase.
68 if (ground - tower.top > p.h * 0.45) {
69 p.fill(tower.x + tower.w / 2, tower.x + tower.w / 2 + 1, tower.top - 3, tower.top, FAR)
70 if (Math.floor(t * 1.2 + hash(tower.seed) * 2) % 2 === 0) p.dot(tower.x + tower.w / 2, tower.top - 3, BEACON)
71 }
72 }
73
74 for (const tower of skyline(p, 2, ground, 0.12, 0.38, 4, 9)) {
75 p.fill(tower.x, tower.x + tower.w, tower.top, ground, MID)
76 for (let y = Math.ceil(tower.top) + 1; y < ground - 1; y += 2) {
77 for (let x = tower.x + 1; x < tower.x + tower.w - 1; x += 2) {
78 // Offices switch on and off in their own rhythm, about every 9s.
79 const epoch = Math.floor(t / 9 + hash2(x, y))
80 if (hash2(x * 13 + tower.seed + epoch * 7, y) < 0.32) p.dot(x, y, hash2(x, y + 1) < 0.7 ? WARM : COOL)
81 }
82 }
83 }
84
85 // Elevated line: a three-car train every 18s.
86 const trackY = Math.round(p.h * 0.75)
87 p.fill(0, p.w, trackY, trackY + 1, TRACK)
88 const length = Math.max(12, p.w * 0.6)
89 const trainX = ((t % 18) / 18) * (p.w + length * 1.5) - length
90 p.fill(trainX, trainX + length, trackY - 2, trackY, TRAIN)
91 for (let x = Math.ceil(trainX) + 1; x < trainX + length - 1; x += 2) {
92 if (x % 9 !== 0) p.dot(x, trackY - 2, TRAIN_WINDOW)
93 }
94
95 // The river mirrors the city, broken up by ripples.
96 for (let y = ground; y < p.h; y++) {
97 const mirror = ground - (y - ground) - 1
98 const shift = Math.round(Math.sin(y * 1.3 + t * 2.2) * 1.2)
99 for (let x = 0; x < p.w; x++) {
100 p.px[y * p.w + x] = mix(p.get(x + shift, mirror), WATER, 0.45 + (y - ground) / (p.h - ground) * 0.3)
101 }
102 }
103 },
104}
105hooks/scenes/mountains.ts 105 lines1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { clamp01, dither, fbm1, hash, hash2, mix, Pixels, ramp, type Scene } from '../screen'
5
6const SKY = [0x14122b, 0x2b2350, 0x5a3a6b, 0xb35a5f, 0xf08a5d, 0xf5c27a] as const
7const SUN = 0xffd896
8const SUN_EDGE = 0xffb36b
9const STAR = 0xc9c3e6
10const CLOUD = 0xe89a78
11const MIST = 0x9a7aa8
12const SNOW = 0xe6dcf0
13const BIRD = 0x241a33
14const PINE = 0x120e22
15
16// Far to near. Parallax: nearer ridges scroll faster.
17// Scales are tuned for a narrow pane: wide-pane scales flatten into a band.
18const RIDGES = [
19 { base: 0.62, amp: 0.3, scale: 0.09, speed: 0.5, color: 0x6e5c92, fog: 0.3, seed: 11 },
20 { base: 0.7, amp: 0.2, scale: 0.12, speed: 1.2, color: 0x4b3e6e, fog: 0.22, seed: 23 },
21 { base: 0.8, amp: 0.14, scale: 0.16, speed: 2.6, color: 0x2e2649, fog: 0.12, seed: 37 },
22 { base: 0.9, amp: 0.09, scale: 0.22, speed: 5.5, color: 0x1a1530, fog: 0, seed: 51 },
23] as const
24
25// fbm sits around 0.3-0.7. Stretched to 0-1, or every ridge comes out flat.
26const heightAt = (r: (typeof RIDGES)[number], x: number, t: number): number =>
27 clamp01((fbm1((x + t * r.speed) * r.scale, r.seed) - 0.3) / 0.45)
28
29const ridgeTop = (p: Pixels, r: (typeof RIDGES)[number], x: number, t: number): number =>
30 p.h * r.base - p.h * r.amp * heightAt(r, x, t)
31
32export const mountains: Scene = {
33 id: 'mountains',
34 title: 'mountains at dusk',
35 draw: (p, t) => {
36 const horizon = p.h * 0.6
37 for (let y = 0; y < p.h; y++) {
38 for (let x = 0; x < p.w; x++) {
39 let c = ramp(SKY, y / horizon, x, y)
40 if (y < p.h * 0.35 && hash2(x, y) < 0.012 && hash2(x + Math.floor(t * 1.5), y) < 0.85) c = STAR
41 p.px[y * p.w + x] = c
42 }
43 }
44
45 const sunX = p.w * 0.68
46 const sunY = p.h * 0.4
47 const sunR = Math.max(3, p.w * 0.12)
48 p.disc(sunX, sunY, sunR + 1.5, mix(SKY[4], SUN_EDGE, 0.5))
49 p.disc(sunX, sunY, sunR, SUN)
50
51 // Thin clouds, drifting.
52 for (let i = 0; i < 4; i++) {
53 const len = p.w * (0.25 + hash(i * 3) * 0.3)
54 const y = p.h * (0.12 + hash(i * 3 + 1) * 0.3)
55 const x0 = ((hash(i * 3 + 2) * (p.w + len) + t * (0.8 + i * 0.3)) % (p.w + len)) - len
56 for (let x = Math.floor(x0); x < x0 + len; x++) {
57 const edge = Math.min(x - x0, x0 + len - x) / (len * 0.3)
58 p.dot(x, y, dither(p.get(x, y), CLOUD, clamp01(edge), x, Math.floor(y)))
59 }
60 }
61
62 // Birds: a flock of three every 14s, right to left.
63 const flight = (t % 14) / 9
64 if (flight < 1) {
65 const by = p.h * (0.22 + hash(Math.floor(t / 14)) * 0.12)
66 for (let b = 0; b < 3; b++) {
67 const bx = p.w * (1.1 - flight * 1.3) + b * 3
68 const flap = Math.floor(t * 4 + b) % 2
69 p.dot(bx - 1, by + b - flap, BIRD)
70 p.dot(bx, by + b, BIRD)
71 p.dot(bx + 1, by + b - flap, BIRD)
72 }
73 }
74
75 for (const r of RIDGES) {
76 for (let x = 0; x < p.w; x++) {
77 const v = heightAt(r, x, t)
78 const top = p.h * r.base - p.h * r.amp * v
79 const snow = r === RIDGES[0] ? Math.max(0, v - 0.55) * p.h * r.amp * 0.6 : 0
80 for (let y = Math.max(0, Math.floor(top)); y < p.h; y++) {
81 const depth = clamp01((y - top) / (p.h * 0.35))
82 // The first row sits above `top`, so y - top < 0 there: test snow > 0 first.
83 p.px[y * p.w + x] = snow > 0 && y - top < snow ? SNOW : dither(r.color, MIST, depth * r.fog, x, y)
84 }
85 }
86 }
87
88 // Pines on the nearest ridge, scrolling with it.
89 const near = RIDGES[3]
90 const spacing = Math.max(3, Math.round(p.w / 14))
91 const offset = (t * near.speed) % spacing
92 for (let i = -1; i <= p.w / spacing + 1; i++) {
93 const x = i * spacing - offset
94 const world = Math.round((x + t * near.speed) / spacing)
95 if (hash(world * 17) < 0.35) continue
96 const tall = 3 + Math.round(hash(world * 19) * p.h * 0.08)
97 const base = ridgeTop(p, near, x, t) + 1
98 for (let dy = 0; dy < tall; dy++) {
99 const half = (dy / tall) * Math.max(1, tall * 0.35)
100 p.fill(x - half, x + half + 1, base - tall + dy, base - tall + dy + 1, PINE)
101 }
102 }
103 },
104}
105hooks/screen.ts 182 lines1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4// Fork of plugins/arcade/hooks/screen.ts. Plugins install one by one, so
5// nothing is shared across plugin folders. No score bar here: full bleed.
6
7const UPPER_HALF = 0x2580
8
9export const clamp01 = (n: number): number => Math.max(0, Math.min(1, n))
10export const frac = (n: number): number => n - Math.floor(n)
11
12export const mix = (a: number, b: number, t: number): number => {
13 const k = clamp01(t)
14 const channel = (shift: number) => {
15 const x = (a >> shift) & 0xff
16 const y = (b >> shift) & 0xff
17
18 return Math.round(x + (y - x) * k) << shift
19 }
20
21 return channel(16) | channel(8) | channel(0)
22}
23
24export const shade = (c: number, f: number): number => mix(0, c, f)
25
26// MurmurHash3 fmix32 (Austin Appleby, public domain).
27export const hash = (n: number): number => {
28 let t = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b)
29 t ^= t >>> 13
30 t = Math.imul(t, 0xc2b2ae35)
31 t ^= t >>> 16
32
33 return (t >>> 0) / 4294967296
34}
35
36export const hash2 = (x: number, y: number): number => hash(Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663))
37
38// Value noise, smoothstep between integer lattice points.
39export const noise1 = (x: number, seed: number): number => {
40 const i = Math.floor(x)
41 const f = x - i
42 const a = hash(i * 7919 + seed)
43 const b = hash((i + 1) * 7919 + seed)
44
45 return a + (b - a) * f * f * (3 - 2 * f)
46}
47
48export const fbm1 = (x: number, seed: number, octaves = 4): number => {
49 let sum = 0
50 let amp = 0.5
51 let norm = 0
52 for (let o = 0; o < octaves; o++) {
53 sum += noise1(x * 2 ** o, seed + o * 101) * amp
54 norm += amp
55 amp *= 0.5
56 }
57
58 return sum / norm
59}
60
61const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(v => (v + 0.5) / 16)
62
63// Ordered 4x4 dither between two colours: the 8-bit look for gradients and fog.
64export const dither = (a: number, b: number, t: number, x: number, y: number): number =>
65 t > (BAYER[(y & 3) * 4 + (x & 3)] ?? 0.5) ? b : a
66
67// Dithered ramp over palette stops, t in [0, 1].
68export const ramp = (stops: readonly number[], t: number, x: number, y: number): number => {
69 const at = clamp01(t) * (stops.length - 1)
70 const i = Math.min(stops.length - 2, Math.floor(at))
71
72 return dither(stops[i] ?? 0, stops[i + 1] ?? 0, at - i, x, y)
73}
74
75export class Pixels {
76 readonly w: number
77 readonly h: number
78 readonly px: Uint32Array
79
80 constructor(
81 readonly columns: number,
82 readonly rows: number,
83 ) {
84 this.w = columns
85 this.h = rows * 2
86 this.px = new Uint32Array(this.w * this.h)
87 }
88
89 get(x: number, y: number): number {
90 const px = Math.max(0, Math.min(this.w - 1, Math.floor(x)))
91 const py = Math.max(0, Math.min(this.h - 1, Math.floor(y)))
92
93 return this.px[py * this.w + px] ?? 0
94 }
95
96 dot(x: number, y: number, color: number): void {
97 const px = Math.floor(x)
98 const py = Math.floor(y)
99 if (px < 0 || px >= this.w || py < 0 || py >= this.h) return
100 this.px[py * this.w + px] = color
101 }
102
103 // Min 1px each way. Far objects shrink below a pixel and would vanish.
104 fill(x0: number, x1: number, y0: number, y1: number, color: number): void {
105 const left = Math.max(0, Math.round(x0))
106 const right = Math.min(this.w, Math.max(left + 1, Math.round(x1)))
107 const top = Math.max(0, Math.round(y0))
108 const bottom = Math.min(this.h, Math.max(top + 1, Math.round(y1)))
109 for (let y = top; y < bottom; y++) this.px.fill(color, y * this.w + left, y * this.w + right)
110 }
111
112 disc(cx: number, cy: number, r: number, color: number): void {
113 const rr = Math.max(0.5, r)
114 for (let y = Math.floor(cy - rr); y <= Math.ceil(cy + rr); y++) {
115 for (let x = Math.floor(cx - rr); x <= Math.ceil(cx + rr); x++) {
116 const dx = x + 0.5 - cx
117 const dy = y + 0.5 - cy
118 if (dx * dx + dy * dy <= rr * rr) this.dot(x, y, color)
119 }
120 }
121 }
122
123 cells(): Uint32Array {
124 const out = new Uint32Array(this.columns * this.rows * 3)
125 for (let r = 0; r < this.rows; r++) {
126 for (let x = 0; x < this.columns; x++) {
127 const at = (r * this.columns + x) * 3
128 out[at] = UPPER_HALF
129 out[at + 1] = this.px[2 * r * this.w + x] ?? 0
130 out[at + 2] = this.px[(2 * r + 1) * this.w + x] ?? 0
131 }
132 }
133
134 return out
135 }
136}
137
138// ASCII only. Raster refuses anything not width 1. Background comes from the
139// cell's lower pixel so the label sits on the scene, not on a box.
140export const label = (cells: Uint32Array, columns: number, row: number, col: number, text: string, fg: number): void => {
141 for (let i = 0; i < text.length; i++) {
142 const x = col + i
143 const at = (row * columns + x) * 3
144 if (x < 0 || x >= columns || at + 2 >= cells.length) continue
145 cells[at] = text.charCodeAt(i)
146 cells[at + 1] = fg
147 }
148}
149
150const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
151
152// Native toBase64 in the mod sandbox (2.1.287). Manual path is for Node 20
153// previews.
154export const base64 = (bytes: Uint8Array): string => {
155 const native = (bytes as unknown as { toBase64?: () => string }).toBase64
156 if (typeof native === 'function') return native.call(bytes)
157
158 const out: string[] = []
159 let i = 0
160 for (; i + 2 < bytes.length; i += 3) {
161 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
162 out.push(B64.charAt((n >> 18) & 63), B64.charAt((n >> 12) & 63), B64.charAt((n >> 6) & 63), B64.charAt(n & 63))
163 }
164 const rest = bytes.length - i
165 if (rest > 0) {
166 const n = ((bytes[i] ?? 0) << 16) | (rest > 1 ? (bytes[i + 1] ?? 0) << 8 : 0)
167 out.push(B64.charAt((n >> 18) & 63), B64.charAt((n >> 12) & 63), rest > 1 ? B64.charAt((n >> 6) & 63) : '=', '=')
168 }
169
170 return out.join('')
171}
172
173export const encodeCells = (cells: Uint32Array): string =>
174 base64(new Uint8Array(cells.buffer, cells.byteOffset, cells.byteLength))
175
176export type Scene = {
177 id: string
178 title: string
179 // t in seconds since the pane opened.
180 draw(p: Pixels, t: number): void
181}
182types/index.d.ts 12 lines1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4// Scene id on screen.
5export type AmbientScene = string
6
7declare module 'claude-code' {
8 interface PluginState {
9 ambient: { scene: AmbientScene }
10 }
11}
12