A collectible pixel pet that hatches, reacts and evolves while you code.

<img src="../../brand/pixel-icons/svg/hatchling.svg" width="64" height="64" alt="">
A collectible pixel pet that hatches, reacts and evolves while you code.
/plugin install hatchling --marketplace modmyclaude/claude-code-mods
An egg appears in a small band above your prompt: Bolt's egg, in the modmyclaude mascot's lime with its antenna light on. It wobbles while Claude works, cracks a little more at a quarter, half and three quarters of the way, and hatches after 25 tool calls into one of 8 original species, or something rarer:
| Species | Rarity | Odds |
|---|---|---|
| Pebblet (a mossy river stone), Fluffle (lop-eared fluff), Coilsnail (snail with a thread-spool shell) | common | 36 in 64 (56%) |
| Nimbit (a pocket raincloud), Lampwing (a moth with a glowing belly) | uncommon | 18 in 64 (28%) |
| Foldling (a folded paper bird), Geobug (a beetle with a geode shell) | rare | 8 in 64 (12.5%) |
| Starwhelp (a teacup-sized star dragon) | legendary | 2 in 64 (3%) |
| ??? | secret | 1 in 100 |
The species odds apply to the other 99 eggs in 100. Each species has a 1 in 64 chance to be shiny (another palette). The roll happens once, at hatch time, and is saved: restarting never rerolls. The egg carries its own random seed, so two sessions that hatch it at the same moment hatch the same pet.
What it does while you work:
Commands:
| Command | What it does |
|---|---|
/pet | Stat card: name, species, rarity, shiny or not, stage, XP to the next stage, days together |
/pet name <name> | Rename it (up to 16 letters, numbers, spaces, - ' . _). Works on the egg too. Answers with a toast. |
/pet hide / /pet show | Hide or bring back the band (remembered across sessions). Answers with a toast. |
/pet card | A ~30 x 12 plain-text card with a Copy card button, for sharing |
The pet is one pet for you, across every session and project. It shares the band with other mods: theirs is drawn first and the pet below it, so it never pushes their buttons out of view.
None.
/pet and /pet card (a few short lines), like any command's output. /pet name, hide, show and help answer with a toast the model never reads. Tool calls are only observed, never changed or refused./pet and /pet card still work there as text.cat, grep, gh run view, docker logs, kubectl logs, also after a cd or wrapped in sudo, env, a subshell or bash -c) do not count. Test runs started in the background, very long outputs whose summary is cut, and PowerShell are not read.This mod was built from this prompt.
More mods: the full catalogue.
hooks/register.tsx 615 lines1// hatchling: a pixel pet in the band above the prompt. It hatches after 25
2// tool calls, reacts to what Claude does, and evolves as it gains XP.
3//
4// Where things live:
5// $.store 'pet' the saved pet (survives restarts, shared by sessions)
6// $.state pet/anim/... the session's copy the band draws from
7// module variables only the two timers, which a reload drops anyway
8
9import { atom, read, update } from 'claude-code'
10import type { BoxProps, ElementConstructor, EngineInterface, Register, RenderNode, TextProps, Timer } from 'claude-code'
11
12import type { HatchlingAnim, HatchlingKind, HatchlingMood, HatchlingPet, HatchlingStage } from '../types'
13import { drawFrame, petInfo, rasterCells, toCells, toRuns } from './art'
14import type { Cell, View } from './art'
15import { glyphRow } from './cells'
16import {
17 ADULT_XP,
18 applyToolCall,
19 applyTurn,
20 cardText,
21 cleanName,
22 cryptoU32,
23 detectTestPass,
24 newSeed,
25 HATCH_AT,
26 nameOf,
27 normalizePet,
28 parseCard,
29 progressOf,
30 rarityOf,
31 safeText,
32 stageOf,
33 statText,
34 TEEN_XP,
35 toolLabel,
36} from './pet'
37import type { Change } from './pet'
38import { RARITY_COLOR } from './sprites'
39
40const STORE_KEY = 'pet'
41const FRAME_MS = 300
42const SLEEP_MS = 5 * 60_000
43/** A turn that never reported its end stops the working loop after an hour. */
44const STALL_TICKS = Math.round((60 * 60_000) / FRAME_MS)
45
46/** Ticks each mood plays before settling (sleep: the doze-off, then still). */
47const DURATION: Readonly<Record<HatchlingMood, number>> = {
48 idle: 0, happy: 4, sweat: 9, cheer: 12, sleep: 6, hatch: 10, evolve: 12,
49}
50/** A busier mood is not cut short by a lesser one. */
51const PRIORITY: Readonly<Record<HatchlingMood, number>> = {
52 idle: 0, sleep: 0, happy: 1, sweat: 2, cheer: 3, evolve: 4, hatch: 5,
53}
54
55const IDLE: HatchlingAnim = { mood: 'idle', tick: 0, left: 0, line: '' }
56const EGG_ACCENT = '#e9dcb8'
57
58const petState = atom({ plugin: 'hatchling', key: 'pet' } as const, null)
59const animState = atom({ plugin: 'hatchling', key: 'anim' } as const, IDLE)
60const workingState = atom({ plugin: 'hatchling', key: 'working' } as const, false)
61
62type Dollar = EngineInterface
63type Tags = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
64
65const HELP = 'Try /pet, /pet name <name>, /pet hide, /pet show or /pet card.'
66
67// The only module state: the two timers and what schedules them. A reload
68// drops them with the old environment, which is what we want.
69let ticker: Timer | undefined
70let sleeper: Timer | undefined
71let quietTicks = 0
72let tickerSeq = 0
73let sleepSeq = 0
74let queue: Promise<unknown> = Promise.resolve()
75
76// ------------------------------------------------------------ saving
77
78/**
79 * Applies one change to the saved pet. Re-reads the store first every time
80 * (another open session may have written since), and runs one at a time so
81 * parallel tool calls never lose an update.
82 */
83function commit($: Dollar, change: (pet: HatchlingPet, now: number) => Change): Promise<Change> {
84 async function job() {
85 const now = await $.clock.now()
86 const result = change(normalizePet(await $.store.get(STORE_KEY), now), now)
87 await $.store.set(STORE_KEY, result.pet)
88 await update($, petState, () => result.pet)
89 return result
90 }
91 const run = queue.then(job, job)
92 queue = run.catch(() => undefined)
93 return run
94}
95
96function same(pet: HatchlingPet): Change {
97 return { pet, isHatched: false }
98}
99
100// ------------------------------------------------------------ timers
101
102/**
103 * True while a surface that draws the band is attached. Not isInteractive:
104 * that is false under the SDK, which is how the desktop app runs a session.
105 */
106async function isDrawn($: Dollar): Promise<boolean> {
107 const surfaces = await $.session.surfaces().catch(() => [])
108 return surfaces.some(surface => surface === 'terminal' || surface === 'desktop')
109}
110
111function stopTimers(): void {
112 tickerSeq += 1
113 sleepSeq += 1
114 ticker?.cancel()
115 sleeper?.cancel()
116 ticker = undefined
117 sleeper = undefined
118}
119
120/** The frame timer runs only while a mood plays or Claude is working. */
121async function syncTicker($: Dollar) {
122 const seq = ++tickerSeq
123 const pet = await read($, petState)
124 const anim = await read($, animState)
125 const isWorking = await read($, workingState)
126 const drawn = await isDrawn($)
127 if (seq !== tickerSeq) return // a newer call decides
128 const isOn = drawn && pet !== null && !pet.hidden && (anim.left > 0 || (isWorking && anim.mood !== 'sleep'))
129 if (isOn && ticker === undefined) ticker = $.clock.every(FRAME_MS, () => void step($))
130 if (!isOn && ticker !== undefined) {
131 ticker.cancel()
132 ticker = undefined
133 }
134}
135
136async function step($: Dollar) {
137 try {
138 quietTicks += 1
139 if (quietTicks === STALL_TICKS) {
140 await update($, workingState, () => false)
141 await armSleep($)
142 }
143 await update($, animState, anim => {
144 const left = Math.max(0, anim.left - 1)
145 const isSettling = anim.left > 0 && left === 0
146 // A mood settles to idle; the doze-off settles into plain sleep.
147 if (isSettling && anim.mood !== 'sleep') return { ...IDLE, tick: anim.tick + 1 }
148 if (isSettling) return { ...anim, tick: anim.tick + 1, left, line: '' }
149 return { ...anim, tick: anim.tick + 1, left }
150 })
151 await syncTicker($)
152 } catch {
153 // A missed frame is not worth a failure.
154 }
155}
156
157/** Starts the 5 minute countdown to sleep, unless working or hidden. */
158async function armSleep($: Dollar) {
159 const seq = ++sleepSeq
160 sleeper?.cancel()
161 sleeper = undefined
162 const pet = await read($, petState)
163 const isWorking = await read($, workingState)
164 const drawn = await isDrawn($)
165 if (seq !== sleepSeq || !drawn || pet === null || pet.hidden || isWorking) return
166 sleeper = $.clock.after(SLEEP_MS, () => {
167 sleeper = undefined
168 void fallAsleep($)
169 })
170}
171
172async function fallAsleep($: Dollar) {
173 try {
174 const pet = await read($, petState)
175 if (pet === null || pet.hidden || (await read($, workingState))) return
176 const line = pet.hatch === undefined ? 'The egg is dreaming' : `${nameOf(pet)} dozed off`
177 await update($, animState, () => ({ mood: 'sleep', tick: 0, left: DURATION.sleep, line }))
178 await syncTicker($)
179 } catch {
180 // Staying awake is fine.
181 }
182}
183
184/** Anything happened: wake up and restart the countdown to sleep. */
185async function touch($: Dollar) {
186 quietTicks = 0
187 await update($, animState, anim => (anim.mood === 'sleep' ? { ...IDLE, tick: anim.tick } : anim))
188 await armSleep($)
189}
190
191// ------------------------------------------------------------ moods
192
193async function setMood($: Dollar, mood: HatchlingMood, line: string, from?: HatchlingStage) {
194 // Only a mood that is really playing (its timer running) holds its place,
195 // so a mood set where nothing draws can never freeze the band later.
196 await update($, animState, anim =>
197 anim.left > 0 && ticker !== undefined && PRIORITY[mood] < PRIORITY[anim.mood]
198 ? anim
199 : { mood, tick: 0, left: DURATION[mood], line, ...(from === undefined ? {} : { from }) },
200 )
201 await syncTicker($)
202}
203
204/** Turns a saved change into a reaction: hatch, evolve, or a mood. */
205async function react($: Dollar, change: Change, mood?: 'happy' | 'sweat' | 'cheer', tool = '') {
206 const pet = change.pet
207 const name = nameOf(pet)
208 if (change.isHatched && pet.hatch !== undefined) {
209 const kind = `${pet.hatch.shiny ? 'shiny ' : ''}${rarityOf(pet.hatch.species)} ${petInfo(pet.hatch.species).name}`
210 const article = /^[aeiou]/.test(kind) ? 'an' : 'a'
211 $.ui.toast(`Your egg hatched! Meet ${name}, ${article} ${kind}.`, { timeoutMs: 8000 })
212 await setMood($, 'hatch', `Crack! ${name} hatched - ${article} ${kind}`)
213 } else if (change.evolved !== undefined) {
214 const to = change.evolved.to
215 $.ui.toast(`${name} evolved into ${to === 'adult' ? 'an adult' : 'a teen'}!`, { timeoutMs: 8000 })
216 await setMood($, 'evolve', `${name} is evolving!`, change.evolved.from)
217 } else if (pet.hatch !== undefined && mood !== undefined) {
218 const line =
219 mood === 'cheer'
220 ? `${name} is cheering - tests passed`
221 : mood === 'sweat'
222 ? `${name} is sweating - ${tool} failed`
223 : `${name} bounces - ${tool} worked`
224 await setMood($, mood, line)
225 }
226}
227
228export const register: Register = on => {
229 // ------------------------------------------------------------ events
230
231 on('session.start', async ($, e, next) => {
232 stopTimers()
233 await $.command.register({
234 name: 'pet',
235 description: 'Your pixel pet: stats, name, hide, show, share card',
236 argumentHint: '[name <name> | hide | show | card]',
237 immediate: true,
238 })
239 try {
240 // First run: this saves a fresh egg with its hatch seed, so every open
241 // session rolls the same pet. Later runs: this loads the saved pet.
242 await commit($, pet => same(pet.hatch !== undefined || pet.seed !== undefined ? pet : { ...pet, seed: newSeed() }))
243 await update($, animState, () => IDLE)
244 await update($, workingState, () => false)
245 await touch($)
246 } catch {
247 // No pet this session (the store could not be read); the session goes on.
248 }
249 return next(e)
250 })
251
252 on('session.end', async ($, e, next) => {
253 if (e.reason !== 'clear') stopTimers()
254 return next(e)
255 })
256
257 // The desktop app attaches its client after the session starts, and may
258 // come and go: timers follow whether anything draws the band.
259 on('session.attach', async ($, e, next) => {
260 const attached = await next(e)
261 try {
262 await touch($)
263 await syncTicker($)
264 } catch {
265 // The next event starts them instead.
266 }
267 return attached
268 })
269
270 on('session.detach', async ($, e, next) => {
271 const detached = await next(e)
272 try {
273 await syncTicker($)
274 await armSleep($)
275 } catch {
276 // The next event stops them instead.
277 }
278 return detached
279 })
280
281 on('turn.start', async ($, e, next) => {
282 try {
283 await update($, workingState, () => true)
284 await touch($)
285 await syncTicker($)
286 } catch {
287 // The pet never holds up a turn.
288 }
289 return next(e)
290 })
291
292 // Every finished tool call: count it, then react to how it went. The pet
293 // never refuses or changes a call. If the bookkeeping fails, the .catch
294 // hands back the result the tool already produced (next.called replays it;
295 // nothing runs twice), so a broken store never costs you a tool call.
296 on('tool.call', async ($, e, next) => {
297 const ran = await next(e)
298 if (ran.deny !== undefined) return ran
299 const hasFailed = ran.isError === true
300 const hasPassedTests = !hasFailed && e.tool === 'Bash' && detectTestPass(e.command, ran.text ?? '')
301 const change = await commit($, (pet, now) => applyToolCall(pet, { hasPassedTests }, now, cryptoU32))
302 await touch($)
303 await react($, change, hasFailed ? 'sweat' : hasPassedTests ? 'cheer' : 'happy', toolLabel(String(e.tool)))
304 return ran
305 }).catch(($, e, next) => next(e))
306
307 on('turn.complete', async ($, e, next) => {
308 const done = await next(e)
309 // A subagent's run raises turn.complete too; only your own turns count.
310 if (e.agentId !== undefined) return done
311 try {
312 await update($, workingState, () => false)
313 if (e.reason === 'answer') await react($, await commit($, pet => applyTurn(pet)))
314 await touch($)
315 await syncTicker($)
316 } catch {
317 // XP can wait for the next turn.
318 }
319 return done
320 })
321
322 // ------------------------------------------------------------ /pet
323
324 // /pet and /pet card answer with text (their rows draw the pet). The rest
325 // answer with a toast and no text, so the model never reads them.
326 on('command.run', { command: 'pet' }, async ($, e) => {
327 const args = e.args.trim()
328 const verb = (args.split(/\s+/)[0] ?? '').toLowerCase()
329
330 // The two display verbs only read: a write here could undo another
331 // session's change made a moment before.
332 if (verb === '' || verb === 'card') {
333 const now = await $.clock.now()
334 const pet = normalizePet(await $.store.get(STORE_KEY), now)
335 return { text: verb === 'card' ? `\`\`\`\n${cardText(pet, now)}\n\`\`\`` : statText(pet, now) }
336 }
337 if (verb === 'name') {
338 const result = cleanName(args.slice(verb.length))
339 if ('error' in result) {
340 $.ui.toast(result.error)
341 return {}
342 }
343 const { pet } = await commit($, pet => same({ ...pet, name: result.name }))
344 $.ui.toast(pet.hatch === undefined ? `The egg will hatch as ${result.name}.` : `Your pet is now called ${result.name}.`)
345 return {}
346 }
347 if (verb === 'hide') {
348 const { pet } = await commit($, pet => same({ ...pet, hidden: true }))
349 stopTimers()
350 $.ui.toast(`${nameOf(pet)} is hidden. /pet show brings the band back.`)
351 return {}
352 }
353 if (verb === 'show') {
354 const { pet } = await commit($, pet => same({ ...pet, hidden: false }))
355 await update($, animState, () => IDLE)
356 await touch($)
357 await syncTicker($)
358 $.ui.toast(`${nameOf(pet)} is back above your prompt.`)
359 return {}
360 }
361 $.ui.toast(HELP)
362 return {}
363 })
364
365 // ------------------------------------------------------------ drawing
366
367 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
368 if (e.props.hasSurvey) return next(e)
369 const pet = await read($, petState)
370 if (pet === null || pet.hidden) return next(e)
371 const anim = await read($, animState)
372 const isWorking = e.props.isWorking || (await read($, workingState))
373 const frame = drawFrame(viewOf(pet, anim), anim.mood, anim.tick, isWorking)
374 const cells = toCells(frame)
375 const accent = accentOf(pet)
376 const line = anim.line !== '' ? anim.line : idleLine(pet, anim.mood, isWorking)
377 const { Box, Text } = $.ui.resolve(e)
378 // Other mods share this band. Their part comes first and the pet after:
379 // a band taller than its rows scrolls its end out of view, and a
380 // decorative pet must never push another mod's buttons out of sight.
381 const below = await next(e)
382
383 // Too narrow or too short for the sprite: one line instead.
384 const width = e.props.bodyColumns
385 if (width < frame.w + 20 || e.props.maxRows < cells.length) {
386 return (
387 <Box flexDirection="column">
388 {below}
389 <Box key="band">{barLine({ Box, Text }, e.surface, bar(progressOf(pet), 5), accent, ` ${line}`)}</Box>
390 </Box>
391 )
392 }
393
394 let sprite =
395 e.surface === 'desktop' ? spriteCells({ Box, Text }, cells, 'sprite') : spriteRows({ Box, Text }, cells, 'sprite', '\u00a0')
396 if (e.surface === 'terminal') {
397 // The terminal has a cell grid element: one Raster, not a Text per run.
398 const { Raster } = $.ui.resolve(e)
399 sprite = <Raster key="sprite" columns={frame.w} rows={cells.length} cells={rasterCells(cells)} />
400 }
401 const barWidth = Math.max(6, Math.min(16, width - frame.w - 16))
402 return (
403 <Box flexDirection="column">
404 {below}
405 <Box key="band" flexDirection="row" gap={2} alignItems="center">
406 {sprite}
407 <Box key="info" flexDirection="column">
408 <Text wrap="truncate-end">
409 <Text bold>{nameOf(pet)}</Text>
410 {' '}
411 {pet.hatch === undefined ? (
412 <Text dimColor>{bandTag(pet)}</Text>
413 ) : (
414 <Text color={RARITY_COLOR[rarityOf(pet.hatch.species)]}>{rarityOf(pet.hatch.species)}</Text>
415 )}
416 {pet.hatch === undefined ? null : <Text dimColor>{` - ${stageOf(pet)}`}</Text>}
417 {pet.hatch?.shiny === true ? <Text color={RARITY_COLOR.legendary}>{' - shiny'}</Text> : null}
418 </Text>
419 <Text wrap="truncate-end">{line}</Text>
420 {barLine({ Box, Text }, e.surface, bar(progressOf(pet), barWidth), accent, <Text dimColor>{` ${shortProgress(pet)}`}</Text>)}
421 </Box>
422 </Box>
423 </Box>
424 )
425 })
426
427 // /pet and /pet card rows: the pet as it was when the command ran, read
428 // back from the row's own text (never live state, so old rows stay put).
429 on('ui.render', { component: 'CommandOutput', props: { command: 'pet' } }, async ($, e, next) => {
430 const info = parseCard(e.props.text)
431 if (info === undefined || e.props.isErrored) return next(e)
432 const { Box, Text, Button } = $.ui.resolve(e)
433 const blank = e.surface === 'terminal' ? ' ' : '\u00a0'
434 // The row's text is ours, but it is drawn, so it is drawn safe.
435 const body = e.props.text
436 .replace(/^```\n?/, '')
437 .replace(/\n?```\s*$/, '')
438 .split('\n')
439 .map(line => safeText(line, 400))
440 .join('\n')
441
442 if (e.props.args.trim().toLowerCase() === 'card') {
443 // Draw exactly what Copy puts on the clipboard, in the theme's own text
444 // colour so it reads on light and dark backgrounds alike.
445 const copy = async (surface: typeof e.surface) => {
446 const done = await $.ui.copy({ text: body, surface })
447 $.ui.toast(done.isCopied ? 'Card copied - paste it anywhere.' : 'No clipboard here - select the card text to copy it.')
448 }
449 // The card is monospace art (its border, the sprite). The desktop's Text is
450 // proportional and switches a row with block characters to the code font, so
451 // its edges would not line up: there every character takes
452 // one code-font cell.
453 return (
454 <Box key="card" flexDirection="column">
455 {body.split('\n').map(text => (e.surface === 'desktop' ? monoRow({ Box, Text }, text) : <Text>{text.replace(/ /g, blank)}</Text>))}
456 <Button key="copy" label="Copy card" onPress={press => void copy(press.surface)} />
457 </Box>
458 )
459 }
460
461 const portrait = drawFrame({ ...info }, info.stage === 'egg' ? 'idle' : 'happy', 1, false)
462 const lines = body.split('\n')
463 return (
464 <Box key="stats" flexDirection="row" gap={2} alignItems="center">
465 {e.surface === 'desktop'
466 ? spriteCells({ Box, Text }, toCells(portrait), 'portrait')
467 : spriteRows({ Box, Text }, toCells(portrait), 'portrait', blank)}
468 <Box flexDirection="column">
469 {lines.map((text, i) =>
470 i === 0 ? (
471 <Text bold>{text}</Text>
472 ) : (
473 <Text dimColor={i > 1}>{text}</Text>
474 ),
475 )}
476 </Box>
477 </Box>
478 )
479 })
480}
481
482// -------------------------------------------------------------- helpers
483
484function viewOf(pet: HatchlingPet, anim: HatchlingAnim): View {
485 const view: View = {
486 stage: stageOf(pet),
487 shiny: pet.hatch?.shiny ?? false,
488 eggCalls: pet.eggCalls,
489 }
490 if (pet.hatch !== undefined) view.species = pet.hatch.species
491 if (anim.mood === 'evolve' && anim.from !== undefined) view.from = anim.from
492 return view
493}
494
495function speciesAccent(kind: HatchlingKind, shiny: boolean): string {
496 const info = petInfo(kind)
497 return shiny ? info.shinyAccent : info.accent
498}
499
500function accentOf(pet: HatchlingPet): string {
501 return pet.hatch === undefined ? EGG_ACCENT : speciesAccent(pet.hatch.species, pet.hatch.shiny)
502}
503
504function idleLine(pet: HatchlingPet, mood: HatchlingMood, isWorking: boolean): string {
505 const name = nameOf(pet)
506 if (pet.hatch === undefined) {
507 if (mood === 'sleep') return 'The egg is dreaming'
508 return isWorking ? 'The egg is wobbling...' : 'Something stirs inside the egg'
509 }
510 if (mood === 'sleep') return `${name} is asleep`
511 return isWorking ? `${name} is watching Claude work` : `${name} is hanging out`
512}
513
514function bar(progress: number, width: number): string {
515 const filled = Math.round(Math.max(0, Math.min(1, progress)) * width)
516 return '█'.repeat(filled) + '░'.repeat(width - filled)
517}
518
519/**
520 * A progress bar and the words after it. The terminal keeps one Text. On the desktop
521 * a Text holding block characters is drawn in the code font, words and all, and
522 * overflows (there the bar is one code-font cell per glyph,
523 * apart from the words.
524 */
525function barLine(tags: Tags, surface: string, glyphs: string, color: string, words: RenderNode) {
526 const { Box, Text } = tags
527 if (surface !== 'desktop') {
528 return (
529 <Text wrap="truncate-end">
530 <Text color={color}>{glyphs}</Text>
531 {words}
532 </Text>
533 )
534 }
535 return (
536 <Box flexDirection="row">
537 {glyphRow(tags, surface, glyphs, { color })}
538 <Text wrap="truncate-end">{words}</Text>
539 </Box>
540 )
541}
542
543/** The egg's countdown, after its name. */
544function bandTag(pet: HatchlingPet): string {
545 const left = HATCH_AT - pet.eggCalls
546 return `hatches in ${left} tool call${left === 1 ? '' : 's'}`
547}
548
549function shortProgress(pet: HatchlingPet): string {
550 if (pet.hatch === undefined) return `${pet.eggCalls}/${HATCH_AT} to hatch`
551 const stage = stageOf(pet)
552 if (stage === 'adult') return `${pet.xp} XP`
553 return `${pet.xp}/${stage === 'baby' ? TEEN_XP : ADULT_XP} XP`
554}
555
556/**
557 * The desktop's sprite: one code-font cell per character, as bolt-draw.tsx draws
558 * Bolt. Its Text is proportional, so rows of Text runs drift out of line.
559 */
560/** One line of monospace art on the desktop: every character in its own code-font cell, a run of spaces one gap as wide. */
561function monoRow({ Box, Text }: Tags, text: string) {
562 return (
563 <Box flexDirection="row" flexWrap="nowrap">
564 {(text.match(/ +|[^ ]/gu) ?? []).map(run => (
565 // A gap keeps its spaces as text, so the card still reads (and selects) as written.
566 <Box width={[...run].length}>
567 <Text>{run}</Text>
568 </Box>
569 ))}
570 </Box>
571 )
572}
573
574function spriteCells({ Box, Text }: Tags, cells: readonly (readonly Cell[])[], key: string) {
575 return (
576 <Box key={key} flexDirection="column" flexShrink={0}>
577 {cells.map(line => (
578 <Box flexDirection="row" flexWrap="nowrap">
579 {line.map(cell => {
580 if (cell.fg === undefined && cell.bg === undefined) return <Box width={1} />
581 const paint: TextProps = {}
582 if (cell.fg !== undefined) paint.color = cell.fg
583 if (cell.bg !== undefined) paint.backgroundColor = cell.bg
584 return (
585 <Box width={1}>
586 <Text {...paint}>{cell.ch}</Text>
587 </Box>
588 )
589 })}
590 </Box>
591 ))}
592 </Box>
593 )
594}
595
596/** Text-cell sprite for the terminal's /pet rows and surfaces with no cell grid: one Text per row of runs. */
597function spriteRows({ Box, Text }: Tags, cells: readonly (readonly Cell[])[], key: string, blank: string) {
598 return (
599 <Box key={key} flexDirection="column">
600 {cells.map(line => (
601 <Text>
602 {toRuns(line).map(run => {
603 const text = run.text.replace(/ /g, blank)
604 if (run.fg === undefined && run.bg === undefined) return text
605 const paint: TextProps = {}
606 if (run.fg !== undefined) paint.color = run.fg
607 if (run.bg !== undefined) paint.backgroundColor = run.bg
608 return <Text {...paint}>{text}</Text>
609 })}
610 </Text>
611 ))}
612 </Box>
613 )
614}
615hooks/art.ts 325 lines1// Turns the art in sprites.ts (and Bolt's egg and the secret Bolt pet, from bolt.ts)
2// into pixels for one mood and frame, then into text cells (half-blocks: two pixels
3// per cell) for every surface. Pure.
4
5import type { HatchlingKind, HatchlingMood, HatchlingRarity, HatchlingSpecies, HatchlingStage } from '../types'
6import { BOLT_PALETTE, BOLT_SPRITES, boltCells } from './bolt'
7import type { BoltSmallPose } from './bolt'
8import { BOLT_MOODS, BOLT_PET, CANVAS, CONFETTI, EGG_WORKING, FX, MOODS, SPECIES } from './sprites'
9import type { MoodArt, Pose, SpeciesArt } from './sprites'
10
11/** A pixel: a #rrggbb colour, or null where nothing is drawn. */
12export type Px = string | null
13
14/** A letter drawn over a whole cell (the sleep z), in cell coordinates. */
15export type Glyph = { col: number; row: number; ch: string; color: string }
16
17/**
18 * One drawn frame. `ink` marks the pixels a one-colour copy shows: the body,
19 * but not the eyes or an open mouth, which read as holes in a silhouette.
20 */
21export type Frame = { w: number; h: number; px: Px[][]; ink: boolean[][]; glyphs: Glyph[] }
22
23/** One text cell: a character and its colours (absent = the surface's own). */
24export type Cell = { ch: string; fg?: string; bg?: string }
25
26/** Just what the art needs to know about the pet. */
27export type View = {
28 stage: HatchlingStage
29 species?: HatchlingKind
30 shiny: boolean
31 eggCalls: number
32 /** The stage before an evolution (the evolve flash shows both). */
33 from?: HatchlingStage
34}
35
36/** Tool calls an egg takes to hatch (pet.ts's rules read it from here, as the cracks do). */
37export const HATCH_AT = 25
38
39/** The egg shows crack 1, 2 and 3 from a quarter, half and three quarters of the way to hatching. */
40export const CRACKS = [0.25, 0.5, 0.75] as const
41
42const UPPER = '▀' // ▀
43const LOWER = '▄' // ▄
44const FULL = '█' // █
45
46export function speciesArt(id: HatchlingSpecies): SpeciesArt {
47 const art = SPECIES.find(one => one.id === id)
48 if (art === undefined) throw new Error(`unknown species ${id}`)
49 return art
50}
51
52/** What the band, cards and toasts say about a hatched pet: a species, or the secret Bolt. */
53export function petInfo(kind: HatchlingKind): { name: string; rarity: HatchlingRarity; blurb: string; accent: string; shinyAccent: string } {
54 return kind === 'bolt' ? BOLT_PET : speciesArt(kind)
55}
56
57/** Which frames play: a mood's own, or the working loop while idle. */
58export function moodArt(mood: HatchlingMood, isWorking: boolean, stage: HatchlingStage): MoodArt {
59 if (stage === 'egg') {
60 if (mood === 'sleep') return MOODS.sleep
61 return isWorking ? EGG_WORKING : { hold: 1, frames: [{ egg: true }] }
62 }
63 if (mood === 'idle' && isWorking) return MOODS.working
64 return MOODS[mood]
65}
66
67export function poseAt(art: MoodArt, tick: number): Pose {
68 const index = Math.floor(Math.max(0, tick) / art.hold) % art.frames.length
69 return art.frames[index] ?? {}
70}
71
72/** The canvas a view draws into: its stage's (the evolve flash draws the old stage inside the new one's). */
73export function canvasOf(view: View): { w: number; h: number } {
74 return CANVAS[view.stage]
75}
76
77/** Draws one frame of the pet. */
78export function drawFrame(view: View, mood: HatchlingMood, tick: number, isWorking: boolean): Frame {
79 const { w, h } = canvasOf(view)
80 const frame: Frame = {
81 w,
82 h,
83 px: Array.from({ length: h }, () => Array<Px>(w).fill(null)),
84 ink: Array.from({ length: h }, () => Array<boolean>(w).fill(false)),
85 glyphs: [],
86 }
87 const pose = poseAt(moodArt(mood, isWorking, view.stage), tick)
88
89 if (view.stage === 'egg' || pose.egg === true || view.species === undefined) {
90 drawBolt(frame, eggPose(view, mood, pose))
91 drawZ(frame, pose)
92 return frame
93 }
94
95 if (view.species === 'bolt') {
96 // The secret pet keeps Bolt's own poses at every stage, never shiny.
97 const art = BOLT_MOODS[mood === 'idle' && isWorking ? 'working' : mood]
98 drawBolt(frame, art.frames[Math.floor(Math.max(0, tick) / art.hold) % art.frames.length] ?? 'idle')
99 if (pose.shell === true) drawShell(frame)
100 return frame
101 }
102
103 const art = speciesArt(view.species)
104 const stage = pose.use === 'from' && view.from !== undefined && view.from !== 'egg' ? view.from : view.stage
105 const rows = stage === 'baby' ? art.baby : stage === 'teen' ? art.teen : stage === 'adult' ? art.adult : art.baby
106 const palette = view.shiny ? art.shiny : art.palette
107 const face = palette[art.face] ?? null
108 const bodyW = rows[0]?.length ?? 0
109 const x0 = Math.floor((w - bodyW) / 2) + (pose.dx ?? 0)
110 const y0 = h - rows.length + (pose.dy ?? 0)
111
112 rows.forEach((row, y) => {
113 for (let x = 0; x < row.length; x += 1) {
114 const letter = row[x] ?? '.'
115 const [color, isHole] = colorOf(letter, pose, palette, face)
116 if (color === null) continue
117 put(frame, x0 + x, y0 + y, pose.white === true ? FX.flash : color, !isHole)
118 }
119 })
120
121 if (pose.shell === true) drawShell(frame)
122 if (pose.drop !== undefined) {
123 const x = Math.min(w - 1, x0 + bodyW)
124 put(frame, x, y0 + 1 + pose.drop, FX.sweat, true)
125 put(frame, x, y0 + 2 + pose.drop, FX.sweat, true)
126 }
127 if (pose.confetti !== undefined) drawConfetti(frame, pose.confetti)
128 drawZ(frame, pose)
129 return frame
130}
131
132function drawZ(frame: Frame, pose: Pose): void {
133 if (pose.z === undefined) return
134 frame.glyphs.push({ col: frame.w - 1, row: pose.z === 'Z' ? 0 : 1, ch: pose.z, color: FX.zzz })
135}
136
137/** The colour of one sprite letter under a pose, and whether it is a hole. */
138function colorOf(
139 letter: string,
140 pose: Pose,
141 palette: Readonly<Record<string, string>>,
142 face: Px,
143): [Px, boolean] {
144 const eye = palette.E ?? FX.eye
145 const eyes = pose.eyes ?? 'open'
146 switch (letter) {
147 case '.':
148 return [null, false]
149 case 'E':
150 return eyes === 'closed' ? [face, false] : [eye, true]
151 case 'e':
152 return eyes === 'happy' ? [face, false] : [eye, true]
153 case 'm':
154 if (pose.mouth === 'open') return [palette.m ?? FX.mouth, true]
155 if (pose.mouth === 'flat') return [palette.o ?? FX.eye, true]
156 return [face, false]
157 case 'h':
158 return pose.blush === true ? [palette.h ?? FX.cheek, false] : [face, false]
159 default:
160 return [palette[letter] ?? null, false]
161 }
162}
163
164function put(frame: Frame, x: number, y: number, color: string, isInk: boolean): void {
165 const row = frame.px[y]
166 const inkRow = frame.ink[y]
167 if (row === undefined || inkRow === undefined || x < 0 || x >= frame.w) return
168 row[x] = color
169 inkRow[x] = isInk
170}
171
172/**
173 * Which of Bolt's egg poses a frame shows: the lid lifting off as it hatches, the
174 * cracks from a quarter, half and three quarters of the way, else upright or leaning
175 * with the wobble. Bolt has no cracked lean, so a cracked egg keeps its crack on
176 * every frame of the wobble.
177 */
178function eggPose(view: View, mood: HatchlingMood, pose: Pose): BoltSmallPose {
179 if (pose.split === true) return 'egg-hatch'
180 if (mood === 'hatch') return 'egg-crack-3'
181 if (view.eggCalls >= CRACKS[2] * HATCH_AT) return 'egg-crack-3'
182 if (view.eggCalls >= CRACKS[1] * HATCH_AT) return 'egg-crack-2'
183 if (view.eggCalls >= CRACKS[0] * HATCH_AT) return 'egg-crack-1'
184 const lean = pose.skew ?? 0
185 return lean < 0 ? 'egg-wobble-l' : lean > 0 ? 'egg-wobble-r' : 'egg'
186}
187
188/**
189 * One of Bolt's small poses (brand/bolt-sprites): the egg, or the secret pet.
190 * Drawn exactly as Bolt is drawn, bottom-centred: never tilted, split or recoloured
191 * here; its own poses lean, crack, hatch and move. Its letters (the sleep z) come
192 * from bolt.ts's own cells, so they sit where Bolt puts them.
193 */
194function drawBolt(frame: Frame, pose: BoltSmallPose): void {
195 const rows: readonly string[] = BOLT_SPRITES.small[pose]
196 const x0 = Math.floor((frame.w - (rows[0]?.length ?? 0)) / 2)
197 const y0 = frame.h - rows.length
198 rows.forEach((row, y) => {
199 for (let x = 0; x < row.length; x += 1) {
200 const letter = row[x] ?? '.'
201 // The crack's and the eyes' ink is a hole in the one-colour copy.
202 if (letter !== '.') put(frame, x0 + x, y0 + y, BOLT_PALETTE[letter as keyof typeof BOLT_PALETTE], letter !== 'E')
203 }
204 })
205 boltCells('small', pose).forEach((line, row) =>
206 line.forEach((cell, col) => {
207 if (![' ', UPPER, LOWER, FULL].includes(cell.ch)) {
208 frame.glyphs.push({ col: x0 + col, row: Math.floor(y0 / 2) + row, ch: cell.ch, color: cell.fg ?? FX.zzz })
209 }
210 }),
211 )
212}
213
214/** Bits of Bolt's eggshell around the newly hatched pet. */
215function drawShell(frame: Frame): void {
216 const { w, h } = frame
217 const bits: readonly (readonly [number, number, keyof typeof BOLT_PALETTE])[] = [
218 [0, h - 1, 'B'], [1, h - 1, 'S'], [0, h - 2, 'B'],
219 [w - 1, h - 1, 'B'], [w - 2, h - 1, 'S'],
220 ]
221 for (const [x, y, letter] of bits) put(frame, x, y, BOLT_PALETTE[letter], true)
222}
223
224function drawConfetti(frame: Frame, pattern: number): void {
225 const spots = CONFETTI[pattern % CONFETTI.length] ?? []
226 spots.forEach(([x, y], i) => {
227 // The patterns are laid out for a 10-wide canvas; stretch to this one.
228 const sx = Math.round((x * (frame.w - 1)) / 9)
229 if (frame.px[y]?.[sx] === null) {
230 put(frame, sx, y, FX.confetti[(i + pattern) % FX.confetti.length] ?? FX.flash, true)
231 }
232 })
233}
234
235/** Packs a frame into text cells, two pixel rows per cell row. */
236export function toCells(frame: Frame): Cell[][] {
237 const out: Cell[][] = []
238 for (let r = 0; r < frame.h / 2; r += 1) {
239 const line: Cell[] = []
240 for (let c = 0; c < frame.w; c += 1) {
241 const glyph = frame.glyphs.find(one => one.row === r && one.col === c)
242 if (glyph !== undefined) {
243 line.push({ ch: glyph.ch, fg: glyph.color })
244 continue
245 }
246 const top = frame.px[r * 2]?.[c] ?? null
247 const bottom = frame.px[r * 2 + 1]?.[c] ?? null
248 if (top === null && bottom === null) line.push({ ch: ' ' })
249 else if (bottom === null) line.push({ ch: UPPER, fg: top ?? undefined })
250 else if (top === null) line.push({ ch: LOWER, fg: bottom })
251 else if (top === bottom) line.push({ ch: FULL, fg: top })
252 else line.push({ ch: UPPER, fg: top, bg: bottom })
253 }
254 out.push(line)
255 }
256 return out
257}
258
259/** Merges a row of cells into runs of one style, for Text spans. */
260export function toRuns(line: readonly Cell[]): { text: string; fg?: string; bg?: string }[] {
261 const runs: { text: string; fg?: string; bg?: string }[] = []
262 for (const cell of line) {
263 const last = runs[runs.length - 1]
264 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.ch
265 else runs.push({ text: cell.ch, fg: cell.fg, bg: cell.bg })
266 }
267 return runs
268}
269
270/** RasterProps.cells: base64 of little-endian u32 [codePoint, fg, bg] triplets. */
271export function rasterCells(cells: readonly (readonly Cell[])[]): string {
272 const DEFAULT = 0x01000000
273 const count = cells.reduce((sum, line) => sum + line.length, 0)
274 const view = new DataView(new ArrayBuffer(count * 12))
275 let at = 0
276 for (const line of cells) {
277 for (const cell of line) {
278 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
279 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
280 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
281 at += 12
282 }
283 }
284 let binary = ''
285 const bytes = new Uint8Array(view.buffer)
286 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
287 return btoa(binary)
288}
289
290/** Unpacks RasterProps.cells back into characters (for tests and checks). */
291export function rasterText(base64: string, columns: number): string[] {
292 const binary = atob(base64)
293 const bytes = new Uint8Array(binary.length)
294 for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
295 const view = new DataView(bytes.buffer)
296 const lines: string[] = []
297 let line = ''
298 for (let at = 0; at + 12 <= bytes.length; at += 12) {
299 line += String.fromCodePoint(view.getUint32(at, true))
300 if (line.length === columns) {
301 lines.push(line)
302 line = ''
303 }
304 }
305 return lines
306}
307
308/**
309 * The frame as plain characters with no colour: a silhouette that pastes
310 * anywhere monospace (chat, an issue, a tweet). Eyes and mouth are holes.
311 */
312export function monoLines(frame: Frame): string[] {
313 const lines: string[] = []
314 for (let r = 0; r < frame.h / 2; r += 1) {
315 let line = ''
316 for (let c = 0; c < frame.w; c += 1) {
317 const top = frame.ink[r * 2]?.[c] === true
318 const bottom = frame.ink[r * 2 + 1]?.[c] === true
319 line += top && bottom ? FULL : top ? UPPER : bottom ? LOWER : ' '
320 }
321 lines.push(line)
322 }
323 return lines
324}
325hooks/cells.tsx 24 lines1// Glyph art (ants, progress bars) on the desktop app. Its Text is proportional, and a
2// Text that holds block characters is drawn in the code font, so words beside the art
3// change font and overflow, and rows of art drift against each other.
4// There the art is drawn one code-font cell per character, apart from the words.
5
6import type { BoxProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
7
8type Elements = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
9
10/** One row of glyph art: a plain Text on the terminal, a row of one-cell Boxes on the desktop. */
11export function glyphRow(elements: Elements, surface: string, text: string, paint: TextProps = {}): RenderElement {
12 const { Box, Text } = elements
13 if (surface !== 'desktop') return <Text {...paint}>{text}</Text>
14 const cells: RenderElement[] = []
15 for (const run of text.match(/ +|[^ ]/gu) ?? []) {
16 cells.push(run.startsWith(' ') ? <Box width={run.length} /> : <Box width={1}><Text {...paint}>{run}</Text></Box>)
17 }
18 return (
19 <Box flexDirection="row" flexWrap="nowrap" flexShrink={0}>
20 {cells}
21 </Box>
22 )
23}
24hooks/pet.ts 393 lines1// The pet's rules, kept pure so they can be tested on their own: hatching,
2// the one-time roll, experience and evolution, the test-run detector, names,
3// and the text of the stat card and the share card.
4
5import type { HatchlingKind, HatchlingPet, HatchlingRarity, HatchlingStage } from '../types'
6import { drawFrame, HATCH_AT, monoLines, petInfo } from './art'
7import { BOLT_PET, SPECIES } from './sprites'
8
9// The hatch count lives with the art, which cracks the egg at fractions of it.
10export { HATCH_AT }
11export const TEEN_XP = 200
12export const ADULT_XP = 800
13export const XP = { tool: 1, turn: 5, tests: 5 } as const
14export const DAY_MS = 86_400_000
15
16/** Rarity odds out of 64, so the top 6 bits of one random u32 pick exactly. */
17export const RARITY_ODDS: readonly (readonly [HatchlingRarity, number])[] = [
18 ['common', 36],
19 ['uncommon', 18],
20 ['rare', 8],
21 ['legendary', 2],
22]
23
24/** One egg in this many hatches the secret Bolt instead of a species. */
25export const BOLT_ODDS = 100
26
27/** Names a pet can hatch with when you have not chosen one. */
28export const NAMES: readonly string[] = [
29 'Pip', 'Mochi', 'Bean', 'Nib', 'Tofu', 'Fig', 'Nori', 'Juniper', 'Biscuit', 'Clover',
30 'Sprout', 'Waffle', 'Ziggy', 'Momo', 'Dot', 'Kiwi', 'Bao', 'Puck', 'Tuffet', 'Quill',
31 'Sable', 'Echo', 'Maple', 'Pocket',
32]
33
34export type Change = {
35 pet: HatchlingPet
36 isHatched: boolean
37 evolved?: { from: HatchlingStage; to: HatchlingStage }
38}
39
40export function freshPet(now: number): HatchlingPet {
41 return { v: 1, bornAt: now, eggCalls: 0, xp: 0, hidden: false, toolCalls: 0, turns: 0, testsPassed: 0 }
42}
43
44/**
45 * Reads whatever the store held into a valid pet, field by field, so a
46 * damaged or older record keeps everything that still makes sense. A valid
47 * hatch is always kept: a pet is never rolled twice.
48 */
49export function normalizePet(raw: unknown, now: number): HatchlingPet {
50 const pet = freshPet(now)
51 if (raw === null || typeof raw !== 'object') return pet
52 const r = raw as Record<string, unknown>
53 const count = (value: unknown) =>
54 typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0
55 if (typeof r.bornAt === 'number' && Number.isFinite(r.bornAt) && r.bornAt <= now) pet.bornAt = r.bornAt
56 pet.eggCalls = Math.min(HATCH_AT, count(r.eggCalls))
57 pet.xp = count(r.xp)
58 pet.toolCalls = count(r.toolCalls)
59 pet.turns = count(r.turns)
60 pet.testsPassed = count(r.testsPassed)
61 pet.hidden = r.hidden === true
62 const seed = r.seed
63 if (Array.isArray(seed) && seed.length === 4 && seed.every(n => Number.isInteger(n) && n >= 0 && n < 2 ** 32)) {
64 pet.seed = seed as number[]
65 }
66 if (typeof r.name === 'string') {
67 const name = cleanName(r.name)
68 if ('name' in name) pet.name = name.name
69 }
70 const hatch = r.hatch as Record<string, unknown> | undefined
71 const kind = hatch?.species
72 if (hatch !== null && typeof hatch === 'object' && (kind === BOLT_PET.id || SPECIES.some(one => one.id === kind))) {
73 pet.hatch = {
74 species: kind as HatchlingKind,
75 // Bolt is never shiny, whatever was saved (BRAND.md: never recolour Bolt).
76 shiny: kind !== BOLT_PET.id && hatch.shiny === true,
77 at: typeof hatch.at === 'number' && Number.isFinite(hatch.at) ? hatch.at : now,
78 }
79 pet.eggCalls = HATCH_AT
80 }
81 return pet
82}
83
84export function stageOf(pet: HatchlingPet): HatchlingStage {
85 if (pet.hatch === undefined) return 'egg'
86 return pet.xp >= ADULT_XP ? 'adult' : pet.xp >= TEEN_XP ? 'teen' : 'baby'
87}
88
89export function rarityOf(kind: HatchlingKind): HatchlingRarity {
90 return petInfo(kind).rarity
91}
92
93/**
94 * The hatch roll. `u32` returns uniform 32-bit integers (crypto in the mod,
95 * a fixed list in tests). Rarity first (top 6 bits, out of 64), then a
96 * species of that rarity, then shiny at 1 in 64, then a name. The secret Bolt
97 * reads the bits the rarity leaves: the top hundredth of the first number's
98 * low 26 bits, so 1 egg in 100 (rarest at the top, as legendary is).
99 */
100export function rollHatch(u32: () => number): { species: HatchlingKind; shiny: boolean; name: string } {
101 const first = u32() >>> 0
102 const pick = first >>> 26
103 let rarity: HatchlingRarity = 'common'
104 let floor = 0
105 for (const [one, odds] of RARITY_ODDS) {
106 if (pick < floor + odds) {
107 rarity = one
108 break
109 }
110 floor += odds
111 }
112 const pool = SPECIES.filter(one => one.rarity === rarity)
113 const species = pool[Math.floor(((u32() >>> 0) / 2 ** 32) * pool.length)] ?? pool[0] ?? SPECIES[0]
114 const shiny = u32() >>> 26 === 0
115 const name = NAMES[Math.floor(((u32() >>> 0) / 2 ** 32) * NAMES.length)] ?? 'Pip'
116 // The secret Bolt is never shiny (BRAND.md: never recolour Bolt).
117 const isBolt = Math.floor(((first & 0x3ffffff) / 2 ** 26) * BOLT_ODDS) === BOLT_ODDS - 1
118 if (isBolt) return { species: BOLT_PET.id, shiny: false, name }
119 return { species: (species ?? SPECIES[0])!.id, shiny, name }
120}
121
122/** One uniform 32-bit integer from the platform's crypto source. */
123export function cryptoU32(): number {
124 return crypto.getRandomValues(new Uint32Array(1))[0] ?? 0
125}
126
127/** The egg's hatch seed: decided when the egg is laid, read when it hatches. */
128export function newSeed(): number[] {
129 return [cryptoU32(), cryptoU32(), cryptoU32(), cryptoU32()]
130}
131
132/** One finished tool call: the egg counts toward hatching, a pet gains XP. */
133export function applyToolCall(
134 pet: HatchlingPet,
135 outcome: { hasPassedTests: boolean },
136 now: number,
137 u32: () => number,
138): Change {
139 const before = stageOf(pet)
140 const next: HatchlingPet = {
141 ...pet,
142 toolCalls: pet.toolCalls + 1,
143 testsPassed: pet.testsPassed + (outcome.hasPassedTests ? 1 : 0),
144 }
145 if (pet.hatch === undefined) {
146 // The roll reads the egg's seed, so every session hatches the same pet.
147 // An egg saved without one (older versions) gets it on its next call.
148 const seed = pet.seed ?? [u32(), u32(), u32(), u32()]
149 next.seed = seed
150 next.eggCalls = Math.min(HATCH_AT, pet.eggCalls + 1)
151 if (next.eggCalls < HATCH_AT) return { pet: next, isHatched: false }
152 let at = 0
153 const roll = rollHatch(() => seed[at++ % seed.length] ?? 0)
154 next.hatch = { species: roll.species, shiny: roll.shiny, at: now }
155 next.name = pet.name ?? roll.name
156 return { pet: next, isHatched: true }
157 }
158 next.xp = pet.xp + XP.tool + (outcome.hasPassedTests ? XP.tests : 0)
159 return withEvolution(before, next)
160}
161
162/** One finished turn of the main conversation. */
163export function applyTurn(pet: HatchlingPet): Change {
164 const before = stageOf(pet)
165 const next: HatchlingPet = { ...pet, turns: pet.turns + 1 }
166 if (pet.hatch === undefined) return { pet: next, isHatched: false }
167 next.xp = pet.xp + XP.turn
168 return withEvolution(before, next)
169}
170
171function withEvolution(before: HatchlingStage, pet: HatchlingPet): Change {
172 const after = stageOf(pet)
173 return after === before ? { pet, isHatched: false } : { pet, isHatched: false, evolved: { from: before, to: after } }
174}
175
176// ---------------------------------------------------------------- test runs
177
178// Terminal escape sequences: CSI (colours, cursor), OSC (titles, links), and
179// the two-character escapes.
180const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b[@-Z\\-_]/g
181const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
182
183/**
184 * Text from outside the mod (a tool's name, a stored name) made safe to draw:
185 * escape sequences removed, any other control character shown as U+FFFD
186 * (the engine refuses a whole tree that holds one), and the length capped.
187 */
188export function safeText(text: string, max = 200): string {
189 const chars = [...text.replace(ANSI, '').replace(CONTROL, '\ufffd')]
190 return chars.length > max ? `${chars.slice(0, max - 1).join('')}.` : chars.join('')
191}
192
193// Commands that only show text (a log, a file): their output is not a run.
194const VIEWER =
195 /^\s*(cat|bat|head|tail|less|more|grep|rg|ag|echo|printf|sed|awk|jq|wc|ls|find|cd|pwd|git\s+(log|show|diff|blame)|gh\s+run\s+view|docker\s+(compose\s+)?logs|kubectl\s+logs)\b/
196
197const FAILED: readonly RegExp[] = [
198 /\b[1-9]\d* (failed|failing|failures?|errors?)\b/i,
199 /\b(failed|failures|errors)[:=]?\s+[1-9]\d*/i,
200 /^(?:#|ℹ)\s*fail\s+[1-9]\d*/m,
201 /^\s*[1-9]\d* fail\s*$/m,
202 /^FAIL\b/m,
203 /--- FAIL\b/,
204 /\bFAILED\b/,
205]
206
207const PASSED: readonly RegExp[] = [
208 /^\s*Tests?:?\s+(?:\d+ \w+(?:, | \| ))*[1-9]\d* passed\b/m, // jest, vitest
209 /^\s*Test Files\s+[1-9]\d* passed\b/m, // vitest
210 /\b[1-9]\d* passed\b[^\n]*\bin [\d.]+s\b/, // pytest
211 /^ok\s+\S+\s+(?:\(cached\)|[\d.]+s)/m, // go test
212 /^PASS\s*$/m, // go test -v
213 /test result: ok\. [1-9]\d* passed; 0 failed/, // cargo test
214 /^(?:#|ℹ)\s*pass\s+[1-9]\d*/m, // node:test
215 /^\s*[1-9]\d* passing\b/m, // mocha
216 /\b[1-9]\d* (?:examples?|tests?|runs?), 0 failures\b/, // rspec, minitest, ExUnit
217 /^\s*[1-9]\d* pass\s*$/m, // bun test
218 /^ok \| [1-9]\d* passed \| 0 failed/m, // deno test
219 /^OK \([1-9]\d* tests?, /m, // phpunit
220 /^Ran [1-9]\d* tests? in [\d.]+s\s+OK\b/m, // python unittest
221 /Passed!\s+-\s+Failed:\s+0, Passed:\s+[1-9]\d*/, // dotnet test
222 /^\s*[1-9]\d* passed \([\d.]+m?s\)/m, // playwright
223 /\b[1-9]\d* tests? run: [1-9]\d* passed\b/, // cargo nextest
224]
225
226/**
227 * True when a Bash command's output reads as a test run that passed: a
228 * runner's summary with at least one pass and no failures anywhere.
229 */
230export function detectTestPass(command: string, output: string): boolean {
231 // Every step of the chain only shows text (`cd app && cat ci.log`): not a run.
232 const steps = command.split(/&&|\|\||;|\n/).map(step => bareStep(step.split('|')[0] ?? ''))
233 if (steps.every(step => step === '' || VIEWER.test(step))) return false
234 const text = output.replace(ANSI, '')
235 if (FAILED.some(pattern => pattern.test(text))) return false
236 return PASSED.some(pattern => pattern.test(text))
237}
238
239/**
240 * One step of a command without what only wraps it: a subshell's bracket,
241 * `sudo`, `env`, `NAME=value` and `bash -c '...'`, so `sudo cat ci.log`
242 * reads as `cat ci.log`.
243 */
244function bareStep(step: string): string {
245 let bare = step.trim()
246 for (;;) {
247 const next = bare
248 .replace(/^[({]\s*/, '')
249 .replace(/^(sudo|env|exec|time|nice|command)\s+/, '')
250 .replace(/^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/, '')
251 .replace(/^(ba|z)?sh\s+-c\s+['"]?/, '')
252 if (next === bare) return bare
253 bare = next
254 }
255}
256
257// ---------------------------------------------------------------- names
258
259/** Checks a name typed after /pet name. */
260export function cleanName(raw: string): { name: string } | { error: string } {
261 const name = raw
262 .normalize('NFC')
263 .replace(ANSI, '')
264 .replace(/\s+/g, ' ')
265 .replace(CONTROL, '')
266 .trim()
267 if (name === '') return { error: 'Give it a name, for example: /pet name Pip' }
268 if ([...name].length > 16) return { error: 'Names are up to 16 characters.' }
269 if (!/^[\p{L}\p{N}][\p{L}\p{N} '._-]*$/u.test(name)) {
270 return { error: "Names use letters, numbers, spaces and - ' . _" }
271 }
272 return { name }
273}
274
275// ---------------------------------------------------------------- text
276
277export function daysTogether(pet: HatchlingPet, now: number): number {
278 return Math.max(1, Math.floor((now - pet.bornAt) / DAY_MS) + 1)
279}
280
281export function nameOf(pet: HatchlingPet): string {
282 return safeText(pet.name ?? (pet.hatch === undefined ? 'Egg' : 'Pip'), 16)
283}
284
285export function speciesName(pet: HatchlingPet): string {
286 return pet.hatch === undefined ? 'egg' : petInfo(pet.hatch.species).name
287}
288
289/** "Pip the Foldling" (or "Egg the egg" before a name is chosen). */
290export function titleLine(pet: HatchlingPet): string {
291 return pet.hatch === undefined && pet.name === undefined ? 'An egg' : `${nameOf(pet)} the ${speciesName(pet)}`
292}
293
294/** "rare - teen - shiny": the line the card renderer reads back. */
295export function tagLine(pet: HatchlingPet, isLong = false): string {
296 if (pet.hatch === undefined) return `egg - ${pet.eggCalls}/${HATCH_AT} to hatch`
297 const shiny = pet.hatch.shiny ? ' - shiny' : isLong ? ' - not shiny' : ''
298 return `${rarityOf(pet.hatch.species)} - ${stageOf(pet)}${shiny}`
299}
300
301export function xpLine(pet: HatchlingPet): string {
302 if (pet.hatch === undefined) return `${pet.eggCalls} of ${HATCH_AT} tool calls - ${HATCH_AT - pet.eggCalls} more to hatch`
303 const stage = stageOf(pet)
304 if (stage === 'adult') return `XP ${pet.xp} - fully grown`
305 const goal = stage === 'baby' ? TEEN_XP : ADULT_XP
306 return `XP ${pet.xp} - ${goal - pet.xp} more to ${stage === 'baby' ? 'teen' : 'adult'}`
307}
308
309/** Progress toward the next stage, 0 to 1. */
310export function progressOf(pet: HatchlingPet): number {
311 if (pet.hatch === undefined) return pet.eggCalls / HATCH_AT
312 const stage = stageOf(pet)
313 if (stage === 'adult') return 1
314 return stage === 'baby' ? pet.xp / TEEN_XP : (pet.xp - TEEN_XP) / (ADULT_XP - TEEN_XP)
315}
316
317function plural(n: number, word: string): string {
318 return `${n} ${word}${n === 1 ? '' : 's'}`
319}
320
321/** The /pet stat card, as plain text (what the model also reads). */
322export function statText(pet: HatchlingPet, now: number): string {
323 return [
324 titleLine(pet),
325 tagLine(pet, true),
326 xpLine(pet),
327 `${plural(daysTogether(pet, now), 'day')} together - ${plural(pet.toolCalls, 'tool call')} - ${plural(pet.testsPassed, 'test run')} cheered`,
328 pet.hatch === undefined ? 'Something stirs inside.' : petInfo(pet.hatch.species).blurb,
329 ].join('\n')
330}
331
332const CARD_W = 30
333
334/** The /pet card share card: about 30 x 12 plain characters, no colour. */
335export function cardText(pet: HatchlingPet, now: number): string {
336 const inner = CARD_W - 2
337 const pad = (text: string) => {
338 const chars = [...text].slice(0, inner - 2)
339 return `│ ${chars.join('')}${' '.repeat(inner - 2 - chars.length)} │`
340 }
341 const center = (text: string) => {
342 const chars = [...text]
343 const left = Math.max(0, Math.floor((inner - chars.length) / 2))
344 return `│${' '.repeat(left)}${chars.join('')}${' '.repeat(Math.max(0, inner - left - chars.length))}│`
345 }
346 const view = { stage: stageOf(pet), species: pet.hatch?.species, shiny: pet.hatch?.shiny ?? false, eggCalls: pet.eggCalls }
347 // The canvas keeps headroom for jumps; a card has no use for blank rows.
348 const sprite = monoLines(drawFrame(view, 'idle', 0, false))
349 .map(line => line.replace(/\s+$/, ''))
350 .filter(line => line !== '')
351 const width = Math.max(...sprite.map(line => [...line].length))
352 const label = ' hatchling '
353 const rule = '─'.repeat(inner - label.length - 2)
354 // A long name takes two lines: "Bartholomew" then "the Starwhelp".
355 const title = titleLine(pet)
356 const titles =
357 [...title].length <= inner - 2 || pet.hatch === undefined ? [title] : [nameOf(pet), `the ${speciesName(pet)}`]
358 return [
359 `┌${'─'.repeat(inner)}┐`,
360 ...sprite.map(line => center(line + ' '.repeat(width - [...line].length))),
361 ...titles.map(pad),
362 pad(tagLine(pet)),
363 pad(`${plural(daysTogether(pet, now), 'day')} - ${plural(pet.toolCalls, 'tool call')}`),
364 `└${rule}${label}──┘`,
365 ].join('\n')
366}
367
368export type CardInfo = { stage: HatchlingStage; species?: HatchlingKind; shiny: boolean; eggCalls: number }
369
370/**
371 * Reads the pet's look back out of a /pet or /pet card text, so a transcript
372 * row redraws the pet as it was when the command ran, not as it is now.
373 */
374export function parseCard(text: string): CardInfo | undefined {
375 const lines = text.split('\n').map(line => line.replace(/^│\s?|\s*│$/g, '').trim())
376 const tagAt = lines.findIndex(line => /^(egg - \d+\/\d+ to hatch|(common|uncommon|rare|legendary|secret) - (baby|teen|adult)( - (shiny|not shiny))?)$/.test(line))
377 const tag = lines[tagAt]
378 if (tag === undefined) return undefined
379 const egg = /^egg - (\d+)\//.exec(tag)
380 if (egg !== null) return { stage: 'egg', shiny: false, eggCalls: Number(egg[1]) }
381 const above = lines.slice(0, tagAt)
382 const species = [...SPECIES, BOLT_PET].find(one => above.some(line => line === `the ${one.name}` || line.endsWith(` the ${one.name}`)))
383 const stage = /- (baby|teen|adult)\b/.exec(tag)?.[1] as HatchlingStage | undefined
384 if (species === undefined || stage === undefined) return undefined
385 return { stage, species: species.id, shiny: / - shiny$/.test(tag), eggCalls: HATCH_AT }
386}
387
388/** "mcp__server__tool" reads as "tool"; long names are cut. */
389export function toolLabel(tool: string): string {
390 const short = tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool) : tool
391 return safeText(short, 20)
392}
393hooks/sprites.ts 550 lines1// All of hatchling's art, as data. Nothing here runs: art.ts turns it into
2// pixels. Every species is an original design, drawn to read at baby size:
3// a closed outline, light from the top left (a 1-2 px highlight
4// there, shade on the right and bottom), eyes inside the body, no stray pixels.
5//
6// A sprite is a list of rows, one letter per pixel. The renderer packs two
7// pixel rows into one text row with half-block characters, so a sprite 10
8// pixels tall is 5 rows on screen.
9//
10// . transparent o outline a body
11// b body shade c light / belly d f g accents
12// w white E eye (upper) e eye (lower)
13// m mouth h cheek
14//
15// E, e, m and h change with the mood (art.ts): open eyes are E+e, closed eyes
16// keep only e, happy eyes keep only E; the mouth and cheeks show when happy.
17
18import type { HatchlingMood, HatchlingRarity, HatchlingSpecies, HatchlingStage } from '../types'
19import { BOLT_PALETTE } from './bolt'
20import type { BoltSmallPose } from './bolt'
21
22export type Palette = Readonly<Record<string, string>>
23
24export type SpeciesArt = {
25 id: HatchlingSpecies
26 name: string
27 rarity: HatchlingRarity
28 /** One line for the card. */
29 blurb: string
30 /** Letter the closed eye and hidden mouth fall back to. */
31 face: string
32 palette: Palette
33 shiny: Palette
34 /** Colour of the name in the band. */
35 accent: string
36 shinyAccent: string
37 baby: readonly string[]
38 teen: readonly string[]
39 adult: readonly string[]
40}
41
42/** Canvas per stage, in pixels: room for a jump above the body. */
43export const CANVAS: Readonly<Record<HatchlingStage, { w: number; h: number }>> = {
44 egg: { w: 10, h: 10 },
45 baby: { w: 10, h: 10 },
46 teen: { w: 12, h: 10 },
47 adult: { w: 16, h: 12 },
48}
49
50/** Colours shared by every species: eyes, mouth, effects. */
51export const FX = {
52 eye: '#1d1a2b',
53 mouth: '#c8405c',
54 cheek: '#ff8fab',
55 sweat: '#7fd3ff',
56 zzz: '#aab4d8',
57 flash: '#ffffff',
58 confetti: ['#ffd166', '#ff6fae', '#5ee0d0', '#9b8cff'],
59} as const
60
61export const RARITY_COLOR: Readonly<Record<HatchlingRarity, string>> = {
62 common: '#9aa3b5',
63 uncommon: '#5fcf80',
64 rare: '#5aa9ff',
65 legendary: '#ffc93c',
66 secret: BOLT_PALETTE.B,
67}
68
69// ---------------------------------------------------------------- the egg
70
71// The egg is Bolt's, the modmyclaude mascot: its upright, leaning,
72// cracked and hatching poses live in bolt.ts, copied in from brand/bolt-sprites;
73// art.ts picks one per frame.
74
75// ---------------------------------------------------------------- the secret
76
77/**
78 * One egg in 100 hatches Bolt instead of a species. Bolt is drawn from
79 * bolt.ts exactly as Bolt is drawn: never shiny, recoloured, flashed or moved
80 * (BRAND.md), and the same at every stage; only the stage text grows.
81 */
82export const BOLT_PET = {
83 id: 'bolt',
84 name: 'Bolt',
85 rarity: 'secret',
86 blurb: "It was Bolt's egg all along.",
87 accent: BOLT_PALETTE.B,
88 /** Bolt is never shiny: the same lime. */
89 shinyAccent: BOLT_PALETTE.B,
90} as const
91
92/**
93 * Bolt's own small pose for each mood: at rest when idle or watching Claude work,
94 * a jump to bounce on a good tool call, the sweat drop on a failed one, a cheer
95 * for passing tests, a hatch and a stage-up, and asleep after 5 quiet minutes.
96 */
97export const BOLT_MOODS: Readonly<Record<HatchlingMood | 'working', { hold: number; frames: readonly BoltSmallPose[] }>> = {
98 idle: { hold: 1, frames: ['idle'] },
99 working: { hold: 1, frames: ['idle'] },
100 happy: { hold: 1, frames: ['jump', 'idle'] },
101 sweat: { hold: 1, frames: ['sweat'] },
102 cheer: { hold: 1, frames: ['cheer', 'idle'] },
103 sleep: { hold: 1, frames: ['sleep'] },
104 hatch: { hold: 1, frames: ['cheer'] },
105 evolve: { hold: 1, frames: ['cheer'] },
106}
107
108// ---------------------------------------------------------------- species
109
110export const SPECIES: readonly SpeciesArt[] = [
111 {
112 id: 'pebblet',
113 name: 'Pebblet',
114 rarity: 'common',
115 blurb: 'A river stone that grew moss and opinions.',
116 face: 'a',
117 accent: '#8fd17f',
118 shinyAccent: '#e8c27a',
119 palette: { o: '#3d4352', a: '#9aa3b4', b: '#737c90', c: '#c9d0db', d: '#63b35a', f: '#3f7f3a', g: '#f59bbd', w: '#fff6c9' },
120 shiny: { o: '#5b4526', a: '#ddb873', b: '#b48a4a', c: '#f4dfab', d: '#73cfc3', f: '#3f948a', g: '#ffffff', w: '#ffe066' },
121 baby: [
122 '..dddd..',
123 '.ddddfd.',
124 'ocEaaEbo',
125 'oheaaeho',
126 'oaammabo',
127 '.obbbbo.',
128 '..oooo..',
129 ],
130 teen: [
131 '...ddgd...',
132 '.ddddddfd.',
133 'ocaEaaEabo',
134 'oaheaaehbo',
135 'oaaammaabo',
136 'oaaaaaaabo',
137 '.obbbbbbo.',
138 '..oooooo..',
139 ],
140 adult: [
141 '.........g....',
142 '....ddddgwgd..',
143 '..ddddddfgddd.',
144 '.ddfddddddddfd',
145 'ocaaaaaaaaaabo',
146 'oaaaEaaaaEaabo',
147 'oaaheaaaaehabo',
148 'oaaaaammaaaabo',
149 '.obbbbbbbbbbo.',
150 '..oooooooooo..',
151 ],
152 },
153 {
154 id: 'fluffle',
155 name: 'Fluffle',
156 rarity: 'common',
157 blurb: 'Mostly fluff. The rest is also fluff.',
158 face: 'a',
159 accent: '#c6b1f2',
160 shinyAccent: '#8fe3c6',
161 palette: { a: '#b9a3e3', b: '#9480c4', c: '#e9e0ff', d: '#f7ead0', f: '#ff9fc2' },
162 shiny: { a: '#9fe3c9', b: '#6fbf9f', c: '#e2fbf0', d: '#fff4d9', f: '#ffb0c4' },
163 baby: [
164 '..bbbb..',
165 '.bccaab.',
166 'bbaaaabb',
167 'bfEaaEfb',
168 'bfeaaefb',
169 'bfammafb',
170 'bb.bb.bb',
171 ],
172 teen: [
173 '...bbbb...',
174 '..bccaab..',
175 '.bbaaaabb.',
176 'bfaEaaEafb',
177 'bfheaaehfb',
178 'bfaammaafb',
179 'bfbaddabfb',
180 'bb.bbbb.bb',
181 ],
182 adult: [
183 '.....bbbb.....',
184 '...bbccaabb...',
185 '..bccaaaaaab..',
186 '.bbbaaaaaabbb.',
187 'bfbaaEaaEaabfb',
188 'bfbaaeaaeaabfb',
189 'bfbahammahabfb',
190 'bfbaaddddaabfb',
191 '.bbbaddddabbb.',
192 '...bbbbbbbb...',
193 ],
194 },
195 {
196 id: 'coilsnail',
197 name: 'Coilsnail',
198 rarity: 'common',
199 blurb: 'Carries a spool of thread it never unwinds.',
200 face: 'a',
201 accent: '#5cc8b9',
202 shinyAccent: '#f19ac0',
203 palette: { o: '#22535b', a: '#56bfb1', b: '#3a8f84', c: '#9ee8dc', d: '#f6dcae', f: '#d4864a', g: '#9c5426' },
204 shiny: { o: '#6b2f4b', a: '#ee9bbd', b: '#c9729a', c: '#ffd3e6', d: '#e9ecf7', f: '#8d96c9', g: '#555e99' },
205 baby: [
206 'o..o....',
207 'ocaofffg',
208 'oEao.dd.',
209 'oeho.dd.',
210 'omaafffg',
211 'oabbbbbo',
212 '.oooooo.',
213 ],
214 teen: [
215 '.o..o.....',
216 '.o..o.....',
217 'ocaaaofffg',
218 'oaEaao.dd.',
219 'oaehao.dd.',
220 'omaaaafffg',
221 'oaabbbbbbo',
222 '.oooooooo.',
223 ],
224 adult: [
225 '.o...o........',
226 '.o...o..gffffg',
227 'oocaaoo..dddd.',
228 'oaaaaao..dgdd.',
229 'oaEaaao..dddd.',
230 'oaehaao..ddgd.',
231 'omaaaaao.dddd.',
232 'oaaaaaaagffffg',
233 'oabbbbbbbbbbbo',
234 '.oooooooooooo.',
235 ],
236 },
237 {
238 id: 'nimbit',
239 name: 'Nimbit',
240 rarity: 'uncommon',
241 blurb: 'A pocket raincloud. Drizzles when nervous.',
242 face: 'a',
243 accent: '#9fd2ff',
244 shinyAccent: '#ffa8c4',
245 palette: { o: '#7f97c4', a: '#f3f7ff', b: '#cfdcf2', c: '#ffffff', d: '#7cc7ff', f: '#a9bbd9' },
246 shiny: { o: '#b46a7e', a: '#ffe1d3', b: '#f6b9a6', c: '#fff4ee', d: '#ff8fbd', f: '#e5a2ad' },
247 baby: [
248 '...oo...',
249 '..occo..',
250 '.ocaaaoo',
251 'oaEaaEao',
252 'oheaaeho',
253 'oaammabo',
254 '.oooooo.',
255 '..d..d..',
256 ],
257 teen: [
258 '...oooo...',
259 '..occaao..',
260 '.oocaaaaoo',
261 'oacEaaEaao',
262 'oaheaaehbo',
263 'oaaammaabo',
264 '.oooooooo.',
265 '..d..d..d.',
266 ],
267 adult: [
268 '.....oooo.....',
269 '....occaao....',
270 '.oooccaaaaooo.',
271 'occaaaaaaaaaao',
272 'oaaaEaaaaEaaao',
273 'oaaaeaaaaeaabo',
274 'oaahaammaahabo',
275 '.obbbbbbbbbbo.',
276 '..oooooooooo..',
277 '..d..d..d..d..',
278 ],
279 },
280 {
281 id: 'lampwing',
282 name: 'Lampwing',
283 rarity: 'uncommon',
284 blurb: 'Its belly glows brighter the later you work.',
285 face: 'c',
286 accent: '#ffd36b',
287 shinyAccent: '#8fe9ff',
288 palette: { o: '#4a3428', a: '#c99a6b', b: '#93693f', c: '#f2dcb8', d: '#ffd65e', f: '#7a54a6', w: '#fff7d6' },
289 shiny: { o: '#26405e', a: '#9cc6ea', b: '#6a95c2', c: '#e8f4ff', d: '#8ff0ff', f: '#e46fb1', w: '#ffffff' },
290 baby: [
291 '.b....b.',
292 '..bccb..',
293 '.oEccEo.',
294 'oaecceao',
295 'ofammafo',
296 'oaaddaao',
297 '.oowdoo.',
298 ],
299 teen: [
300 '..b....b..',
301 '...bccb...',
302 '..oEccEo..',
303 '.oaecceao.',
304 'oafammafao',
305 'offaddaffo',
306 'oaaadwaaao',
307 '.oooddooo.',
308 ],
309 adult: [
310 '...b......b...',
311 'oo..b.cc.b..oo',
312 'oaoo.bccb.ooao',
313 'oafaaEccEaafao',
314 'oaffaecceaffao',
315 'oaaaahmmhaaaao',
316 '.oaaaoccoaaao.',
317 '..oaaodwoaao..',
318 '...ooowdooo...',
319 '......dd......',
320 ],
321 },
322 {
323 id: 'foldling',
324 name: 'Foldling',
325 rarity: 'rare',
326 blurb: 'Folded from a page of someone else\'s notes.',
327 face: 'a',
328 accent: '#3fb8aa',
329 shinyAccent: '#ffd36b',
330 palette: { o: '#6a6e7f', a: '#f7f3ea', b: '#d6d0c2', c: '#ffffff', d: '#35a99c', f: '#237a70' },
331 shiny: { o: '#7a5a1e', a: '#f6d980', b: '#d9b450', c: '#fff2b8', d: '#d8434f', f: '#9e2632' },
332 baby: [
333 'o......o',
334 'od....fo',
335 'ocd..fbo',
336 'oaadfbbo',
337 '.oEabEo.',
338 '.oemmeo.',
339 '..odfo..',
340 ],
341 teen: [
342 'o........o',
343 'od......fo',
344 'ocd....fbo',
345 'oaad..fbbo',
346 'oaaadfbbbo',
347 '.oaEabEbo.',
348 '..oemmeo..',
349 '...odfo...',
350 ],
351 adult: [
352 'o............o',
353 'od..........fo',
354 'ocd...oo...fbo',
355 'oaad.oabo.fbbo',
356 'oaaadaabbfbbbo',
357 '.oaaaEabEbbbo.',
358 '..oaaeabebbo..',
359 '...oahmmhbo...',
360 '....oaabbo....',
361 '.....odfo.....',
362 ],
363 },
364 {
365 id: 'geobug',
366 name: 'Geobug',
367 rarity: 'rare',
368 blurb: 'Plain rock outside. Make it grin and the crystals show.',
369 face: 'b',
370 accent: '#c08cff',
371 shinyAccent: '#5fe39a',
372 palette: { o: '#241d2c', a: '#5c5066', b: '#433950', c: '#8c7f98', d: '#b57cff', f: '#ead2ff', g: '#7a3fd1', E: '#fdf6ff', m: '#ff9ccf' },
373 shiny: { o: '#1d2a24', a: '#566a60', b: '#3d4f46', c: '#8aa397', d: '#4fe08a', f: '#d2ffe0', g: '#1f9e57', E: '#fbfff6', m: '#ffd36b' },
374 baby: [
375 '..aaaa..',
376 '.accfda.',
377 'cacdgfac',
378 '.adgdga.',
379 '.aaaaaa.',
380 'caEbbEac',
381 '.aebbea.',
382 '..amma..',
383 ],
384 teen: [
385 '...aaaa...',
386 '..accfda..',
387 'cacdfgdgac',
388 '.adgdgdga.',
389 '.aaaaaaaa.',
390 'cabEbbEbac',
391 '.abebbeba.',
392 'c.abmmba.c',
393 ],
394 adult: [
395 '....aaaaaa....',
396 '..aacccfdbaa..',
397 '.accfdgdgdbba.',
398 'cacfdgfgdgdbac',
399 '.acdgdgdgdgba.',
400 'caaaaaaaaaaaac',
401 '..abbEbbEbba..',
402 '.cabbebbebbac.',
403 '..abbbmmbbba..',
404 '...aaaaaaaa...',
405 ],
406 },
407 {
408 id: 'starwhelp',
409 name: 'Starwhelp',
410 rarity: 'legendary',
411 blurb: 'A dragon small enough to sleep in a teacup. Hoards stars.',
412 face: 'a',
413 accent: '#a99bff',
414 shinyAccent: '#e8ecff',
415 palette: { o: '#1c1745', a: '#4d40b5', b: '#342986', c: '#9a8ef5', d: '#ffd36b', f: '#ff8fd8', g: '#9ee8ff', E: '#fff3c4', m: '#ff8fd8', h: '#ff8fd8' },
416 shiny: { o: '#39407a', a: '#e8ecff', b: '#b8c1ee', c: '#ffffff', d: '#ffd36b', f: '#7fd3ff', g: '#ffb3e6', E: '#232a66', m: '#e2507a' },
417 baby: [
418 '.d....d.',
419 '.ocggao.',
420 'oaEaaEao',
421 'oheaaeho',
422 'boammaob',
423 '.occcco.',
424 '.bb..bbd',
425 ],
426 teen: [
427 '..d....d..',
428 '..ocggao..',
429 '.ocaaaaao.',
430 'boaEaaEaob',
431 'bboeaaeobb',
432 '.bohmmhob.',
433 '..occcco..',
434 '..bb..bbdf',
435 ],
436 adult: [
437 '...d......d...',
438 '...dd....dd...',
439 '....ocggao....',
440 '...ocaaaaao...',
441 'bb.oaEaaEao.bb',
442 'bbboheaaehobbb',
443 '.bboaammaaobb.',
444 '...oaccccao.d.',
445 '...occccccodfd',
446 '....bb..bb..d.',
447 ],
448 },
449]
450
451// ---------------------------------------------------------------- moods
452
453/**
454 * One frame of a mood, as a recipe applied to the stage's sprite.
455 *
456 * `dy`/`dx` move the body; `eyes`, `mouth`, `blush` set the face; `drop`
457 * slides a sweat drop; `z` draws the sleep letter; `confetti` picks a confetti
458 * pattern; `white` flashes the silhouette; `use` picks which stage an
459 * evolution frame shows; `skew` leans the egg and `split` lifts its lid (Bolt's
460 * egg poses, art.ts).
461 */
462export type Pose = {
463 dy?: number
464 dx?: number
465 eyes?: 'open' | 'closed' | 'happy'
466 mouth?: 'open' | 'flat'
467 blush?: boolean
468 drop?: number
469 z?: 'z' | 'Z'
470 confetti?: number
471 white?: boolean
472 use?: 'from' | 'to'
473 egg?: boolean
474 skew?: number
475 split?: boolean
476 shell?: boolean
477}
478
479export type MoodArt = {
480 /** Ticks each frame stays up. */
481 hold: number
482 frames: readonly Pose[]
483}
484
485const HAPPY: Pose = { eyes: 'happy', mouth: 'open', blush: true }
486
487export const MOODS: Readonly<Record<HatchlingMood | 'working', MoodArt>> = {
488 // Still while nothing happens; the working loop is what breathes.
489 idle: { hold: 1, frames: [{}] },
490 working: { hold: 2, frames: [{}, { dy: -1 }, { eyes: 'closed' }] },
491 happy: { hold: 1, frames: [{ ...HAPPY, dy: -1 }, { ...HAPPY }] },
492 sweat: {
493 hold: 1,
494 frames: [
495 { dx: -1, drop: 0, mouth: 'flat' },
496 { dx: 1, drop: 1, mouth: 'flat' },
497 { drop: 2, mouth: 'flat' },
498 ],
499 },
500 cheer: {
501 hold: 1,
502 frames: [
503 { ...HAPPY, dy: -2, confetti: 0 },
504 { ...HAPPY, dy: -1, confetti: 1 },
505 { ...HAPPY, confetti: 2 },
506 ],
507 },
508 sleep: {
509 hold: 2,
510 frames: [
511 { eyes: 'closed', blush: true, z: 'z' },
512 { eyes: 'closed', blush: true, z: 'Z' },
513 ],
514 },
515 hatch: {
516 hold: 2,
517 frames: [
518 { egg: true, skew: -1 },
519 { egg: true, skew: 1 },
520 { egg: true, split: true },
521 { ...HAPPY, shell: true },
522 { ...HAPPY, dy: -1, confetti: 1 },
523 ],
524 },
525 evolve: {
526 hold: 2,
527 frames: [
528 { use: 'from', white: true },
529 { use: 'to', white: true },
530 { use: 'from', white: true },
531 { use: 'to', white: true },
532 { use: 'to', ...HAPPY, confetti: 0 },
533 { use: 'to', ...HAPPY, dy: -1, confetti: 2 },
534 ],
535 },
536}
537
538/** Egg wobble while Claude works: tilt left, upright, tilt right. */
539export const EGG_WORKING: MoodArt = {
540 hold: 2,
541 frames: [{ egg: true }, { egg: true, skew: -1 }, { egg: true }, { egg: true, skew: 1 }],
542}
543
544/** Where confetti lands, as [x, y] on the canvas, per pattern. */
545export const CONFETTI: readonly (readonly (readonly [number, number])[])[] = [
546 [[1, 0], [8, 1], [0, 3]],
547 [[2, 1], [9, 0], [1, 5]],
548 [[0, 1], [7, 0], [9, 4]],
549]
550hooks/bolt.ts 109 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43 },
44 tiny: {
45 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49 'crash': ['...', '.SO', 'BEB', 'SSS'],
50 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52 },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64 if (key === undefined || key === '.') return undefined
65 return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76 const out: BoltCell[][] = []
77 for (let r = 0; r + 1 < rows.length; r += 2) {
78 const top = rows[r] ?? ''
79 const bottom = rows[r + 1] ?? ''
80 const line: BoltCell[] = []
81 for (let c = 0; c < top.length; c += 1) {
82 const t = colour(top[c])
83 const b = colour(bottom[c])
84 if (t === undefined && b === undefined) line.push({ ch: ' ' })
85 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87 else if (t === b) line.push({ ch: '\u2588', fg: t })
88 else line.push({ ch: '\u2580', fg: t, bg: b })
89 }
90 out.push(line)
91 }
92 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93 const line = out[glyph.row]
94 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95 }
96 return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101 const runs: BoltCell[] = []
102 for (const cell of line) {
103 const last = runs[runs.length - 1]
104 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105 else runs.push({ ...cell })
106 }
107 return runs
108}
109types/index.d.ts 84 lines1// The values hatchling keeps in $.state (the session's copy) and in $.store
2// (the saved pet, kept across sessions and projects).
3
4export type HatchlingSpecies =
5 | 'pebblet'
6 | 'fluffle'
7 | 'coilsnail'
8 | 'nimbit'
9 | 'lampwing'
10 | 'foldling'
11 | 'geobug'
12 | 'starwhelp'
13
14/** What an egg hatches into: one of the 8 species, or (1 egg in 100) Bolt, the secret. */
15export type HatchlingKind = HatchlingSpecies | 'bolt'
16
17export type HatchlingRarity = 'common' | 'uncommon' | 'rare' | 'legendary' | 'secret'
18
19export type HatchlingStage = 'egg' | 'baby' | 'teen' | 'adult'
20
21/** The one-time roll, made when the egg hatches and never again. */
22export type HatchlingRoll = {
23 species: HatchlingKind
24 /** Never true for Bolt. */
25 shiny: boolean
26 /** When it hatched, ms since the epoch. */
27 at: number
28}
29
30/** The saved pet: everything that survives a restart. */
31export type HatchlingPet = {
32 v: 1
33 /** When the egg first appeared (days together count from here). */
34 bornAt: number
35 /** Tool calls seen while still an egg; it hatches at 25. */
36 eggCalls: number
37 /**
38 * Four random u32s saved with the egg. The hatch roll reads these, so two
39 * sessions that hatch the egg at the same moment hatch the same pet.
40 */
41 seed?: number[]
42 /** Chosen with /pet name, or picked at hatch. */
43 name?: string
44 hatch?: HatchlingRoll
45 /** Experience since hatching; the stage follows from it. */
46 xp: number
47 hidden: boolean
48 toolCalls: number
49 turns: number
50 testsPassed: number
51}
52
53export type HatchlingMood =
54 | 'idle'
55 | 'happy'
56 | 'sweat'
57 | 'cheer'
58 | 'sleep'
59 | 'hatch'
60 | 'evolve'
61
62/** What the band is doing right now (session only). */
63export type HatchlingAnim = {
64 mood: HatchlingMood
65 /** Frame counter; only moves while the animation timer runs. */
66 tick: number
67 /** Ticks left before the mood settles back (0 = it has settled). */
68 left: number
69 /** The one-line status under the name. */
70 line: string
71 /** The stage before an evolution, for the evolve flash. */
72 from?: HatchlingStage
73}
74
75declare module 'claude-code' {
76 interface PluginState {
77 hatchling: {
78 pet: HatchlingPet | null
79 anim: HatchlingAnim
80 working: boolean
81 }
82 }
83}
84