SLOPSHOPPER

Code Pet

A pixel pet that lives in a pane and reacts to what Claude does: eats on tool calls, gets sick on failures, panics on rm -rf, sleeps when idle, evolves as you…

newpaneguardcommandtoaststatus
★ 2v0.1.0MITupdated 2026-10-02OneWave-AI/claude-code-mods/code-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · code-pet
│ ┃ Code Pet ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀LEVEL▀UP▀▀▀▀▀▀▀▀▀▀▀▀ │ code-pet │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ Byte hatched. Feed it by working. /pet to │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ visit. │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 li╭──────────────────────────────────╮ │ ┃ ⏺ Bash(bun test) │ code-pet │ │ ┃ ⎿ 3 pass, 1 fail │ Byte evolved into a blob! (Lv 2) │ │ ┃ ╰──────────────────────────────────╯ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /pet │ ┃ ⎿ code-pet: Pet pane opened. │ ┃ │ ┃ │ ┃ │ ┃ BYTE Lv 2 blob · 0m old · 8 meals EVOLVING │ ┃ HP ████████ FOOD ████████ XP ████░░░░ │ ┃ │ ┃ f: snack d: demo │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ code-pet: Byte Lv2 · evolving

Draws

Pane · Code Pet
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀LEVEL▀UP▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ BYTE Lv 2 blob · 0m old · 8 meals EVOLVING HP ████████ FOOD ████████ XP ████░░░░ f: snack d: demo
README

code-pet

A pixel pet that lives in a pane and reacts to what Claude does: eats on tool calls, gets sick on failures, panics on rm -rf, sleeps when idle, evolves as you ship.

code-pet

A pixel pet in a pane. It eats when Claude calls tools, gets sick when they fail, panics when a destructive command shows up, sleeps when the session is idle, and evolves as you ship. /pet rename <name>, /pet snack.

Install

In a Claude Code session (2.1.287 or later):

/plugin marketplace add OneWave-AI/claude-code-mods
/plugin install code-pet@claude-code-mods

Or load this folder for one session: claude --plugin-dir ./code-pet

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
code-petNoNoNoNoNo

Run claude plugin validate ./code-pet to list every event it hooks and every call it makes. See the repository README for the full security notes.

Part of claude-code-mods by OneWave AI. MIT licensed.

