SLOPSHOPPER

hatchling

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

newbandrowsguardcommandtoast
★ 1v0.1.2MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/hatchling
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hatchling
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pet ⎿ hatchling: An egg ⎿ hatchling: egg - 9/25 to hatch ⎿ hatchling: 9 of 25 tool calls - 16 more to hatch ⎿ hatchling: 1 day together - 9 tool calls - 0 test runs cheered ⎿ hatchling: Something stirs inside. ⟨Claude Code's own drawing⟩ ▀▀ Egg hatches in 16 tool calls ▄▀▀██▄ Something stirs inside the egg ▄██████▄ ██████░░░░░░░░░░ 9/25 to hatch █▀▀████▀ ▀▀▀▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▀▀ Egg hatches in 16 tool calls ▄▀▀██▄ Something stirs inside the egg ▄██████▄ ██████░░░░░░░░░░ 9/25 to hatch █▀▀████▀ ▀▀▀▀▀▀
README

<img src="../../brand/pixel-icons/svg/hatchling.svg" width="64" height="64" alt="">

Hatchling

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

Install

/plugin install hatchling --marketplace modmyclaude/claude-code-mods

Use it

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:

SpeciesRarityOdds
Pebblet (a mossy river stone), Fluffle (lop-eared fluff), Coilsnail (snail with a thread-spool shell)common36 in 64 (56%)
Nimbit (a pocket raincloud), Lampwing (a moth with a glowing belly)uncommon18 in 64 (28%)
Foldling (a folded paper bird), Geobug (a beetle with a geode shell)rare8 in 64 (12.5%)
Starwhelp (a teacup-sized star dragon)legendary2 in 64 (3%)
???secret1 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:

  • Bounces when a tool call succeeds, sweats when one fails, cheers when a Bash command prints a passing test run (jest, vitest, pytest, go test, cargo test and nextest, node:test, mocha, rspec, bun, deno, phpunit, unittest, dotnet test, Playwright).
  • Falls asleep after 5 minutes with nothing happening, and wakes on the next prompt or tool call.
  • Grows: 1 XP per tool call (including those Claude's subagents make), 5 per finished turn of your own, 5 more for a passing test run. It becomes a teen at 200 XP and an adult at 800 XP, each with a short celebration and a toast.

Commands:

CommandWhat it does
/petStat 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 showHide or bring back the band (remembered across sessions). Answers with a toast.
/pet cardA ~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.

Settings

None.

What it can touch

  • Files: none directly. The pet is saved in the mod's own store (a JSON file Claude Code keeps for each plugin under your config directory).
  • Processes, network, model calls: none. Everything is deterministic except the one hatch roll, which uses the platform's crypto random source.
  • What the model sees: the text of /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.
  • Clipboard: only when you press Copy card.

Limits

  • The band draws in the terminal and the desktop app's Code tab. VS Code and mobile have no band above the prompt; /pet and /pet card still work there as text.
  • The desktop app draws the sprite as text cells too. How crisp it looks depends on the app's font and line spacing: generous line spacing can show thin seams between rows of pixels.
  • "Tests passed" is read from the runner's summary in the Bash output. A runner with an unusual summary is not recognised (no cheer, no harm). Commands that only show a log (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.
  • Animation runs on a timer only while something happens (a reaction, Claude working, the doze-off); when idle, asleep or hidden no timer runs.
  • Two sessions open at once share the one pet. Each change re-reads the saved pet first, so progress from both sessions adds up, and a pet that has hatched is never rolled again. Two writes in the same instant can still lose one tool call's XP. Another session's progress shows in this band at this session's next event.
  • When hidden, the pet still counts tool calls and turns, and still toasts when it hatches or evolves.

Built from a prompt

This mod was built from this prompt.

More mods: the full catalogue.

Source 7 files
hooks/register.tsx 615 lines
1// 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}
615
hooks/art.ts 325 lines
1// 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}
325
hooks/cells.tsx 24 lines
1// 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}
24
hooks/pet.ts 393 lines
1// 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}
393
hooks/sprites.ts 550 lines
1// 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]
550
hooks/bolt.ts 109 lines
1// 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}
109
types/index.d.ts 84 lines
1// 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