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…

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.

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.
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
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| code-pet | No | No | No | No | No |
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.
hooks/register.tsx 294 lines1import { 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}
294hooks/pet.ts 174 lines1/** 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}
174hooks/scene.ts 315 lines1/** 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))
315hooks/pixels.ts 111 lines1/** 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))
111types/index.d.ts 28 lines1export 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