The boss and a posse of subagent creatures walk back and forth above the prompt while Claude Code works

A Claude Code mod that puts a little parade above your prompt while Claude works. The boss walks back and forth, and every subagent Claude starts joins in as a creature of its own.

The whole lineup, as text:
█▜██▜█ ▝▖▗▘ ▗▄██▄▖ ▀▙▟▀ ▙▟▙▟ ▝▀▀▘
▀██████▀ █▜▛█ █▜▛█ █▜▛█ █▜▛█ █▜▛█
▜▀▛▜▀▛ ▀▛▛▜▜▀ ▀▛▛▜▜▀ ▀▛▛▜▜▀ ▀▛▛▜▜▀ ▀▛▛▜▜▀
▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
From left to right: the boss, the biggest of them, then the creatures for Explore, Plan, general-purpose, claude and fork subagents. The boss is drawn after Claude Code's mascot. Every creature has dark eyes and a darker side at its back, which flips when it turns; when one stops to face you, it stands square on.
| Subagent type | Headwear | Body | Headwear color |
|---|---|---|---|
| main turn (the boss) | none; bigger than everyone else | orange, with a darker side at its back and dark eyes | — |
| Explore | antennae | blue | light gray |
| Plan | top hat | green | red |
| general-purpose | propeller cap | purple | cyan |
claude | crown | pink | gold |
| fork | halo | peach | pale yellow |
| any other type | ears, horns, a mohawk or a sprout | picked from the type's name | picked from the type's name |
A custom type always gets the same look, never headwear a built-in type wears, and never a hat the color of its body.
No two creatures on the strip wear the same hat color. When a hat color is already taken, often by another agent of the same type, the newcomer keeps its body and headwear but wears a spare color, and keeps it until it leaves.
+N more./posse switches the posse on or off. The setting is remembered across sessions./posse on and /posse off pick one./posse legend shows which creature is which./posse demo brings the whole posse out for 20 seconds, even while Claude isn't working: the boss, a creature for each kind of agent, and a second Explore in a spare hat, with the legend naming each one. Each walks at a different pace, as agents doing more or less work would. It starts no agents and uses no tokens. Run it again to end it sooner.The command runs right away, even while Claude is working and the posse is walking.
In the terminal, the creatures are drawn in text characters and repainted 20 times a second. In the desktop app, they're one SVG that animates itself, so nothing is sent per frame. It's redrawn when a creature arrives or leaves, when a creature's pace changes by a tenth or more, when a turn starts or ends, and when the window is resized. A resize changes how far each creature has to walk, so the creatures jump to new spots.
Clone the repo:
git clone https://github.com/RyanEmslie/prompt-posse.git
The steps below write the folder this makes as /path/to/prompt-posse. Use its absolute path instead, such as /Users/you/code/prompt-posse.
To try it in one session, point Claude Code at the folder:
claude --plugin-dir /path/to/prompt-posse
To load it in every session, add the folder's absolute path to the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/prompt-posse"
}
}
If CLAUDE_CODE_PLUGIN_DIRS already names other plugin folders, keep them and add this one to the list, separated by : (; on Windows):
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/another-plugin:/path/to/prompt-posse"
Sessions the desktop app starts read the same setting.
Interactive terminal sessions watch the folder, so edits reload in sessions that are already open. Desktop sessions only do that when CLAUDE_CODE_PLUGIN_DIR_WATCH is also set to 1. Otherwise edits show up in new sessions.
Start a new session and run /posse demo. The whole posse walks above the prompt box for 20 seconds. Or send any prompt: the boss walks while Claude works, and leaves when the turn ends. /posse legend lists who's who. If nothing shows, check that the strip has room: it needs at least 4 free rows above the prompt and 8 columns.
Pull the latest version into the folder:
git -C /path/to/prompt-posse pull
Terminal sessions that watch the folder pick it up on their own. Other sessions show it once restarted.
To hide the posse but keep it installed, run /posse off. It stays off in new sessions until you run /posse on.
To remove it, take its path out of CLAUDE_CODE_PLUGIN_DIRS (or stop passing --plugin-dir), start a new session, and delete the folder.
TICK_MS in hooks/walker.ts. Lower is faster.hooks/pace.ts sets how an agent's work moves its creature's pace: WINDOW_MS is how far back its output tokens count, FULL_RATE the tokens a second for the fastest pace, and MIN_PACE and MAX_PACE the slowest and fastest pace as a share of the creature's own speed.hooks/looks.ts. Headwear is two text rows of 12 characters, with # for a filled pixel. Colors are 0xRRGGBB values.hooks/sprite.ts, drawn the same way, with % for a pixel of the shaded side and o for an eye. The shade is a darker version of the body color unless the look sets shadeColor, and the eyes are near black unless it sets eyeColor.hooks/svg.ts turns the same sprites into the animated SVG.claude plugin validate .
claude plugin test .
What changed in each version is in CHANGELOG.md.
[-] collapse button sits on the strip's top row, so near the right edge it briefly covers a creature's headwear.MIT. See LICENSE.
This is an unofficial fan project, not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic.
hooks/register.tsx 657 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { hex, legendRow } from './legend'
4import { ease, paceFor, rateAt } from './pace'
5import type { Work } from './pace'
6import { BOSS, hash, hatFor, legendText } from './looks'
7import { BOSS_WIDTH, SPRITE_ROWS, SPRITE_WIDTH, rasterCells } from './sprite'
8import type { Look } from './sprite'
9import { posseSvg, strideAt } from './svg'
10import { TICK_MS, createWalker, freeSpot, lastX, pose, step } from './walker'
11import type { Walker } from './walker'
12
13const KEY = 'posse'
14const GROUND = '▔'
15// The columns the engine keeps at the band's right for its `[-]`, beside the
16// first row, the creatures' headwear row; the creatures and the ground reach
17// under the mark's columns to span the prompt.
18const MARK_COLUMNS = 5
19const BAND_ROWS = SPRITE_ROWS + 1
20// The legend takes a row of its own under the ground, while subagents walk
21// and there's room for it.
22const LEGEND_ROWS = 1
23// How often, in ticks, the session's agents are listed again.
24export const LIST_EVERY = 10
25const ACTIVE = new Set(['pending', 'running', 'waiting'])
26// How much a desktop creature's pace must change before its SVG is drawn
27// again at the new one, so a drift of its pace doesn't redraw it each time.
28const REDRAW_CHANGE = 0.1
29// A teammate in a terminal pane of its own reports running by what it last
30// wrote, which a pane that died leaves standing; after this long its
31// creature is retired until the teammate goes idle or ends.
32export const PANE_STALE_MS = 10 * 60_000
33// Checks this far apart may have missed a retired teammate going idle, so
34// it gets a fresh start.
35const UNSEEN_MS = TICK_MS * LIST_EVERY * 4
36// Times a failed listing is retried while nothing walks to keep retrying it.
37export const IDLE_RETRIES = 3
38// Where `/posse` remembers being switched off, across sessions.
39const STORE_KEY = 'isOn'
40const OPTIONS =
41 '`/posse` to switch the posse on or off, `/posse on` or `/posse off` to pick one, `/posse legend` to see which creature is which, or `/posse demo` to watch them walk.'
42// How long `/posse demo` walks, and who walks in it: one of each kind of
43// agent and a second Explore, to show the spare hat a newcomer wears.
44export const DEMO_MS = 20_000
45// Each walks at a pace of its own, as agents doing more or less work would.
46const DEMO = [
47 ['Explore', 'Explore', 1.5],
48 ['Plan', 'Plan', 0.6],
49 ['general-purpose', 'general-purpose', 1.2],
50 ['claude', 'claude', 0.9],
51 ['fork', 'fork', 0.5],
52 ['Explore', 'a second Explore', 1.3],
53 ['my-agent', 'any other type', 1],
54] as const
55
56type Creature = {
57 walker: Walker
58 look: Look
59 /** The kind of agent it walks for (`Explore`, `Plan`, ...); the boss's is empty. */
60 type: string
61 /** What its agent is doing, as the Agent call described it; the boss's is empty. */
62 description: string
63 /** When it started walking: the desktop's animation runs from it. */
64 since: number
65 /** Its own speed, which the desktop walks it at. */
66 baseSpeed: number
67 /** In the terminal, the share of its own speed its agent's work sets it walking at. */
68 pace: number
69 /** A demo creature's pace, as there is no agent's work to set it. */
70 demoPace?: number
71 /** Where the desktop's SVG walks it from: the time it set out from `walker.x`, and how fast. */
72 drawnAt: number
73 drawnSpeed: number
74}
75
76type Walk = {
77 boss: Creature
78 /** A creature for each active subagent, by agent id; background ones keep walking after the main turn ends. */
79 subagents: Map<string, Creature>
80 /** The tokens each agent's model wrote lately, by agent id, which set its creature's pace. */
81 work: Map<string, Work[]>
82 /** The creatures `/posse demo` brought out, until its time is up. */
83 demo: Map<string, Creature>
84 demoTimer: Timer | null
85 isMainTurn: boolean
86 /** Whether `/posse` has it switched on. */
87 isOn: boolean
88 /** Listings started so far. */
89 listings: number
90 /** The newest listing applied: an older one that answers later is ignored. */
91 applied: number
92 /** Failed listings in a row. */
93 failures: number
94 /** When the last listing that read the time was applied. */
95 listedAt: number
96 /** Pane teammates whose creatures were retired, until they stop running. */
97 stale: Set<string>
98 /** Whether this load has caught up with a turn and agents already under way. */
99 isSynced: boolean
100 /** The band while it shows creatures, so the timer knows where to repaint. */
101 band: {
102 requestId: string
103 columns: number
104 hasBoss: boolean
105 surface: 'terminal' | 'desktop'
106 } | null
107 timer: Timer | null
108 /** How often the timer fires: every tick on the terminal, only to list agents on the desktop. */
109 timerMs: number
110 ticks: number
111 /** The walk's own milliseconds, which run as the timer fires; agents' work is timed by them. */
112 clockMs: number
113}
114
115const shown = (walk: Walk, hasBoss: boolean) => [
116 ...(hasBoss ? [walk.boss] : []),
117 ...walk.subagents.values(),
118 ...walk.demo.values(),
119]
120
121/** Whether anyone besides the boss is out walking. */
122const hasCompany = (walk: Walk) => walk.subagents.size > 0 || walk.demo.size > 0
123
124const frame = (columns: number, creatures: readonly Creature[]) =>
125 rasterCells(
126 columns,
127 creatures.map(({ walker, look }) => ({ x: walker.x, pose: pose(walker), look })),
128 )
129
130/** What the desktop's SVG shows, for a reader that can't see it. */
131function describe(hasBoss: boolean, types: readonly string[]) {
132 const agents = `${types.length} agent${types.length === 1 ? '' : 's'}`
133 const who = [hasBoss ? 'The boss' : '', types.length > 0 ? agents : '']
134 .filter(Boolean)
135 .join(' and ')
136 const kinds =
137 types.length > 1
138 ? `${types.slice(0, -1).join(', ')} and ${types.at(-1)}`
139 : (types[0] ?? '')
140
141 return `${who} walking above the prompt${kinds === '' ? '' : `: ${kinds}`}`
142}
143
144/** An error's message, whatever was thrown. */
145const reasonOf = (error: unknown) =>
146 error instanceof Error ? error.message : typeof error === 'string' ? error : JSON.stringify(error)
147
148function stop(walk: Walk) {
149 walk.timer?.cancel()
150 walk.timer = null
151}
152
153// The desktop's SVG animates itself, so its timer only wakes to list agents.
154const periodFor = (walk: Walk) =>
155 walk.band?.surface === 'desktop' ? TICK_MS * LIST_EVERY : TICK_MS
156
157/** Starts the timer, or restarts it when the band moved to a surface that wants another pace. */
158function wake($: EngineInterface, walk: Walk) {
159 const ms = periodFor(walk)
160 if (!walk.isOn || (walk.timer !== null && walk.timerMs === ms)) {
161 return
162 }
163 walk.timer?.cancel()
164 walk.timerMs = ms
165 walk.timer = $.clock.every(ms, () => tick($, walk))
166}
167
168/**
169 * Lists the agents again. A failure is retried on the timer while anything
170 * walks, and otherwise a few times on its own before giving up.
171 */
172function relist($: EngineInterface, walk: Walk) {
173 void list($, walk).then(
174 () => {
175 walk.failures = 0
176 },
177 (error: unknown) => {
178 walk.failures += 1
179 if (walk.failures === 1) {
180 $.ui.log(
181 `prompt-posse: couldn't list the session's agents (${reasonOf(error)}); trying again in half a second`,
182 { to: 'debug' },
183 )
184 }
185 if (walk.isMainTurn || hasCompany(walk)) {
186 wake($, walk)
187 } else if (walk.failures <= IDLE_RETRIES) {
188 $.clock.after(TICK_MS * LIST_EVERY, () => relist($, walk))
189 }
190 },
191 )
192}
193
194/** Brings the creatures in line with the session's active agents. */
195async function list($: EngineInterface, walk: Walk) {
196 const listing = ++walk.listings
197 const agents = await $.agent.list()
198 const active = new Map(
199 agents.filter(agent => ACTIVE.has(agent.status)).map(agent => [agent.id, agent]),
200 )
201 const isPane = (agent: { id: string; teammateId?: string }) => agent.id === agent.teammateId
202 const needsTime =
203 walk.stale.size > 0 ||
204 [...active.values()].some(agent => !walk.subagents.has(agent.id) || isPane(agent))
205 const now = needsTime ? await $.clock.now() : 0
206 // A listing older than one already applied has nothing newer to say.
207 if (listing <= walk.applied) {
208 return
209 }
210 walk.applied = listing
211
212 if (needsTime) {
213 if (now - walk.listedAt > UNSEEN_MS) {
214 walk.stale.clear()
215 }
216 walk.listedAt = now
217 }
218 for (const id of walk.stale) {
219 if (!active.has(id)) {
220 walk.stale.delete(id)
221 }
222 }
223 for (const agent of active.values()) {
224 const creature = walk.subagents.get(agent.id)
225 if (isPane(agent) && creature !== undefined && now - creature.since > PANE_STALE_MS) {
226 walk.stale.add(agent.id)
227 }
228 }
229 for (const id of walk.stale) {
230 active.delete(id)
231 }
232
233 let isChanged = false
234 for (const id of walk.subagents.keys()) {
235 if (!active.has(id)) {
236 walk.subagents.delete(id)
237 isChanged = true
238 }
239 }
240 for (const id of walk.work.keys()) {
241 if (!active.has(id)) {
242 walk.work.delete(id)
243 }
244 }
245
246 for (const agent of active.values()) {
247 if (walk.subagents.has(agent.id)) {
248 continue
249 }
250 walk.subagents.set(
251 agent.id,
252 join(walk, agent.id, agent.type, agent.description.trim() || agent.type, now),
253 )
254 isChanged = true
255 }
256
257 if (isChanged) {
258 $.ui.invalidate('ui.render')
259 }
260 if (hasCompany(walk)) {
261 wake($, walk)
262 } else if (!walk.isMainTurn) {
263 stop(walk)
264 }
265}
266
267/** A newcomer's creature, in the freest spot and a hat color no one on the strip wears. */
268function join(walk: Walk, id: string, type: string, description: string, now: number): Creature {
269 const columns = walk.band?.columns ?? 80
270 const creatures = shown(walk, walk.band?.hasBoss ?? walk.isMainTurn)
271 const x = freeSpot(columns, SPRITE_WIDTH, creatures.map(c => c.walker))
272 const heading = x < lastX(columns) / 2 ? 1 : -1
273 const speed = 0.7 + (hash(id) % 6) / 10
274 const pace = paceFor(rateAt(walk.work.get(id) ?? [], walk.clockMs))
275 const worn = creatures.filter(c => c !== walk.boss).map(({ look }) => look.hatColor ?? look.color)
276
277 return {
278 walker: createWalker(x, heading, speed, SPRITE_WIDTH),
279 look: hatFor(type, worn),
280 type,
281 description,
282 since: now,
283 baseSpeed: speed,
284 pace,
285 drawnAt: now,
286 drawnSpeed: speed * pace,
287 }
288}
289
290/** Moves each subagent's creature `ms` along toward the pace its agent's work calls for. */
291function setPaces(walk: Walk, ms: number) {
292 const set = (creature: Creature, target: number) => {
293 creature.pace = ease(creature.pace, target, ms)
294 creature.walker.speed = creature.baseSpeed * creature.pace
295 }
296 for (const [id, creature] of walk.subagents) {
297 set(creature, paceFor(rateAt(walk.work.get(id) ?? [], walk.clockMs)))
298 }
299 for (const creature of walk.demo.values()) {
300 set(creature, creature.demoPace ?? 1)
301 }
302}
303
304/**
305 * On the desktop, sets out again at its new pace each creature whose pace has
306 * moved far enough from the one its SVG walks at: from where the SVG has it
307 * now, so it doesn't jump. One pausing at an edge waits until it walks on.
308 */
309async function redrawPaces($: EngineInterface, walk: Walk) {
310 const columns = walk.band?.columns
311 if (columns === undefined) {
312 return
313 }
314 const now = await $.clock.now()
315 let isChanged = false
316 for (const creature of [...walk.subagents.values(), ...walk.demo.values()]) {
317 const speed = creature.baseSpeed * creature.pace
318 if (Math.abs(speed - creature.drawnSpeed) <= creature.drawnSpeed * REDRAW_CHANGE) {
319 continue
320 }
321 const { walker, look } = creature
322 const at = strideAt(columns, {
323 x: walker.x,
324 heading: walker.heading,
325 speed: creature.drawnSpeed,
326 elapsedMs: now - creature.drawnAt,
327 look,
328 })
329 if (at.isPausing) {
330 continue
331 }
332 walker.x = Math.round(at.x)
333 walker.heading = at.heading
334 creature.drawnAt = now
335 creature.drawnSpeed = speed
336 isChanged = true
337 }
338 if (isChanged) {
339 $.ui.invalidate('ui.render')
340 }
341}
342
343/** Sends the demo's creatures home, and stops the walk if no one else is out. */
344function endDemo($: EngineInterface, walk: Walk) {
345 walk.demoTimer?.cancel()
346 walk.demoTimer = null
347 walk.demo.clear()
348 $.ui.invalidate('ui.render')
349 if (!walk.isMainTurn && !hasCompany(walk)) {
350 stop(walk)
351 }
352}
353
354function tick($: EngineInterface, walk: Walk) {
355 walk.ticks += 1
356 walk.clockMs += walk.timerMs
357 if (walk.timerMs !== TICK_MS || walk.ticks % LIST_EVERY === 0) {
358 relist($, walk)
359 }
360 if (walk.band === null) {
361 return
362 }
363 setPaces(walk, walk.timerMs)
364 // The desktop's SVG animates itself, drawn again only for a new pace; the
365 // terminal is repainted each tick.
366 if (walk.band.surface !== 'terminal') {
367 void redrawPaces($, walk)
368 return
369 }
370
371 const { requestId, columns, hasBoss } = walk.band
372 const creatures = shown(walk, hasBoss)
373 for (const creature of creatures) {
374 const others = creatures.filter(c => c !== creature).map(c => c.walker)
375 step(creature.walker, columns, others)
376 }
377 void $.ui.blit({ requestId, key: KEY, cells: frame(columns, creatures) })
378}
379
380export const register: Register = on => {
381 const walk: Walk = {
382 boss: {
383 walker: createWalker(0, 1, 1, BOSS_WIDTH),
384 look: BOSS,
385 type: '',
386 description: '',
387 since: 0,
388 baseSpeed: 1,
389 pace: 1,
390 drawnAt: 0,
391 drawnSpeed: 1,
392 },
393 subagents: new Map(),
394 work: new Map(),
395 demo: new Map(),
396 demoTimer: null,
397 isMainTurn: false,
398 isOn: true,
399 listings: 0,
400 applied: 0,
401 failures: 0,
402 listedAt: 0,
403 stale: new Set(),
404 isSynced: false,
405 band: null,
406 timer: null,
407 timerMs: TICK_MS,
408 ticks: 0,
409 clockMs: 0,
410 }
411
412 on('session.start', async ($, e, next) => {
413 walk.isOn = (await $.store.get(STORE_KEY)) !== false
414 await $.command.register({
415 name: 'posse',
416 description: "Show or hide the creatures that walk above the prompt while Claude and its agents work, or see who's who",
417 argumentHint: '[on|off|legend|demo]',
418 immediate: true,
419 })
420
421 return next(e)
422 })
423
424 on('command.run', { command: 'posse' }, async ($, e) => {
425 const arg = e.args.trim().toLowerCase()
426 if (arg === 'legend') {
427 return { text: legendText() }
428 }
429 if (arg === 'demo') {
430 if (!walk.isOn) {
431 return { text: 'The posse is off. Run `/posse on` first, then `/posse demo`.' }
432 }
433 if (walk.demo.size > 0) {
434 endDemo($, walk)
435 return { text: 'The demo is over.' }
436 }
437 const now = await $.clock.now()
438 for (const [i, [type, description, pace]] of DEMO.entries()) {
439 const id = `demo-${i}`
440 const creature = join(walk, id, type, description, now)
441 walk.demo.set(id, {
442 ...creature,
443 pace,
444 demoPace: pace,
445 drawnSpeed: creature.baseSpeed * pace,
446 })
447 }
448 walk.demoTimer = $.clock.after(DEMO_MS, () => endDemo($, walk))
449 wake($, walk)
450 $.ui.invalidate('ui.render')
451 return {
452 text: `The posse walks for ${DEMO_MS / 1000} seconds: the boss, a creature for each kind of agent, and a second Explore in a spare hat, each at a different pace as busier or idler agents would walk. The legend under them names each one. Run \`/posse demo\` again to end it sooner.`,
453 }
454 }
455 if (arg !== '' && arg !== 'on' && arg !== 'off') {
456 return { text: `\`${e.args.trim()}\` isn't a /posse option. Use ${OPTIONS}` }
457 }
458
459 walk.isOn = arg === '' ? !walk.isOn : arg === 'on'
460 await $.store.set(STORE_KEY, walk.isOn)
461 if (walk.isOn) {
462 if (walk.isMainTurn) {
463 wake($, walk)
464 }
465 relist($, walk)
466 } else {
467 walk.demoTimer?.cancel()
468 walk.demoTimer = null
469 walk.demo.clear()
470 stop(walk)
471 }
472 $.ui.invalidate('ui.render')
473
474 return {
475 text: walk.isOn
476 ? 'The posse is on. The boss walks above the prompt while Claude works, with a creature for each agent it starts. It stays on in new sessions.'
477 : 'The posse is off. It stays off in new sessions. Run `/posse` to bring it back.',
478 }
479 })
480
481 on('turn.start', async ($, e, next) => {
482 walk.isMainTurn = true
483 walk.boss.since = await $.clock.now()
484 walk.boss.drawnAt = walk.boss.since
485 wake($, walk)
486 $.ui.invalidate('ui.render')
487
488 return next(e)
489 })
490
491 on('turn.complete', ($, e, next) => {
492 // A subagent's run ends with a turn.complete of its own: its creature
493 // leaves once the listing no longer shows it active.
494 if (e.agentId === undefined) {
495 walk.isMainTurn = false
496 if (!hasCompany(walk)) {
497 stop(walk)
498 }
499 $.ui.invalidate('ui.render')
500 }
501 relist($, walk)
502
503 return next(e)
504 })
505
506 // Each response a subagent's model gives adds its output tokens to that
507 // agent's work, which sets how fast its creature walks.
508 on('turn.step', async function* ($, e, next) {
509 const result = yield* next(e)
510 if (e.agentId !== undefined && result.usage !== null && result.usage.output_tokens > 0) {
511 const work = walk.work.get(e.agentId) ?? []
512 work.push({ at: walk.clockMs, tokens: result.usage.output_tokens })
513 walk.work.set(e.agentId, work)
514 }
515
516 return result
517 })
518
519 on('agent.spawn', async ($, e, next) => {
520 const result = await next(e)
521 if (result.agentId !== undefined) {
522 relist($, walk)
523 }
524
525 return result
526 })
527
528 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
529 // Only the terminal's band reaches under the engine's columns.
530 const reach = e.surface === 'terminal' ? MARK_COLUMNS : 0
531 const columns = Math.min(e.props.bodyColumns + reach, 512)
532 // The boss leads the posse: it walks during the turn and for as long
533 // as any subagent is still out.
534 const hasBoss = e.props.isWorking || hasCompany(walk)
535
536 if ((e.surface !== 'terminal' && e.surface !== 'desktop') || !walk.isOn) {
537 walk.band = null
538 return next(e)
539 }
540
541 // A load (a hot reload among them) can come in mid-turn, or while
542 // background subagents walk: the first drawing catches up with both.
543 if (!walk.isSynced) {
544 walk.isSynced = true
545 if (e.props.isWorking && !walk.isMainTurn) {
546 walk.isMainTurn = true
547 walk.boss.since = await $.clock.now()
548 walk.boss.drawnAt = walk.boss.since
549 wake($, walk)
550 }
551 relist($, walk)
552 }
553
554 if (
555 !hasBoss ||
556 e.props.hasSurvey ||
557 e.props.maxRows < BAND_ROWS ||
558 columns < BOSS_WIDTH / 2
559 ) {
560 walk.band = null
561 return next(e)
562 }
563
564 const creatures = shown(walk, hasBoss)
565 for (const { walker } of creatures) {
566 walker.x = Math.min(walker.x, lastX(columns, walker.width))
567 }
568 walk.band = { requestId: e.requestId, columns, hasBoss, surface: e.surface }
569 if (walk.timer !== null) {
570 wake($, walk)
571 }
572
573 // The subagents' marks and tasks, in the order they joined.
574 const entries = [...walk.subagents.values(), ...walk.demo.values()].map(({ look, description }) => ({
575 text: description,
576 mark: look.hatColor ?? look.color,
577 color: look.color,
578 }))
579 const hasLegend = entries.length > 0 && e.props.maxRows >= BAND_ROWS + LEGEND_ROWS
580 const segments = hasLegend ? legendRow(columns, entries) : []
581
582 if (e.surface === 'desktop') {
583 const now = await $.clock.now()
584 const strides = creatures.map(({ walker, look, drawnAt, drawnSpeed }) => ({
585 x: walker.x,
586 heading: walker.heading,
587 speed: drawnSpeed,
588 elapsedMs: now - drawnAt,
589 look,
590 }))
591 const { Box, Svg, Text } = $.ui.resolve(e)
592 const svg = (
593 <Svg
594 source={posseSvg(columns, strides)}
595 alt={describe(hasBoss, [...walk.subagents.values(), ...walk.demo.values()].map(c => c.type))}
596 isInteractive
597 />
598 )
599 if (!hasLegend) {
600 return svg
601 }
602
603 return (
604 <Box flexDirection="column">
605 {svg}
606 <Text wrap="truncate">
607 {segments.map(({ text, color, isDim }) => (
608 <Text color={color === undefined ? undefined : hex(color)} dimColor={isDim}>
609 {text}
610 </Text>
611 ))}
612 </Text>
613 </Box>
614 )
615 }
616
617 const { Box, Raster, Text } = $.ui.resolve(e)
618
619 // Each row is placed absolutely so it can reach under the engine's
620 // columns; the ground sits at the top of its row, so feet touch it.
621 return (
622 <Box flexDirection="column">
623 <Box height={SPRITE_ROWS}>
624 <Box position="absolute" left={0} width={columns}>
625 <Raster
626 key={KEY}
627 columns={columns}
628 rows={SPRITE_ROWS}
629 cells={frame(columns, creatures)}
630 />
631 </Box>
632 </Box>
633 <Box height={1}>
634 <Box position="absolute" left={0} width={columns}>
635 <Text dimColor wrap="truncate">
636 {GROUND.repeat(columns)}
637 </Text>
638 </Box>
639 </Box>
640 {hasLegend && (
641 <Box height={LEGEND_ROWS}>
642 <Box position="absolute" left={0} width={columns}>
643 <Text wrap="truncate">
644 {segments.map(({ text, color, isDim }) => (
645 <Text color={color === undefined ? undefined : hex(color)} dimColor={isDim}>
646 {text}
647 </Text>
648 ))}
649 </Text>
650 </Box>
651 </Box>
652 )}
653 </Box>
654 )
655 })
656}
657hooks/legend.ts 87 lines1// The row under the ground that says what each creature walks for: a mark in
2// its hat color, then its agent's task in its body color, in the order they
3// joined. Long tasks are cut short, the longest first, so more of them fit;
4// what doesn't fit even then is counted at the end.
5
6export type Entry = {
7 /** The agent's task, as the Agent call described it. */
8 text: string
9 /** The creature's hat color. */
10 mark: number
11 /** The creature's body color. */
12 color: number
13}
14
15/** A run of the row's text, in one color or dim. */
16export type Segment = { text: string; color?: number; isDim?: boolean }
17
18const GAP = ' '
19const MARK = '■'
20
21// The fewest characters a task is cut to, so it can still be told apart.
22const MIN_CHARS = 10
23
24const more = (count: number) => `+${count} more`
25
26const cut = (text: string, chars: number) =>
27 text.length <= chars ? text : `${text.slice(0, chars - 1)}…`
28
29const widthAt = (entries: readonly Entry[], chars: number) =>
30 entries.reduce((sum, { text }) => sum + MARK.length + 1 + Math.min(text.length, chars), 0) +
31 GAP.length * Math.max(0, entries.length - 1)
32
33/**
34 * The legend's row, `columns` wide at most. Tasks are cut to the most
35 * characters at which every entry fits, but no fewer than `MIN_CHARS`; what
36 * still doesn't fit is counted as `+N more`.
37 */
38export function legendRow(columns: number, entries: readonly Entry[]): Segment[] {
39 let chars = Math.max(0, ...entries.map(({ text }) => text.length))
40 while (chars > MIN_CHARS && widthAt(entries, chars) > columns) {
41 chars -= 1
42 }
43
44 return fit(columns, entries.map(entry => ({ ...entry, text: cut(entry.text, chars) })))
45}
46
47/** As many entries as fit whole, then `+N more`. */
48function fit(columns: number, entries: readonly Entry[]): Segment[] {
49 const row: Segment[] = []
50 let used = 0
51 for (const [i, entry] of entries.entries()) {
52 const gap = i === 0 ? '' : GAP
53 const after = entries.length - i - 1
54 // Room is kept after each entry for the count of those that follow.
55 const reserve = after > 0 ? GAP.length + more(after).length : 0
56 const width = gap.length + MARK.length + 1 + entry.text.length
57 if (used + width + reserve <= columns) {
58 row.push({ text: gap }, { text: MARK, color: entry.mark }, { text: ` ${entry.text}`, color: entry.color })
59 used += width
60 continue
61 }
62
63 if (i > 0) {
64 row.push({ text: `${GAP}${more(entries.length - i)}`, isDim: true })
65 return row
66 }
67 // Not even the first fits whole: it's cut short.
68 const room = columns - MARK.length - 1 - reserve
69 if (room < 2) {
70 return [{ text: more(entries.length), isDim: true }]
71 }
72 row.push(
73 { text: MARK, color: entry.mark },
74 { text: ` ${entry.text.slice(0, room - 1)}…`, color: entry.color },
75 )
76 if (after > 0) {
77 row.push({ text: `${GAP}${more(after)}`, isDim: true })
78 }
79 return row
80 }
81
82 return row
83}
84
85/** A color as the `Text` element takes it. */
86export const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
87hooks/pace.ts 40 lines1// How fast a subagent's creature walks follows how hard its agent works: the
2// tokens its model wrote over the last few seconds. A busy agent's creature
3// runs; one waiting on a tool slows to a stroll. Times are the walk's own
4// milliseconds, which run as its timer fires: every tick on the terminal,
5// less often on the desktop.
6
7import { TICK_MS } from './walker'
8
9/** How far back an agent's work counts. */
10export const WINDOW_MS = 10_000
11/** Output tokens a second at which a creature walks its fastest. */
12export const FULL_RATE = 50
13/** The slowest and fastest a creature walks, as a share of its own speed. */
14export const MIN_PACE = 0.5
15export const MAX_PACE = 1.5
16// How much of the way to its new pace a creature goes in a tick: about a
17// second to settle.
18const EASE = 0.05
19
20/** One model response an agent got: when it ended, in the walk's milliseconds, and the tokens it wrote. */
21export type Work = { at: number; tokens: number }
22
23/** A creature's pace for its agent's output tokens a second. */
24export const paceFor = (rate: number) =>
25 MIN_PACE + (MAX_PACE - MIN_PACE) * Math.min(Math.max(rate, 0) / FULL_RATE, 1)
26
27/** Output tokens a second over the window ending at `now`; older work is dropped. */
28export function rateAt(work: Work[], now: number): number {
29 const since = now - WINDOW_MS
30 while (work.length > 0 && (work[0]?.at ?? 0) <= since) {
31 work.shift()
32 }
33
34 return work.reduce((sum, { tokens }) => sum + tokens, 0) / (WINDOW_MS / 1000)
35}
36
37/** The step from `pace` toward `target` that `ms` milliseconds make. */
38export const ease = (pace: number, target: number, ms = TICK_MS) =>
39 pace + (target - pace) * (1 - (1 - EASE) ** (ms / TICK_MS))
40hooks/looks.ts 107 lines1import type { Headwear, Look } from './sprite'
2
3export const ANTENNAE: Headwear = ['...#....#...', '....#..#....']
4export const TOP_HAT: Headwear = ['....####....', '.##########.']
5export const PROPELLER: Headwear = ['..###..###..', '....####....']
6export const CROWN: Headwear = ['..#..##..#..', '..########..']
7export const HALO: Headwear = ['...######...', '............']
8// Worn only by the types without a look of their own.
9export const EARS: Headwear = ['..#......#..', '..##....##..']
10export const HORNS: Headwear = ['.#........#.', '..#......#..']
11export const MOHAWK: Headwear = ['.....##.....', '....####....']
12export const SPROUT: Headwear = ['....##.##...', '......#.....']
13
14export const BOSS: Look = {
15 color: 0xd77757,
16 shadeColor: 0xb05a3e,
17 eyeColor: 0x1f1e1d,
18 headwear: null,
19 isBoss: true,
20}
21
22/** The built-in agent kinds' looks, with the words `/posse legend` uses for them. */
23export const LEGEND = [
24 { type: 'Explore', look: { color: 0x61afef, headwear: ANTENNAE, hatColor: 0xbcbcbc }, says: 'blue, with light gray antennae' },
25 { type: 'Plan', look: { color: 0x98c379, headwear: TOP_HAT, hatColor: 0xe06c75 }, says: 'green, with a red top hat' },
26 { type: 'general-purpose', look: { color: 0xc678dd, headwear: PROPELLER, hatColor: 0x56b6c2 }, says: 'purple, with a cyan propeller cap' },
27 { type: 'claude', look: { color: 0xe06c9f, headwear: CROWN, hatColor: 0xe5c07b }, says: 'pink, with a gold crown' },
28 { type: 'fork', look: { color: 0xf4a582, headwear: HALO, hatColor: 0xffe9a8 }, says: 'peach, with a pale yellow halo' },
29] as const satisfies readonly { type: string; look: Look; says: string }[]
30
31const BY_TYPE = new Map<string, Look>(LEGEND.map(({ type, look }) => [type, look]))
32
33// Bodies and hats for the other types, two lists that share no color.
34const COLORS = [0xb5cc5c, 0xb39ddb, 0xd19a66, 0xa0a8b7, 0x7ec699]
35const HAT_COLORS = [0xbcbcbc, 0xffd75f, 0x56b6c2, 0xe06c75]
36const HEADWEAR = [EARS, HORNS, MOHAWK, SPROUT]
37
38/** A subagent's look: its type's own, or one its type's name always picks. */
39export function lookFor(type: string): Look {
40 const known = BY_TYPE.get(type)
41 if (known !== undefined) {
42 return known
43 }
44
45 const h = hash(type)
46 return {
47 color: COLORS[h % COLORS.length] ?? BOSS.color,
48 headwear: HEADWEAR[(h >>> 8) % HEADWEAR.length] ?? EARS,
49 hatColor: HAT_COLORS[(h >>> 16) % HAT_COLORS.length] ?? 0xbcbcbc,
50 }
51}
52
53// Hats for a creature whose own hat color another one on the strip already
54// wears, so that two agents of a type, and their legend marks, tell apart.
55// Strong colors that read on a dark terminal and a light one; the first three
56// are no kind's own, so a spare rarely takes a hat a later agent wants.
57export const SPARE_HATS = [0xff8c42, 0xc678dd, 0x98c379, 0xe06c75, 0xe5c07b, 0x56b6c2]
58
59/** Whether two colors are close enough to pass for one another. */
60export function isNear(a: number, b: number): boolean {
61 const channel = (color: number, shift: number) => (color >> shift) & 255
62 const distance = Math.hypot(
63 ...[16, 8, 0].map(shift => channel(a, shift) - channel(b, shift)),
64 )
65
66 return distance < 60
67}
68
69/**
70 * A subagent's look, with a hat color unlike any of `worn`, the ones on the
71 * strip already: its type's own if that's free, otherwise the first spare
72 * that is free and unlike its body. With every spare taken, its own.
73 */
74export function hatFor(type: string, worn: readonly number[]): Look {
75 const look = lookFor(type)
76 const own = look.hatColor ?? look.color
77 const isFree = (color: number) => !worn.some(other => isNear(color, other))
78 if (isFree(own)) {
79 return look
80 }
81 const spare = SPARE_HATS.find(color => isFree(color) && !isNear(color, look.color))
82
83 return spare === undefined ? look : { ...look, hatColor: spare }
84}
85
86/** FNV-1a: the same text always gives the same number. */
87export function hash(text: string): number {
88 let h = 0x811c9dc5
89 for (let i = 0; i < text.length; i++) {
90 h ^= text.charCodeAt(i)
91 h = Math.imul(h, 0x01000193)
92 }
93
94 return h >>> 0
95}
96
97/** Which creature is which, as `/posse legend` prints it. */
98export function legendText(): string {
99 return [
100 "Who's who in the posse:",
101 '- **The boss** is Claude itself: orange, bareheaded, and bigger than the rest',
102 ...LEGEND.map(({ type, says }) => `- \`${type}\` agents: ${says}`),
103 '- **Any other agent**: ears, horns, a mohawk or a sprout, in colors that stay the same for each kind of agent',
104 '- **Two or more of one kind** at once: the later ones wear hats in other colors, so each can be told apart',
105 ].join('\n')
106}
107hooks/sprite.ts 219 lines1// The posse in pixels, drawn two by two in quadrant block characters, three
2// terminal rows tall. A subagent's creature is 12 pixels wide (6 columns, 7
3// at an odd pixel offset) with headwear on its top row; the boss is 16 wide
4// and fills all three rows, the biggest of them. A cell shows two colors at
5// most: its glyph in one, and the other behind it where the cell has no hole.
6
7export const SPRITE_WIDTH = 12
8export const BOSS_WIDTH = 16
9export const SPRITE_ROWS = 3
10
11export type Pose = {
12 facing: -1 | 0 | 1
13 /** 0 stands on all four legs; 1 and 2 lift alternate legs. */
14 step: 0 | 1 | 2
15 isBlinking: boolean
16}
17
18/** Two rows of pixels worn above the head, `#` filled. */
19export type Headwear = readonly [string, string]
20
21export type Look = {
22 color: number
23 headwear: Headwear | null
24 /** The headwear's own color, so it stands out from the body. */
25 hatColor?: number
26 /** The shaded side's color; without one, a darker shade of the body's. */
27 shadeColor?: number
28 /** The eyes' color; without one, near black. */
29 eyeColor?: number
30 isBoss?: boolean
31}
32
33/** One creature where it stands: its left edge in pixels, two per column. */
34export type Figure = { x: number; pose: Pose; look: Look }
35
36export const spriteWidth = (look: Look) => (look.isBoss ? BOSS_WIDTH : SPRITE_WIDTH)
37
38// A subagent's creature, drawn walking left with its shaded side (`%`) at
39// its back on the right, and dark eyes (`o`); walking right it's mirrored.
40// Facing you, it stands square on, with no side showing. As on the boss,
41// each eye keeps body on both sides at either pixel offset.
42const BARE: Headwear = ['............', '............']
43const BODY = '..######%%..'
44const ARMS = '########%%%%'
45const EYES = '..#o##o#%%..'
46const LEGS = {
47 0: '..#.#..#.%..',
48 1: '..#....#....',
49 2: '....#....%..',
50} as const
51const FRONT_BODY = '..########..'
52const FRONT_ARMS = '############'
53const FRONT_EYES = '..##o##o##..'
54const FRONT_LEGS = {
55 0: '..#.#..#.#..',
56 1: '..#....#....',
57 2: '....#....#..',
58} as const
59
60const EYE_COLOR = 0x1f1e1d
61
62// The boss, after Claude Code's mascot: a block with a shaded side (`%`),
63// dark eyes (`o`) and four short legs. Drawn here walking left, its shaded
64// side behind it on the right; walking right it's mirrored, so the shade
65// stays at its back. Facing you, it stands as the mascot does, shaded on
66// the right.
67const BOSS_BODY = '..##########%%..'
68const BOSS_ARMS = '############%%%%'
69// Each eye keeps body on both sides, so a cell never has to choose between
70// an eye and a hole or the shade, at either pixel offset.
71const BOSS_EYES = {
72 [-1]: '..#o#####o##%%..',
73 0: '..##o#####o#%%..',
74} as const
75const BOSS_LEGS = {
76 0: '...#..#..#..%...',
77 1: '...#.....#......',
78 2: '......#.....%...',
79} as const
80
81const mirror = (row: string) => [...row].reverse().join('')
82
83// Indexed by the filled quadrants: top-left 1, top-right 2, bottom-left 4,
84// bottom-right 8.
85const QUADRANTS = [...' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█']
86
87const PIXEL_ROWS = SPRITE_ROWS * 2
88const EMPTY = -1
89const TERMINAL_DEFAULT = 0x01000000
90
91type Cell = { glyph: string; color: number; background: number }
92
93/**
94 * A creature's rows of pixels: `#` its body, `+` its headwear, `%` its shaded
95 * side, `o` its eyes, `.` none of them.
96 */
97export function spritePixels(pose: Pose, look: Look): readonly string[] {
98 if (look.isBoss) {
99 const eyes = pose.isBlinking ? BOSS_BODY : BOSS_EYES[pose.facing === 1 ? -1 : pose.facing]
100 const rows = [BOSS_BODY, eyes, BOSS_ARMS, BOSS_BODY, BOSS_BODY, BOSS_LEGS[pose.step]]
101 return pose.facing === 1 ? rows.map(mirror) : rows
102 }
103
104 const [crown = '', brim = ''] = (look.headwear ?? BARE).map(row => row.replaceAll('#', '+'))
105 if (pose.facing === 0) {
106 const eyes = pose.isBlinking ? FRONT_BODY : FRONT_EYES
107 return [crown, brim, FRONT_BODY, eyes, FRONT_ARMS, FRONT_LEGS[pose.step]]
108 }
109 const body = [BODY, pose.isBlinking ? BODY : EYES, ARMS, LEGS[pose.step]]
110 return [crown, brim, ...(pose.facing === 1 ? body.map(mirror) : body)]
111}
112
113/** A color 22% darker, channel by channel. */
114const darker = (color: number) =>
115 [16, 8, 0].reduce((out, shift) => out | (Math.round(((color >> shift) & 255) * 0.78) << shift), 0)
116
117/** The color a sprite pixel paints in, or none. */
118export function pixelColor(pixel: string | undefined, look: Look): number | null {
119 if (pixel === '#') return look.color
120 if (pixel === '+') return look.hatColor ?? look.color
121 if (pixel === '%') return look.shadeColor ?? darker(look.color)
122 if (pixel === 'o') return look.eyeColor ?? EYE_COLOR
123 return null
124}
125
126function paint(columns: number, figures: readonly Figure[]): Cell[][] {
127 // Each pixel holds the color of the last figure to fill it.
128 const width = columns * 2
129 const canvas = Array.from({ length: PIXEL_ROWS }, () =>
130 new Array<number>(width).fill(EMPTY),
131 )
132 for (const { x, pose, look } of figures) {
133 spritePixels(pose, look).forEach((row, py) => {
134 for (let i = 0; i < row.length; i++) {
135 const px = x + i
136 const color = pixelColor(row[i], look)
137 if (color !== null && px >= 0 && px < width) {
138 canvas[py]![px] = color
139 }
140 }
141 })
142 }
143
144 const cells: Cell[][] = []
145 for (let cy = 0; cy < SPRITE_ROWS; cy++) {
146 const top = canvas[cy * 2]!
147 const bottom = canvas[cy * 2 + 1]!
148 const row: Cell[] = []
149 for (let cx = 0; cx < columns; cx++) {
150 const quadrants = [top[cx * 2], top[cx * 2 + 1], bottom[cx * 2], bottom[cx * 2 + 1]]
151 // The glyph takes the color most of the cell is. A cell with no hole
152 // shows its next color behind the glyph; one with a hole shows the
153 // terminal there instead, and paints all its pixels in the glyph's
154 // color. A cell can't show a third color.
155 const counts = new Map<number, number>()
156 for (const color of quadrants) {
157 if (color !== undefined && color !== EMPTY) {
158 counts.set(color, (counts.get(color) ?? 0) + 1)
159 }
160 }
161 const [color = TERMINAL_DEFAULT, behind] = [...counts.keys()].sort(
162 (a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0),
163 )
164 const hasHole = quadrants.includes(EMPTY)
165 const background = hasHole || behind === undefined ? TERMINAL_DEFAULT : behind
166 let bits = 0
167 quadrants.forEach((pixel, i) => {
168 if (pixel !== EMPTY && (hasHole || pixel === color)) {
169 bits |= 1 << i
170 }
171 })
172 row.push({ glyph: QUADRANTS[bits] ?? ' ', color, background })
173 }
174 cells.push(row)
175 }
176
177 return cells
178}
179
180/** The band's rows as text, each figure drawn where it stands. */
181export function glyphRows(columns: number, figures: readonly Figure[]): string[] {
182 return paint(columns, figures).map(row => row.map(cell => cell.glyph).join(''))
183}
184
185/** The figures packed as a Raster's `cells`, on the terminal's own background where they leave a hole. */
186export function rasterCells(columns: number, figures: readonly Figure[]): string {
187 const cells = paint(columns, figures).flat()
188 const view = new DataView(new ArrayBuffer(cells.length * 12))
189
190 cells.forEach(({ glyph, color, background }, i) => {
191 view.setUint32(i * 12, glyph.codePointAt(0) ?? 0x20, true)
192 view.setUint32(i * 12 + 4, color, true)
193 view.setUint32(i * 12 + 8, background, true)
194 })
195
196 return toBase64(new Uint8Array(view.buffer))
197}
198
199const BASE64 =
200 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
201
202export function toBase64(bytes: Uint8Array): string {
203 const out: string[] = []
204 for (let i = 0; i < bytes.length; i += 3) {
205 const b0 = bytes[i] ?? 0
206 const b1 = bytes[i + 1]
207 const b2 = bytes[i + 2]
208 const n = (b0 << 16) | ((b1 ?? 0) << 8) | (b2 ?? 0)
209 out.push(
210 BASE64[(n >> 18) & 63] ?? '',
211 BASE64[(n >> 12) & 63] ?? '',
212 b1 === undefined ? '=' : (BASE64[(n >> 6) & 63] ?? ''),
213 b2 === undefined ? '=' : (BASE64[n & 63] ?? ''),
214 )
215 }
216
217 return out.join('')
218}
219hooks/svg.ts 195 lines1// The desktop draws the posse as one SVG that animates itself: the same
2// sprites, each pixel a rect twice as tall as it is wide (a terminal
3// quadrant's shape), walking on SMIL animations the desktop plays on its
4// own, so nothing is sent per frame. Their phase comes from how long each
5// creature has been walking, so a redraw picks up where the last one was.
6
7import { SPRITE_ROWS, pixelColor, spriteWidth, spritePixels } from './sprite'
8import type { Look, Pose } from './sprite'
9import { BLINK_EVERY, BLINK_TICKS, PAUSE_TICKS, TICK_MS, lastX } from './walker'
10
11/** One creature's walk: where it started, which way, how fast, and for how long. */
12export type Stride = {
13 x: number
14 heading: 1 | -1
15 /** Pixels a tick, as the terminal's walker counts them. */
16 speed: number
17 /** How long it has been walking, in milliseconds. */
18 elapsedMs: number
19 look: Look
20}
21
22const PIXEL_HEIGHT = 2
23const GROUND_Y = SPRITE_ROWS * 2 * PIXEL_HEIGHT
24const HEIGHT = GROUND_Y + 0.5
25const STANDING: Pose = { facing: 0, step: 0, isBlinking: false }
26const FACINGS = [-1, 0, 1] as const
27const STEPS = [0, 1, 2] as const
28
29const seconds = (ms: number) => `${(ms / 1000).toFixed(3)}s`
30const keyTimes = (...times: number[]) => times.map(t => t.toFixed(4)).join(';')
31const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
32
33/** A row's `pixel`s as rects, each run of them one rect. */
34function rects(row: string, py: number, pixel: string): string {
35 let out = ''
36 for (let i = 0; i < row.length; ) {
37 if (row[i] !== pixel) {
38 i += 1
39 continue
40 }
41 let j = i
42 while (row[j] === pixel) {
43 j += 1
44 }
45 out += `<rect x="${i}" y="${py * PIXEL_HEIGHT}" width="${j - i}" height="${PIXEL_HEIGHT}"/>`
46 i = j
47 }
48 return out
49}
50
51function toggle(values: string, times: string, dur: string, begin: string) {
52 return `<animate attributeName="opacity" calcMode="discrete" values="${values}" keyTimes="${times}" dur="${dur}" begin="${begin}" repeatCount="indefinite"/>`
53}
54
55const layer = (content: string, opacity: 0 | 1, animation = '') =>
56 `<g opacity="${opacity}">${animation}${content}</g>`
57
58function creature(columns: number, stride: Stride): string {
59 const { look } = stride
60 const rowsFor = (pose: Partial<Pose>) => spritePixels({ ...STANDING, ...pose }, look)
61 const standing = rowsFor({})
62
63 // Body pixels in the creature's fill; headwear, shade and eyes in their own colors.
64 const draw = (rows: readonly (readonly [string, number])[]) => {
65 const body = rows.map(([row, py]) => rects(row, py, '#')).join('')
66 const rest = ['+', '%', 'o'].map(pixel => {
67 const drawn = rows.map(([row, py]) => rects(row, py, pixel)).join('')
68 const color = pixelColor(pixel, look)
69 return drawn === '' || color === null ? '' : `<g fill="${hex(color)}">${drawn}</g>`
70 })
71 return body + rest.join('')
72 }
73
74 // Which rows change with the legs, and which other rows with the eyes or
75 // the way it faces; the rest stay put. The legs too may differ by facing.
76 const legRows = standing.flatMap((row, py) =>
77 STEPS.some(step => rowsFor({ step })[py] !== row) ? [py] : [],
78 )
79 const eyeRows = standing.flatMap((row, py) =>
80 !legRows.includes(py) &&
81 (FACINGS.some(facing => rowsFor({ facing })[py] !== row) ||
82 rowsFor({ isBlinking: true })[py] !== row)
83 ? [py]
84 : [],
85 )
86 const fixed = draw(
87 standing.flatMap((row, py) =>
88 eyeRows.includes(py) || legRows.includes(py) ? [] : [[row, py] as const],
89 ),
90 )
91 const legs = (step: Pose['step'], facing: Pose['facing'] = 0) =>
92 draw(legRows.map(py => [rowsFor({ step, facing })[py] ?? '', py] as const))
93
94 // Eyes are holes in the body, or the boss's dark pixels; a blink covers them for a moment.
95 const blinkDur = seconds(BLINK_EVERY * TICK_MS)
96 const blinkAt = keyTimes(0, (BLINK_EVERY - BLINK_TICKS) / BLINK_EVERY)
97 const eyes = (facing: Pose['facing']) => {
98 const open = eyeRows.map(py => [rowsFor({ facing })[py] ?? '', py] as const)
99 const lids = eyeRows.map(py => {
100 const shut = rowsFor({ facing, isBlinking: true })[py] ?? ''
101 const row = rowsFor({ facing })[py] ?? ''
102 return [[...shut].map((p, i) => (p === '#' && row[i] !== '#' ? '#' : '.')).join(''), py] as const
103 })
104 const blink = toggle('0;1', blinkAt, blinkDur, seconds(-stride.elapsedMs))
105 return draw(open) + layer(draw(lids), 0, blink)
106 }
107
108 const end = lastX(columns, spriteWidth(look))
109 const fill = hex(look.color)
110 if (end === 0) {
111 return `<g fill="${fill}">${fixed}${eyes(0)}${legs(0)}</g>`
112 }
113
114 // Right across, a pause facing out, back left, a pause: one period.
115 const travelMs = (end / stride.speed) * TICK_MS
116 const pauseMs = PAUSE_TICKS * TICK_MS
117 const periodMs = 2 * (travelMs + pauseMs)
118 const a = travelMs / periodMs
119 const b = (travelMs + pauseMs) / periodMs
120 const c = (2 * travelMs + pauseMs) / periodMs
121 const startMs =
122 stride.heading === 1
123 ? (stride.x / end) * travelMs
124 : travelMs + pauseMs + ((end - stride.x) / end) * travelMs
125 const dur = seconds(periodMs)
126 const begin = seconds(-(startMs + stride.elapsedMs))
127 const pausing = toggle('0;1;0;1', keyTimes(0, a, b, c), dur, begin)
128 const goingRight = toggle('1;0', keyTimes(0, a), dur, begin)
129 const goingLeft = toggle('0;1;0', keyTimes(0, b, c), dur, begin)
130
131 const move = `<animateTransform attributeName="transform" type="translate" values="0 0;${end} 0;${end} 0;0 0;0 0" keyTimes="${keyTimes(0, a, b, c, 1)}" dur="${dur}" begin="${begin}" repeatCount="indefinite"/>`
132 const facing =
133 layer(eyes(1), 1, goingRight) + layer(eyes(0), 0, pausing) + layer(eyes(-1), 0, goingLeft)
134
135 // Two pixels a step, as on the terminal.
136 const stepDur = seconds((4 / stride.speed) * TICK_MS)
137 const stepBegin = seconds(-stride.elapsedMs)
138 const stepping = (facing: 1 | -1) =>
139 layer(legs(1, facing), 1, toggle('1;0', '0;0.5', stepDur, stepBegin)) +
140 layer(legs(2, facing), 0, toggle('0;1', '0;0.5', stepDur, stepBegin))
141 const feet =
142 layer(legs(0), 0, pausing) +
143 layer(stepping(1), 1, goingRight) +
144 layer(stepping(-1), 0, goingLeft)
145
146 return `<g fill="${fill}">${move}${fixed}${facing}${feet}</g>`
147}
148
149/**
150 * Where a creature walking `stride` is once `stride.elapsedMs` have gone by,
151 * as the SVG draws it: its left edge in pixels, which way it heads, and
152 * whether it is pausing at an edge.
153 */
154export function strideAt(
155 columns: number,
156 stride: Stride,
157): { x: number; heading: 1 | -1; isPausing: boolean } {
158 const end = lastX(columns, spriteWidth(stride.look))
159 if (end === 0) {
160 return { x: 0, heading: stride.heading, isPausing: false }
161 }
162
163 const travelMs = (end / stride.speed) * TICK_MS
164 const pauseMs = PAUSE_TICKS * TICK_MS
165 const periodMs = 2 * (travelMs + pauseMs)
166 const startMs =
167 stride.heading === 1
168 ? (stride.x / end) * travelMs
169 : travelMs + pauseMs + ((end - stride.x) / end) * travelMs
170 const t = (startMs + stride.elapsedMs) % periodMs
171 if (t < travelMs) {
172 return { x: (t / travelMs) * end, heading: 1, isPausing: false }
173 }
174 if (t < travelMs + pauseMs) {
175 return { x: end, heading: -1, isPausing: true }
176 }
177 if (t < 2 * travelMs + pauseMs) {
178 return { x: end - ((t - travelMs - pauseMs) / travelMs) * end, heading: -1, isPausing: false }
179 }
180
181 return { x: 0, heading: 1, isPausing: true }
182}
183
184/** The posse walking on a ground line, `columns` wide at two pixels a column. */
185export function posseSvg(columns: number, strides: readonly Stride[]): string {
186 const width = columns * 2
187
188 return (
189 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${HEIGHT}" width="100%" preserveAspectRatio="xMinYMax meet" shape-rendering="crispEdges">` +
190 `<rect x="0" y="${GROUND_Y}" width="${width}" height="0.5" fill="#8a8a8a" opacity="0.6"/>` +
191 strides.map(stride => creature(columns, stride)).join('') +
192 '</svg>'
193 )
194}
195hooks/walker.ts 135 lines1import { SPRITE_WIDTH } from './sprite'
2import type { Pose } from './sprite'
3
4export const TICK_MS = 50
5// Ticks spent facing the person at each edge before turning around.
6export const PAUSE_TICKS = Math.round(800 / TICK_MS)
7// Ticks spent standing still after bumping into another creature.
8export const BUMP_TICKS = Math.round(200 / TICK_MS)
9export const BLINK_EVERY = Math.round(3200 / TICK_MS)
10export const BLINK_TICKS = Math.round(160 / TICK_MS)
11// The fewest free pixels between two creatures side by side: a column, so
12// no cell holds both.
13export const MIN_GAP = 2
14
15export type Walker = {
16 /** The sprite's left edge, in pixels: two per terminal column. */
17 x: number
18 heading: 1 | -1
19 /** Ticks left standing still. */
20 pause: number
21 /** Pixels walked so far; drives the legs. */
22 travelled: number
23 ticks: number
24 /** Pixels a tick, on average. */
25 speed: number
26 /** The part of a pixel walked toward the next. */
27 progress: number
28 /** The sprite's width, in pixels. */
29 width: number
30}
31
32export const createWalker = (
33 x = 0,
34 heading: 1 | -1 = 1,
35 speed = 1,
36 width = SPRITE_WIDTH,
37): Walker => ({ x, heading, pause: 0, travelled: 0, ticks: 0, speed, progress: 0, width })
38
39/** The furthest left edge that keeps a sprite `width` wide in `columns`. */
40export const lastX = (columns: number, width = SPRITE_WIDTH) =>
41 Math.max(0, columns * 2 - width)
42
43/**
44 * Free pixels between a sprite at `x`, `width` wide, and another; below 0
45 * they overlap, by less the closer they are to pulling apart either way.
46 */
47const gapTo = (x: number, width: number, other: Walker) =>
48 Math.max(other.x - (x + width), x - (other.x + other.width))
49
50/** One tick: `speed` pixels along, stopping to turn at an edge or a bump. */
51export function step(
52 walker: Walker,
53 columns: number,
54 others: readonly Walker[] = [],
55): void {
56 walker.ticks += 1
57
58 if (walker.pause > 0) {
59 walker.pause -= 1
60 if (walker.pause === 0) {
61 walker.heading = walker.heading === 1 ? -1 : 1
62 }
63 return
64 }
65
66 // A strip too crowded for everyone to keep their distance lets them walk
67 // through each other, as on the desktop, rather than stand stuck.
68 const needed = others.reduce(
69 (sum, other) => sum + other.width + MIN_GAP,
70 walker.width + MIN_GAP,
71 )
72 const blocking = needed > columns * 2 ? [] : others
73
74 walker.progress += walker.speed
75 while (walker.progress >= 1 && walker.pause === 0) {
76 walker.progress -= 1
77 advance(walker, columns, blocking)
78 }
79}
80
81function advance(walker: Walker, columns: number, others: readonly Walker[]) {
82 const end = lastX(columns, walker.width)
83 const x = Math.min(Math.max(walker.x + walker.heading, 0), end)
84
85 // Only a step that closes in on someone too near is a bump, so two
86 // creatures that start out overlapping can still walk apart.
87 const isBump = others.some(other => {
88 const gap = gapTo(x, walker.width, other)
89 return gap < MIN_GAP && gap < gapTo(walker.x, walker.width, other)
90 })
91 if (isBump) {
92 walker.pause = BUMP_TICKS
93 walker.progress = 0
94 return
95 }
96
97 walker.x = x
98 walker.travelled += 1
99
100 const isAtEdge = walker.heading === 1 ? x >= end : x <= 0
101 if (isAtEdge) {
102 walker.pause = PAUSE_TICKS
103 walker.progress = 0
104 }
105}
106
107/** Where a newcomer `width` wide starts: as far from everyone already walking as it can. */
108export function freeSpot(
109 columns: number,
110 width: number,
111 others: readonly Walker[],
112): number {
113 let best = 0
114 let bestGap = -Infinity
115 for (let x = 0; x <= lastX(columns, width); x++) {
116 const gap = Math.min(...others.map(other => gapTo(x, width, other)))
117 if (gap > bestGap) {
118 best = x
119 bestGap = gap
120 }
121 }
122
123 return best
124}
125
126export function pose(walker: Walker): Pose {
127 const isStanding = walker.pause > 0
128
129 return {
130 facing: isStanding ? 0 : walker.heading,
131 step: isStanding ? 0 : Math.floor(walker.travelled / 2) % 2 === 0 ? 1 : 2,
132 isBlinking: walker.ticks % BLINK_EVERY >= BLINK_EVERY - BLINK_TICKS,
133 }
134}
135