Source 5 files
hooks/register.tsx 294 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DemoView, Mood, Pet, Stage } from '../types'
5import {
6  ageText,
7  apply,
8  decay,
9  isDanger,
10  isFailedRun,
11  levelOf,
12  MOOD_WORD,
13  moodAt,
14  newPet,
15  revive,
16  stageOf,
17  xpForLevel,
18} from './pet'
19import type { Live, PetEvent } from './pet'
20import { petFrame, rowsFor } from './scene'
21
22const PANE = 'code-pet'
23const STORE_KEY = 'pet'
24const FRAME_MS = 90
25
26const petAtom = atom({ plugin: 'code-pet', key: 'pet' } as const, null as Pet | null)
27const moodAtom = atom({ plugin: 'code-pet', key: 'mood' } as const, 'idle' as Mood)
28const demoAtom = atom({ plugin: 'code-pet', key: 'demo' } as const, null as DemoView | null)
29
30const GOLD = '#c2a87e'
31const BLUE = '#61A5FA'
32const RED = '#e5484d'
33const PAPER = '#E8E2D6'
34const INK = '#0B1121'
35
36// The module's live copy; $.store holds the pet across sessions, $.state what the pane draws.
37let pet: Pet | null = null
38let live: Live = { mood: 'idle', until: 0, lastActivityAt: 0 }
39let demo: DemoView | null = null
40let shownMood: Mood = 'idle'
41
42let anim: { cancel: () => void } | null = null
43let animSize = ''
44
45const viewNow = (now: number) => {
46  if (demo) return { stage: demo.stage, mood: demo.mood, caption: demo.caption }
47  if (!pet) return { stage: 'egg' as Stage, mood: 'idle' as Mood }
48  return { stage: stageOf(levelOf(pet.xp)), mood: moodAt(pet, live, now) }
49}
50
51const statusText = (now: number) => {
52  if (!pet) return undefined
53  const mood = demo ? demo.mood : moodAt(pet, live, now)
54  return `${pet.name} Lv${levelOf(pet.xp)} · ${MOOD_WORD[mood]}`
55}
56
57/** Pushes the mood to the pane's text and the status line when it changed. */
58const publishMood = async ($: EngineInterface, now: number) => {
59  const mood = viewNow(now).mood
60  if (mood === shownMood) return
61  shownMood = mood
62  await update($, moodAtom, () => mood)
63  $.ui.status(statusText(now))
64}
65
66const save = async ($: EngineInterface) => {
67  if (!pet) return
68  const snapshot = pet
69  await $.store.set(STORE_KEY, snapshot)
70  await update($, petAtom, () => snapshot)
71}
72
73const happen = async ($: EngineInterface, ev: PetEvent) => {
74  if (!pet) return
75  const now = await $.clock.now()
76  const out = apply(decay(pet, now), live, ev, now)
77  pet = out.pet
78  live = out.live
79  if (out.leveledTo !== undefined) {
80    const stage = stageOf(out.leveledTo)
81    const evolved = stageOf(out.leveledTo - 1) !== stage
82    $.ui.toast(
83      evolved ? `${pet.name} evolved into a ${stage}! (Lv ${out.leveledTo})` : `${pet.name} reached Lv ${out.leveledTo}`,
84      { timeoutMs: 6000 },
85    )
86  }
87  await save($)
88  await publishMood($, now)
89}
90
91const startAnim = ($: EngineInterface, columns: number, rows: number) => {
92  const size = `${columns}x${rows}`
93  if (anim && animSize === size) return
94  anim?.cancel()
95  animSize = size
96  const timer = $.clock.every(FRAME_MS, () => {
97    void $.clock.now().then(now => {
98      void publishMood($, now)
99      return $.ui
100        .blit({ requestId: PANE, key: 'scene', cells: petFrame(columns, rows, now / 1000, viewNow(now)) })
101        .then(r => {
102          if (r.deny && anim === timer) {
103            timer.cancel()
104            anim = null
105          }
106        })
107    })
108  })
109  anim = timer
110}
111
112const DEMO: readonly { mood: Mood; stage: Stage; caption: string; ms: number }[] = [
113  { mood: 'idle', stage: 'blob', caption: 'just vibing', ms: 2600 },
114  { mood: 'eating', stage: 'blob', caption: 'tool call = snack', ms: 2600 },
115  { mood: 'happy', stage: 'blob', caption: 'turn shipped', ms: 2600 },
116  { mood: 'sick', stage: 'blob', caption: 'tests failed', ms: 2800 },
117  { mood: 'panic', stage: 'blob', caption: 'rm -rf detected', ms: 2800 },
118  { mood: 'sad', stage: 'blob', caption: 'not fed in 2 days', ms: 2600 },
119  { mood: 'sleep', stage: 'blob', caption: 'idle 3 min', ms: 2800 },
120  { mood: 'levelup', stage: 'egg', caption: 'Lv 1 egg', ms: 1600 },
121  { mood: 'levelup', stage: 'blob', caption: 'Lv 2 blob', ms: 1600 },
122  { mood: 'levelup', stage: 'critter', caption: 'Lv 5 critter', ms: 1600 },
123  { mood: 'levelup', stage: 'crowned', caption: 'Lv 10 crowned', ms: 2200 },
124  { mood: 'happy', stage: 'crowned', caption: 'ship it', ms: 2600 },
125]
126
127let demoRun = 0
128
129const runDemo = async ($: EngineInterface) => {
130  const run = ++demoRun
131  let at = 0
132  for (const step of DEMO) {
133    $.clock.after(at, () => {
134      if (run !== demoRun) return
135      demo = { mood: step.mood, stage: step.stage, caption: step.caption }
136      void update($, demoAtom, () => demo)
137      void $.clock.now().then(now => publishMood($, now))
138    })
139    at += step.ms
140  }
141  $.clock.after(at, () => {
142    if (run !== demoRun) return
143    demo = null
144    void update($, demoAtom, () => null)
145    void $.clock.now().then(now => publishMood($, now))
146  })
147  return Math.round(at / 1000)
148}
149
150const bar = (fraction: number, size: number) => {
151  const filled = Math.max(0, Math.min(size, Math.round(fraction * size)))
152  return '█'.repeat(filled) + '░'.repeat(size - filled)
153}
154
155const hatch = async ($: EngineInterface) => {
156  const now = await $.clock.now()
157  const stored = revive(await $.store.get(STORE_KEY))
158  pet = decay(stored ?? newPet(now), now)
159  live = { mood: 'idle', until: 0, lastActivityAt: now }
160  await save($)
161  if (!stored) $.ui.toast(`${pet.name} hatched. Feed it by working. /pet to visit.`, { timeoutMs: 6000 })
162}
163
164export const register: Register = on => {
165  on('session.start', async ($, e, next) => {
166    await $.command.register({
167      name: 'pet',
168      description: 'Your Claude pet: /pet [demo|rename <name>|snack]',
169    })
170    await hatch($)
171    $.ui.status(statusText(await $.clock.now()))
172    // Time passes even with the pane shut.
173    $.clock.every(60 * 1000, () => {
174      void $.clock.now().then(async now => {
175        if (!pet) return
176        pet = decay(pet, now)
177        await save($)
178        await publishMood($, now)
179      })
180    })
181
182    return next(e)
183  })
184
185  on('command.run', { command: 'pet' }, async ($, e) => {
186    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
187    const arg = verb.toLowerCase()
188    if (!pet) await hatch($)
189    if (arg === 'rename') {
190      const name = rest.join(' ').trim().slice(0, 16)
191      if (!name || !pet) return { text: 'Usage: /pet rename <name>' }
192      pet = { ...pet, name }
193      await save($)
194      $.ui.status(statusText(await $.clock.now()))
195      return { text: `Your pet is now called ${name}.` }
196    }
197    if (arg === 'snack') {
198      await happen($, { kind: 'snack' })
199      return { text: `${pet?.name ?? 'Your pet'} ate a snack.` }
200    }
201    await $.ui.open({ id: PANE, title: 'Code Pet' })
202    if (arg === 'demo') {
203      const seconds = await runDemo($)
204      return { text: `Demo running (${seconds}s). Hit record.` }
205    }
206    return { text: 'Pet pane opened.' }
207  })
208
209  on('prompt.submit', async ($, e, next) => {
210    await happen($, { kind: 'activity' })
211    return next(e)
212  })
213
214  on('tool.call', async ($, e, next) => {
215    if (e.tool === 'Bash' && isDanger(e.command)) await happen($, { kind: 'danger' })
216    const ran = await next(e)
217    if (ran.deny !== undefined) return ran
218    const failed = e.tool === 'Bash' ? isFailedRun(ran.isError, ran.text) : ran.isError === true
219    await happen($, failed ? { kind: 'failed' } : { kind: 'fed' })
220    return ran
221  })
222
223  on('turn.complete', async ($, e, next) => {
224    await happen($, { kind: 'turn' })
225    return next(e)
226  })
227
228  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
229    const elements = $.ui.resolve(e)
230    const { Box, Text, Button } = elements
231    const Raster = 'Raster' in elements ? elements.Raster : null
232    const now = await $.clock.now()
233    const stats = (await read($, petAtom)) ?? pet
234    const mood = await read($, moodAtom)
235    const shown = await read($, demoAtom)
236    const cols = Math.max(24, e.props.bodyColumns || e.viewport?.columns || 60)
237    const width = cols - 2
238    const rows = rowsFor(width)
239
240    if (Raster && e.surface === 'terminal') startAnim($, width, rows)
241
242    if (!stats) {
243      return (
244        <Box paddingX={1}>
245          <Text dimColor>Hatching...</Text>
246        </Box>
247      )
248    }
249
250    const level = levelOf(stats.xp)
251    const stage = shown?.stage ?? stageOf(level)
252    const from = xpForLevel(level)
253    const to = xpForLevel(level + 1)
254    const meter = Math.max(6, Math.min(14, Math.floor((width - 30) / 3)))
255    const moodColor = mood === 'panic' || mood === 'sick' ? RED : mood === 'sleep' || mood === 'sad' ? BLUE : GOLD
256
257    return (
258      <Box flexDirection="column" paddingX={1}>
259        {Raster && e.surface === 'terminal' && (
260          <Raster key="scene" columns={width} rows={rows} cells={petFrame(width, rows, now / 1000, viewNow(now))} />
261        )}
262        <Box justifyContent="space-between" marginTop={1}>
263          <Text>
264            <Text bold color={PAPER}>{stats.name.toUpperCase()}</Text>
265            <Text dimColor>  Lv {level} {stage} · {ageText(stats.bornAt, now)} · {stats.meals} meals</Text>
266          </Text>
267          <Text backgroundColor={moodColor} color={INK} bold>{` ${MOOD_WORD[mood].toUpperCase()} `}</Text>
268        </Box>
269        <Box gap={2}>
270          <Text>
271            <Text dimColor>HP </Text>
272            <Text color={stats.hp < 30 ? RED : GOLD}>{bar(stats.hp / 100, meter)}</Text>
273          </Text>
274          <Text>
275            <Text dimColor>FOOD </Text>
276            <Text color={stats.hunger < 25 ? RED : BLUE}>{bar(stats.hunger / 100, meter)}</Text>
277          </Text>
278          <Text>
279            <Text dimColor>XP </Text>
280            <Text color={PAPER}>{bar((stats.xp - from) / Math.max(1, to - from), meter)}</Text>
281          </Text>
282        </Box>
283        {shown && (
284          <Text dimColor>demo · {shown.caption}</Text>
285        )}
286        <Box gap={3} marginTop={1}>
287          <Button key="snack" label="snack" hotkey="f" plain onPress={() => void happen($, { kind: 'snack' })} />
288          <Button key="demo" label="demo" hotkey="d" plain onPress={() => void runDemo($)} />
289        </Box>
290      </Box>
291    )
292  })
293}
294
hooks/pet.ts 174 lines
1/** The pet's rules: pure, so the tests can drive them with a fake clock. */
2
3import type { Mood, Pet, Stage } from '../types'
4
5export const IDLE_MS = 3 * 60 * 1000
6export const HUNGER_PER_HOUR = 4
7export const STARVE_HP_PER_HOUR = 3
8
9/** How long an event's mood holds before the pet falls back to its resting mood. */
10export const MOOD_MS: Record<Mood, number> = {
11  idle: 0,
12  sleep: 0,
13  sad: 0,
14  eating: 1800,
15  happy: 2600,
16  sick: 4500,
17  panic: 4000,
18  levelup: 4000,
19}
20
21/** A new mood replaces a held one only when it ranks at least as high. */
22const RANK: Record<Mood, number> = {
23  idle: 0,
24  sleep: 0,
25  sad: 0,
26  eating: 1,
27  happy: 2,
28  sick: 3,
29  panic: 4,
30  levelup: 5,
31}
32
33export type Live = { mood: Mood; until: number; lastActivityAt: number }
34
35const clamp = (n: number, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, n))
36
37export const levelOf = (xp: number) => 1 + Math.floor(Math.sqrt(Math.max(0, xp) / 8))
38export const xpForLevel = (level: number) => 8 * (level - 1) ** 2
39
40export const stageOf = (level: number): Stage =>
41  level >= 10 ? 'crowned' : level >= 5 ? 'critter' : level >= 2 ? 'blob' : 'egg'
42
43export const newPet = (now: number, name = 'Byte'): Pet => ({
44  name,
45  xp: 0,
46  hunger: 80,
47  hp: 100,
48  bornAt: now,
49  lastFedAt: now,
50  lastTickAt: now,
51  meals: 0,
52})
53
54/** A stored value back into a pet, or null when it is not one. */
55export const revive = (value: unknown): Pet | null => {
56  if (!value || typeof value !== 'object') return null
57  const v = value as Record<string, unknown>
58  const num = (k: string) => (typeof v[k] === 'number' && Number.isFinite(v[k]) ? (v[k] as number) : null)
59  const xp = num('xp')
60  const bornAt = num('bornAt')
61  if (xp === null || bornAt === null || typeof v.name !== 'string') return null
62  return {
63    name: v.name,
64    xp,
65    hunger: clamp(num('hunger') ?? 80),
66    hp: clamp(num('hp') ?? 100),
67    bornAt,
68    lastFedAt: num('lastFedAt') ?? bornAt,
69    lastTickAt: num('lastTickAt') ?? bornAt,
70    meals: num('meals') ?? 0,
71  }
72}
73
74/** Time passing: hunger drains, and an empty belly drains HP. */
75export const decay = (pet: Pet, now: number): Pet => {
76  const hours = Math.max(0, now - pet.lastTickAt) / 3.6e6
77  if (hours === 0) return pet
78  const hunger = clamp(pet.hunger - hours * HUNGER_PER_HOUR)
79  const emptyHours = Math.max(0, hours - pet.hunger / HUNGER_PER_HOUR)
80  const hp = clamp(pet.hp - emptyHours * STARVE_HP_PER_HOUR)
81  return { ...pet, hunger, hp, lastTickAt: now }
82}
83
84export type PetEvent =
85  | { kind: 'fed' }
86  | { kind: 'snack' }
87  | { kind: 'failed' }
88  | { kind: 'danger' }
89  | { kind: 'turn' }
90  | { kind: 'activity' }
91
92export type Applied = { pet: Pet; live: Live; leveledTo?: number }
93
94const hold = (live: Live, mood: Mood, now: number): Live =>
95  live.until > now && RANK[live.mood] > RANK[mood]
96    ? { ...live, lastActivityAt: now }
97    : { mood, until: now + MOOD_MS[mood], lastActivityAt: now }
98
99/** One thing that happened, applied to the pet and its mood. */
100export const apply = (pet: Pet, live: Live, ev: PetEvent, now: number): Applied => {
101  const before = levelOf(pet.xp)
102  let next = pet
103  let mood: Mood | null = null
104  switch (ev.kind) {
105    case 'fed':
106      next = { ...pet, xp: pet.xp + 2, hunger: clamp(pet.hunger + 4), hp: clamp(pet.hp + 1), lastFedAt: now, meals: pet.meals + 1 }
107      mood = 'eating'
108      break
109    case 'snack':
110      next = { ...pet, xp: pet.xp + 1, hunger: clamp(pet.hunger + 20), lastFedAt: now, meals: pet.meals + 1 }
111      mood = 'eating'
112      break
113    case 'failed':
114      next = { ...pet, hp: clamp(pet.hp - 6) }
115      mood = 'sick'
116      break
117    case 'danger':
118      mood = 'panic'
119      break
120    case 'turn':
121      next = { ...pet, xp: pet.xp + 3, hp: clamp(pet.hp + 2) }
122      mood = 'happy'
123      break
124    case 'activity':
125      return { pet, live: { ...live, lastActivityAt: now } }
126  }
127  const after = levelOf(next.xp)
128  if (after > before) {
129    return { pet: next, live: hold(live, 'levelup', now), leveledTo: after }
130  }
131  return { pet: next, live: hold(live, mood, now) }
132}
133
134/** The mood with nothing happening: asleep after a quiet spell, sad when hungry or hurt. */
135export const restingMood = (pet: Pet, now: number, lastActivityAt: number): Mood =>
136  now - lastActivityAt > IDLE_MS ? 'sleep' : pet.hunger < 25 || pet.hp < 30 ? 'sad' : 'idle'
137
138export const moodAt = (pet: Pet, live: Live, now: number): Mood =>
139  live.until > now ? live.mood : restingMood(pet, now, live.lastActivityAt)
140
141const DANGER = [
142  /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|--recursive\s+--force|--force\s+--recursive)\b/i,
143  /\bgit\s+push\b[^\n]*(--force\b|--force-with-lease\b|\s-f\b)/i,
144  /\bgit\s+reset\s+--hard\b/i,
145  /\bgit\s+clean\s+-[a-z]*f/i,
146  /\bdrop\s+(table|database|schema)\b/i,
147  /\bmkfs\b|\bdd\s+if=/i,
148]
149
150export const isDanger = (command: string) => DANGER.some(re => re.test(command))
151
152/** A Bash result that failed even where the tool did not flag it. */
153export const isFailedRun = (isError: boolean | undefined, output: string | undefined) =>
154  isError === true || /\bexit code [1-9]\d*\b/i.test(output ?? '')
155
156export const ageText = (bornAt: number, now: number) => {
157  const minutes = Math.max(0, Math.floor((now - bornAt) / 60000))
158  if (minutes < 60) return `${minutes}m old`
159  const hours = Math.floor(minutes / 60)
160  if (hours < 48) return `${hours}h old`
161  return `${Math.floor(hours / 24)}d old`
162}
163
164export const MOOD_WORD: Record<Mood, string> = {
165  idle: 'chilling',
166  happy: 'happy',
167  eating: 'eating',
168  sick: 'sick',
169  panic: 'PANICKING',
170  sleep: 'asleep',
171  sad: 'sad',
172  levelup: 'evolving',
173}
174
hooks/scene.ts 315 lines
1/** The pet's pixel art and one animated frame of its room. */
2
3import type { Mood, Stage } from '../types'
4import { canvas, encode, mix, rect, set, sprite, text } from './pixels'
5import type { Canvas } from './pixels'
6
7const BODY: Record<Stage, readonly string[]> = {
8  egg: [
9    '....DDDD....',
10    '...DBBBBD...',
11    '..DBLLBBBD..',
12    '..DBLBBBBD..',
13    '.DBBBBBBBBD.',
14    '.DBBBBBBBBD.',
15    '.DBBBBBBBBD.',
16    '.DBBBBBBBBD.',
17    '.DBBBBBBBBD.',
18    '..DBBBBBBD..',
19    '...DBBBBD...',
20    '....DDDD....',
21  ],
22  blob: [
23    '............',
24    '............',
25    '....DDDD....',
26    '..DDBBBBDD..',
27    '.DBBLLBBBBD.',
28    '.DBLBBBBBBD.',
29    'DBBBBBBBBBBD',
30    'DBBBBBBBBBBD',
31    'DBBBBBBBBBBD',
32    'DBBBBBBBBBBD',
33    '.DBBBBBBBBD.',
34    '..DDDDDDDD..',
35  ],
36  critter: [
37    '.DD......DD.',
38    '.DBD....DBD.',
39    '.DBBDDDDBBD.',
40    '.DBBBBBBBBD.',
41    'DBBLLBBBBBBD',
42    'DBLBBBBBBBBD',
43    'DBBBBBBBBBBD',
44    'DBBBBBBBBBBD',
45    'DBBBBBBBBBBD',
46    '.DBBBBBBBBD.',
47    '..DBD..DBD..',
48    '..DD....DD..',
49  ],
50  crowned: [
51    '...G.GG.G...',
52    '...GGGGGG...',
53    '....DDDD....',
54    '..DDBBBBDD..',
55    '.DBBLLBBBBD.',
56    '.DBLBBBBBBD.',
57    'DBBBBBBBBBBD',
58    'DBBBBBBBBBBD',
59    'DBBBBBBBBBBD',
60    'DBBBBBBBBBBD',
61    '.DBBBBBBBBD.',
62    '..DDDDDDDD..',
63  ],
64}
65
66/** Where the face sits on each body: eye top row, mouth row. */
67const FACE: Record<Stage, { eye: number; mouth: number }> = {
68  egg: { eye: 5, mouth: 8 },
69  blob: { eye: 6, mouth: 9 },
70  critter: { eye: 5, mouth: 8 },
71  crowned: { eye: 6, mouth: 9 },
72}
73
74type Eyes = { left: readonly string[]; right: readonly string[] }
75const both = (rows: readonly string[]): Eyes => ({ left: rows, right: rows })
76
77const EYES: Record<Mood | 'blink', Eyes> = {
78  idle: both(['WE', 'EE']),
79  blink: both(['..', 'EE']),
80  happy: { left: ['EE', 'E.'], right: ['EE', '.E'] },
81  eating: { left: ['EE', 'E.'], right: ['EE', '.E'] },
82  sleep: both(['..', 'EE']),
83  sick: { left: ['E.', '.E'], right: ['.E', 'E.'] },
84  panic: both(['WW', 'WE']),
85  sad: { left: ['E.', 'EE'], right: ['.E', 'EE'] },
86  levelup: both(['WW', 'WW']),
87}
88
89const MOUTH: Record<Mood, readonly string[]> = {
90  idle: ['.EE.'],
91  happy: ['E..E', '.EE.'],
92  eating: ['.EE.', '.EE.'],
93  sleep: ['.EE.'],
94  sick: ['.E.E', 'E.E.'],
95  panic: ['EEEE', 'EEEE'],
96  sad: ['.EE.', 'E..E'],
97  levelup: ['E..E', '.EE.'],
98}
99
100const stamp = (rows: string[], patch: readonly string[], top: number, left: number) => {
101  patch.forEach((line, r) => {
102    const row = rows[top + r]
103    if (row === undefined) return
104    let out = row
105    for (let i = 0; i < line.length; i++) {
106      if (line[i] === '.') continue
107      out = out.slice(0, left + i) + line[i] + out.slice(left + i + 1)
108    }
109    rows[top + r] = out
110  })
111}
112
113/** The body with this mood's face drawn on. `chew` closes an eating mouth. */
114export const compose = (stage: Stage, mood: Mood, blink: boolean, chew = false) => {
115  const rows = [...BODY[stage]]
116  const face = FACE[stage]
117  const eyes = blink && (mood === 'idle' || mood === 'sad') ? EYES.blink : EYES[mood]
118  stamp(rows, eyes.left, face.eye, 3)
119  stamp(rows, eyes.right, face.eye, 7)
120  stamp(rows, mood === 'eating' && chew ? ['EEEE'] : MOUTH[mood], face.mouth, 4)
121  if (mood === 'happy' || mood === 'levelup') {
122    stamp(rows, ['P'], face.eye + 2, 2)
123    stamp(rows, ['P'], face.eye + 2, 9)
124  }
125  if (mood === 'sad') stamp(rows, ['T'], face.eye + 2, 3)
126  return rows
127}
128
129const BG = 0x0b1121
130const STAR = 0x2a3550
131const GROUND = 0x161d2e
132const GROUND_EDGE = 0x2a3348
133const BLUE = 0x61a5fa
134const GOLD = 0xc2a87e
135const GOLD_HI = 0xd4a44a
136const RED = 0xe5484d
137const PAPER = 0xe8e2d6
138const CLOUD = 0x59606e
139
140const BODY_COLORS: Record<Stage, { B: number; L: number; D: number }> = {
141  egg: { B: 0xe8e2d6, L: 0xfbf8f1, D: 0x8f8676 },
142  blob: { B: 0xc2a87e, L: 0xe3d3b3, D: 0x6e5b3d },
143  critter: { B: 0xc9a36a, L: 0xe8d2a6, D: 0x6b5332 },
144  crowned: { B: 0x61a5fa, L: 0xa9cdfd, D: 0x2b5a9a },
145}
146
147const paletteFor = (stage: Stage, mood: Mood, t: number) => {
148  const base = BODY_COLORS[stage]
149  let { B, L, D } = base
150  if (mood === 'sick') {
151    B = mix(B, 0x8f98a3, 0.65)
152    L = mix(L, 0xb8bec6, 0.65)
153  }
154  if (mood === 'sleep') {
155    B = mix(B, BG, 0.35)
156    L = mix(L, BG, 0.35)
157    D = mix(D, BG, 0.25)
158  }
159  if (mood === 'panic' && Math.floor(t * 8) % 2 === 0) {
160    B = RED
161    L = 0xf08a8d
162    D = 0x7a1f22
163  }
164  if (mood === 'levelup' && Math.floor(t * 6) % 2 === 0) {
165    B = mix(B, 0xffffff, 0.45)
166    L = 0xffffff
167  }
168  return {
169    B,
170    L,
171    D,
172    E: 0x1a1410,
173    W: 0xfbf8f1,
174    T: stage === 'crowned' ? 0xd7e8ff : BLUE,
175    P: 0xe08a7a,
176    G: GOLD_HI,
177  }
178}
179
180const HEART = ['.X.X.', 'XXXXX', '.XXX.', '..X..']
181const SPARK = ['.X.', 'XXX', '.X.']
182
183const hash = (n: number) => {
184  const x = Math.sin(n * 127.1) * 43758.5453
185  return x - Math.floor(x)
186}
187
188/** Sprite scale for a scene this wide: double pixels when there is room. */
189export const scaleFor = (columns: number) => (columns >= 36 ? 2 : 1)
190
191/** Cell rows the scene takes at this width. */
192export const rowsFor = (columns: number) => (scaleFor(columns) === 2 ? 16 : 9)
193
194export type View = { stage: Stage; mood: Mood; caption?: string }
195
196/** One frame of the room at time `t` (seconds), as a canvas. */
197export const scene = (columns: number, rows: number, t: number, view: View): Canvas => {
198  const w = Math.max(12, columns)
199  const h = rows * 2
200  const c = canvas(w, h, BG)
201  const scale = scaleFor(w)
202  const { mood, stage } = view
203
204  // A slow starfield, then the floor.
205  for (let i = 0; i < Math.floor(w / 3); i++) {
206    const sx = Math.floor(hash(i + 1) * w)
207    const sy = Math.floor(hash(i + 50) * (h - 6))
208    const twinkle = (Math.sin(t * 1.5 + i) + 1) / 2
209    set(c, sx, sy, mix(BG, STAR, 0.3 + twinkle * 0.7))
210  }
211  rect(c, 0, h - 2, w, 2, GROUND)
212  rect(c, 0, h - 2, w, 1, GROUND_EDGE)
213
214  const blink = t % 4.2 < 0.16
215  const chew = Math.floor(t * 6) % 2 === 0
216  const body = compose(stage, mood, blink, chew)
217  const sw = 12 * scale
218  const sh = body.length * scale
219
220  const roam = mood === 'idle' || mood === 'happy' || mood === 'sad' || mood === 'eating'
221  const room = Math.max(0, w - sw - 4)
222  const drift = roam ? Math.sin(t * 0.32) * (room / 2) * 0.8 : 0
223  const facingLeft = roam && Math.cos(t * 0.32) < 0
224  let x = Math.round((w - sw) / 2 + drift)
225  let lift = 0
226  if (mood === 'idle' || mood === 'sad') lift = Math.round((Math.sin(t * 3.2) + 1) / 2) * (scale === 2 ? 1 : 0)
227  if (mood === 'happy' || mood === 'levelup') lift = Math.round(Math.abs(Math.sin(t * 6.5)) * 4 * scale)
228  if (mood === 'eating') lift = chew ? 1 : 0
229  if (mood === 'panic') {
230    x += Math.floor(t * 20) % 2 === 0 ? -scale : scale
231    lift = Math.floor(t * 13) % 3 === 0 ? scale : 0
232  }
233  if (mood === 'sick') x += Math.round(Math.sin(t * 2) * scale)
234  if (mood === 'sleep') lift = 0
235
236  const y = h - 2 - sh - lift
237  // Shadow under the pet, smaller as it hops.
238  const shadow = Math.max(2, sw - 4 - lift)
239  rect(c, x + Math.round((sw - shadow) / 2), h - 2, shadow, 1, mix(GROUND, 0x000000, 0.5))
240  sprite(c, body, paletteFor(stage, mood, t), x, y, scale, facingLeft)
241
242  const headX = x + sw / 2
243  const top = y
244
245  if (mood === 'sleep') {
246    for (let k = 0; k < 3; k++) {
247      const phase = (t * 0.45 + k / 3) % 1
248      const col = Math.round(headX + sw / 2 - 2 + phase * 6)
249      const row = Math.floor((top - phase * 10) / 2)
250      text(c, col, row, phase < 0.5 ? 'z' : 'Z', mix(BLUE, BG, phase * 0.8))
251    }
252  }
253
254  if (mood === 'panic') {
255    const on = Math.floor(t * 6) % 2 === 0
256    text(c, Math.round(x - 3), Math.floor((top + 2) / 2), on ? '!' : ' ', RED)
257    text(c, Math.round(x + sw + 2), Math.floor((top + 4) / 2), on ? ' ' : '!', RED)
258    text(c, Math.round(headX - 1), Math.max(0, Math.floor(top / 2) - 2), '!!', RED)
259  }
260
261  if (mood === 'sick') {
262    const drop = (t * 0.9) % 1
263    set(c, x + sw + 1, top + 4 + drop * 8, BLUE)
264    set(c, x + sw + 1, top + 5 + drop * 8, mix(BLUE, BG, 0.4))
265  }
266
267  if (mood === 'sad') {
268    const cx = Math.round(headX - 7)
269    const cy = Math.max(0, top - 9)
270    rect(c, cx + 2, cy, 10, 2, CLOUD)
271    rect(c, cx, cy + 2, 14, 2, CLOUD)
272    for (let k = 0; k < 4; k++) {
273      const fall = (t * 1.6 + k * 0.27) % 1
274      set(c, cx + 2 + k * 3, cy + 4 + fall * 5, BLUE)
275    }
276  }
277
278  if (mood === 'happy') {
279    for (let k = 0; k < 2; k++) {
280      const phase = (t * 0.6 + k * 0.5) % 1
281      const hx = Math.round(headX + (k === 0 ? -sw / 2 - 5 : sw / 2 + 1) + Math.sin(t * 3 + k) * 1)
282      const hy = Math.round(top + 6 - phase * 12)
283      sprite(c, HEART, { X: mix(0xe08a7a, BG, phase * 0.7) }, hx, hy)
284    }
285  }
286
287  if (mood === 'eating') {
288    const phase = (t * 1.4) % 1
289    const fromX = w - 3
290    const mouthX = facingLeft ? x + 2 : x + sw - 4
291    const mouthY = y + FACE[stage].mouth * scale
292    const px = fromX + (mouthX - fromX) * phase
293    const py = mouthY - Math.sin(phase * Math.PI) * 8
294    rect(c, Math.round(px), Math.round(py), 2, 2, GOLD)
295    text(c, Math.round(headX - 1), Math.max(0, Math.floor(top / 2) - 1), chew ? 'nom' : '', PAPER)
296  }
297
298  if (mood === 'levelup') {
299    for (let k = 0; k < 5; k++) {
300      const a = t * 2.2 + (k * Math.PI * 2) / 5
301      const r = sw / 2 + 3 + Math.sin(t * 4 + k) * 2
302      sprite(c, SPARK, { X: k % 2 ? GOLD_HI : BLUE }, headX + Math.cos(a) * r - 1, top + sh / 2 + Math.sin(a) * (sh / 2 + 2) - 1)
303    }
304  }
305
306  const caption = view.caption ?? (mood === 'levelup' ? 'LEVEL UP' : '')
307  if (caption) text(c, Math.round((w - caption.length) / 2), 0, caption.toUpperCase(), mood === 'panic' ? RED : GOLD)
308
309  return c
310}
311
312/** One frame as Raster cells. */
313export const petFrame = (columns: number, rows: number, t: number, view: View) =>
314  encode(scene(columns, rows, t, view))
315
hooks/pixels.ts 111 lines
1/** A tiny pixel canvas packed into Raster cells: two pixels per cell with the upper half block. */
2
3export const DEFAULT = 0x01000000
4const HALF = 0x2580
5
6export type Canvas = {
7  w: number
8  h: number
9  px: Uint32Array
10  /** Cell index (row * w + col) to a glyph drawn over the pixels. */
11  glyphs: Map<number, { ch: number; fg: number }>
12}
13
14export const canvas = (w: number, h: number, bg: number): Canvas => ({
15  w,
16  h,
17  px: new Uint32Array(w * h).fill(bg),
18  glyphs: new Map(),
19})
20
21export const set = (c: Canvas, x: number, y: number, color: number) => {
22  const px = Math.round(x)
23  const py = Math.round(y)
24  if (px < 0 || py < 0 || px >= c.w || py >= c.h) return
25  c.px[py * c.w + px] = color
26}
27
28export const get = (c: Canvas, x: number, y: number) => c.px[y * c.w + x] ?? 0
29
30/** Text over the pixels, in cell coordinates (one cell = one column, two pixel rows). */
31export const text = (c: Canvas, col: number, row: number, s: string, fg: number) => {
32  const rows = c.h / 2
33  for (let i = 0; i < s.length; i++) {
34    const x = Math.round(col) + i
35    const r = Math.round(row)
36    if (x < 0 || x >= c.w || r < 0 || r >= rows) continue
37    const ch = s.charCodeAt(i)
38    if (ch === 0x20) continue
39    c.glyphs.set(r * c.w + x, { ch, fg })
40  }
41}
42
43/** Draws `rows` (one char per pixel, '.' transparent) at x,y, each pixel `scale` square. */
44export const sprite = (
45  c: Canvas,
46  rows: readonly string[],
47  palette: Record<string, number>,
48  x: number,
49  y: number,
50  scale = 1,
51  flip = false,
52) => {
53  const ox = Math.round(x)
54  const oy = Math.round(y)
55  rows.forEach((line, r) => {
56    for (let i = 0; i < line.length; i++) {
57      const ch = line[flip ? line.length - 1 - i : i]!
58      const color = palette[ch]
59      if (ch === '.' || color === undefined) continue
60      for (let sy = 0; sy < scale; sy++) {
61        for (let sx = 0; sx < scale; sx++) set(c, ox + i * scale + sx, oy + r * scale + sy, color)
62      }
63    }
64  })
65}
66
67export const rect = (c: Canvas, x: number, y: number, w: number, h: number, color: number) => {
68  for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) set(c, x + i, y + j, color)
69}
70
71export const mix = (a: number, b: number, t: number) => {
72  const ch = (shift: number) => Math.round(((a >> shift) & 0xff) * (1 - t) + ((b >> shift) & 0xff) * t)
73  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
74}
75
76const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
77
78export const toBase64 = (bytes: Uint8Array) => {
79  let out = ''
80  for (let i = 0; i < bytes.length; i += 3) {
81    const a = bytes[i] ?? 0
82    const b = bytes[i + 1] ?? 0
83    const c = bytes[i + 2] ?? 0
84    const n = (a << 16) | (b << 8) | c
85    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
86    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
87    out += i + 2 < bytes.length ? B64[n & 63]! : '='
88  }
89  return out
90}
91
92/** The canvas as RasterProps cells: `w` columns by `h / 2` rows. */
93export const words = (c: Canvas) => {
94  const rows = Math.floor(c.h / 2)
95  const out = new Uint32Array(c.w * rows * 3)
96  for (let r = 0; r < rows; r++) {
97    for (let x = 0; x < c.w; x++) {
98      const top = get(c, x, r * 2)
99      const bottom = get(c, x, r * 2 + 1)
100      const i = (r * c.w + x) * 3
101      const g = c.glyphs.get(r * c.w + x)
102      out[i] = g ? g.ch : HALF
103      out[i + 1] = g ? g.fg : top
104      out[i + 2] = g ? mix(top, bottom, 0.5) : bottom
105    }
106  }
107  return out
108}
109
110export const encode = (c: Canvas) => toBase64(new Uint8Array(words(c).buffer))
111
types/index.d.ts 28 lines
1export type Mood = 'idle' | 'happy' | 'eating' | 'sick' | 'panic' | 'sleep' | 'sad' | 'levelup'
2
3export type Stage = 'egg' | 'blob' | 'critter' | 'crowned'
4
5/** What lives across sessions in $.store. Times are epoch ms. */
6export type Pet = {
7  name: string
8  xp: number
9  /** 0 starving, 100 full. */
10  hunger: number
11  /** 0 to 100. */
12  hp: number
13  bornAt: number
14  lastFedAt: number
15  /** When decay was last applied. */
16  lastTickAt: number
17  meals: number
18}
19
20/** A scripted look the demo forces, over the real pet. */
21export type DemoView = { mood: Mood; stage: Stage; caption: string }
22
23declare module 'claude-code' {
24  interface PluginState {
25    'code-pet': { pet: Pet | null; mood: Mood; demo: DemoView | null }
26  }
27}
28