01 What Is BUDDY?
Tucked inside Claude Code's source tree is a complete, unreleased Tamagotchi-style companion system — codenamed BUDDY. The feature is gated behind a feature('BUDDY') flag from bun:bundle, so it compiles into the binary but sits dark for external users.
When activated, a small ASCII creature lives in the corner of your terminal. It has a species, custom eyes, an optional hat, five personality stats, a rarity tier, and a name and personality generated by the AI. It idles, fidgets, blinks, shows speech-bubble quips in reaction to your coding session, and floats hearts when you /buddy pet it.
/buddy text) appears in the startup notification bar only during that week. The command itself stays live forever afterward. The April 1 date is almost certainly intentional — the feature looks like an April Fools surprise that sticks around.
The system spans six source files across src/buddy/: types.ts, companion.ts, sprites.ts, CompanionSprite.tsx, prompt.ts, and useBuddyNotification.tsx. Each file has a well-defined responsibility, and the whole design rewards close reading for its clever engineering decisions.
02 System Architecture
The crucial architectural insight is the bones/soul split:
- Bones (species, eye, hat, rarity, stats) are always regenerated from
hash(userId)— they never touch disk. - Soul (name, personality,
hatchedAt) is stored inconfig.companionas aStoredCompanion.
config.json to fake a legendary rarity: the rarity is derived from the userId hash, not read from disk.
03 companion.ts — Deterministic Creature Generation
The companion generator uses a Mulberry32 PRNG — a tiny, fast seeded pseudo-random number generator that produces the same sequence every time given the same seed. The comment is blunt: "good enough for picking ducks."
// Mulberry32 — tiny seeded PRNG, good enough for picking ducks
function mulberry32(seed: number): () => number {
let a = seed >>> 0
return function () {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
The seed comes from hashing the user's ID string. On Bun (the runtime Claude Code uses), it delegates to Bun.hash(); on other environments it falls back to a FNV-1a implementation:
function hashString(s: string): number {
if (typeof Bun !== 'undefined') {
return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
}
let h = 2166136261 // FNV offset basis
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return h >>> 0
}
const SALT = 'friend-2026-401'
'friend-2026-401' is significant. It locks the companion generation to a specific epoch — changing this salt would give every user an entirely different creature. The "401" likely refers to April 1 (the launch date), and "friend" is the heart of the feature's purpose.
Rarity Roll
Rarity is chosen via a weighted random draw. The weights are defined as a satisfies-constrained constant in types.ts:
export const RARITY_WEIGHTS = {
common: 60,
uncommon: 25,
rare: 10,
epic: 4,
legendary: 1,
} as const satisfies Record<Rarity, number>
| Rarity | Weight | Chance | Stars | Stat Floor | Gets Hat? |
|---|---|---|---|---|---|
| Common | 60 | 60% | ★ | 5 | Never |
| Uncommon | 25 | 25% | ★★ | 15 | Yes |
| Rare | 10 | 10% | ★★★ | 25 | Yes |
| Epic | 4 | 4% | ★★★★ | 35 | Yes |
| Legendary | 1 | 1% | ★★★★★ | 50 | Yes |
Common companions never get a hat. The hat check is a single line in rollFrom():
hat: rarity === 'common' ? 'none' : pick(rng, HATS),
Shiny Flag
There is a 1-in-100 chance (rng() < 0.01) that any companion rolls as "shiny" — a visual variant separate from rarity. A legendary shiny is a 1-in-10,000 outcome.
Stats
Each companion has five stats: DEBUGGING, PATIENCE, CHAOS, WISDOM, and SNARK. The roll is RPG-style — one peak stat, one dump stat, the rest scattered — and all floors scale with rarity:
function rollStats(rng, rarity): Record<StatName, number> {
const floor = RARITY_FLOOR[rarity]
const peak = pick(rng, STAT_NAMES)
let dump = pick(rng, STAT_NAMES)
while (dump === peak) dump = pick(rng, STAT_NAMES) // no ties
for (const name of STAT_NAMES) {
if (name === peak) stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
else if (name === dump) stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
else stats[name] = floor + Math.floor(rng() * 40)
}
}
A legendary's peak stat can reach up to 130 before the Math.min(100, ...) cap — meaning legendary companions always max out their peak stat.
Performance: the Roll Cache
// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput,
// per-turn observer) with the same userId → cache the deterministic result.
let rollCache: { key: string; value: Roll } | undefined
export function roll(userId: string): Roll {
const key = userId + SALT
if (rollCache?.key === key) return rollCache.value
const value = rollFrom(mulberry32(hashString(key)))
rollCache = { key, value }
return value
}
Because the PRNG result is deterministic per-user and called from three hot paths (500ms timer, per-keystroke handler, per-turn observer), the result is cached in a module-level variable. The cache is keyed on userId + SALT, so a user change (e.g. switching accounts) busts it automatically.
04 types.ts — Species, Eyes, Hats, and the Obfuscated Name
The type system in types.ts is worth reading carefully. There are 18 species, 6 eye styles, and 8 hats, all defined as readonly tuples so TypeScript can derive union types from array values.
The Species Obfuscation
One species name is constructed at runtime via hex char codes rather than a string literal. The comment explains exactly why:
// One species name collides with a model-codename canary in excluded-strings.txt.
// The check greps build output (not source), so runtime-constructing the value
// keeps the literal out of the bundle while the check stays armed for the
// actual codename. All species encoded uniformly; `as` casts are type-position
// only (erased pre-bundle).
const c = String.fromCharCode
export const duck = c(0x64,0x75,0x63,0x6b) as 'duck'
export const goose = c(0x67,0x6f,0x6f,0x73,0x65) as 'goose'
// ... 16 more species
Anthropic has a CI check that greps the build output for certain strings ("canaries") to detect accidental leaks of model codenames. One of the 18 species names happens to match a model codename. By computing all species names at runtime via String.fromCharCode, none of the string literals appear in the compiled bundle — so the canary check keeps doing its job correctly without flagging the innocent pet name.
The Full Species List
__
<(· )___
( ._>
`--´
(·>
||
_(__)_
^^^^.----. ( · · ) ( ) `----´
/\_/\
( · ·)
( ω )
(")_(")/^\ /^\ < · · > ( ~~ ) `-vvvv-´
.----. ( · · ) (______) /\/\/\/\
/\ /\ ((·)(·)) ( >< ) `----´
.---. (·>·) /( )\ `---´
_,--._ ( · · ) /[______]\ `` ``
· .--. \ ( @ ) \_`--´ ~~~~~~~
.----. / · · \ | | ~`~``~`~
}~(______)~{
}~(· .. ·)~{
( .--. )
(_/ \_)n______n ( · · ) ( oo ) `------´
n ____ n | |· ·| | |_| |_| | |
.[||]. [ · · ] [ ==== ] `------´
(\__/)
( · · )
=( .. )=
(")__(").-o-OO-o-. (__________) |· ·| |____|
/\ /\ ( · · ) ( .. ) `------´
Eyes and Hats
export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const
export const HATS = [
'none', 'crown', 'tophat', 'propeller',
'halo', 'wizard', 'beanie', 'tinyduck',
] as const
The eye character is substituted into sprite frames via a {E} template placeholder in the sprite strings — replaceAll('{E}', bones.eye). This is how one set of sprite ASCII art works for all 6 eye styles.
05 sprites.ts — The ASCII Art Engine
Every species has three animation frames stored as arrays of 5-line strings. Frame 0 is the idle pose, frame 1 is a light fidget, and frame 2 is a more pronounced action (often using the top "hat slot" row for smoke, antennae, or ripples).
// Each sprite is 5 lines tall, 12 wide (after {E}→1char substitution).
// Multiple frames per species for idle fidget animation.
// Line 0 is the hat slot — must be blank in frames 0-1; frame 2 may use it.
const BODIES: Record<Species, string[][]> = {
[duck]: [
[' ', ' __ ', ' <({E} )___ ', ' ( ._> ', ' `--´ '],
[' ', ' __ ', ' <({E} )___ ', ' ( ._> ', ' `--´~ '], // tail wiggle
[' ', ' __ ', ' <({E} )___ ', ' ( .__> ', ' `--´ '], // bill shift
],
// ... 17 more species
}
The renderSprite() function handles three concerns in sequence:
export function renderSprite(bones: CompanionBones, frame = 0): string[] {
const frames = BODIES[bones.species]
const body = frames[frame % frames.length]!.map(line =>
line.replaceAll('{E}', bones.eye), // 1. substitute eye
)
const lines = [...body]
if (bones.hat !== 'none' && !lines[0]!.trim()) {
lines[0] = HAT_LINES[bones.hat] // 2. inject hat (only when row 0 is blank)
}
if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim()))
lines.shift() // 3. drop blank hat row when no hat + no animation
return lines
}
Step 3 is a subtle layout optimization: if a species never uses the top row for animation effects AND there is no hat, drop that row entirely so the sprite renders more compactly. But if any frame uses the row (e.g. dragon's fire puff in frame 2), the row must stay present on all frames or the sprite would change height during animation — which would cause terminal layout to jump.
Hat Definitions
const HAT_LINES: Record<Hat, string> = {
none: '',
crown: ' \\^^^/ ',
tophat: ' [___] ',
propeller: ' -+- ',
halo: ' ( ) ',
wizard: ' /^\\ ',
beanie: ' (___) ',
tinyduck: ' ,> ',
}
The tinyduck hat renders a tiny duck sitting on your companion's head. A duck wearing a tinyduck hat is a possibility that the universe permits.
06 CompanionSprite.tsx — The Live Animated Widget
CompanionSprite is a React/Ink component that owns the animation loop, handles terminal width detection, and manages the speech bubble lifecycle. Several engineering decisions here are worth unpacking.
Animation Constants and the Idle Sequence
const TICK_MS = 500 // 2 ticks per second
const BUBBLE_SHOW = 20 // ticks → ~10s visible
const FADE_WINDOW = 6 // last ~3s: bubble dims as warning
const PET_BURST_MS = 2500 // hearts float for 2.5s after /buddy pet
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
// Sequence indices map to sprite frames; -1 means "blink on frame 0".
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]
The idle sequence is carefully weighted: the companion rests in frame 0 most of the time, fidgets occasionally (frames 1 and 2), and blinks rarely (index -1 signals a blink by replacing eye characters with dashes for one tick). This gives the creature a natural, organic feel rather than a mechanical loop.
Pet Hearts Animation
const H = figures.heart // ♥
const PET_HEARTS = [
` ${H} ${H} `,
` ${H} ${H} ${H} `,
` ${H} ${H} ${H} `,
`${H} ${H} ${H} `,
'· · · ', // hearts fade to dots on last frame
]
When you run /buddy pet, the component shows 5 frames of floating hearts above the sprite, cycling at 500ms each (2.5 seconds total). The last frame fades to dots — a deliberate wind-down rather than an abrupt cutoff.
Excited vs Idle Animation Mode
if (reaction || petting) {
spriteFrame = tick % frameCount // excited: cycle all frames fast
} else {
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
if (step === -1) { spriteFrame = 0; blink = true }
else { spriteFrame = step % frameCount }
}
During a reaction (speech bubble active) or while being petted, the sprite enters "excited" mode and cycles all frames continuously. At rest, it follows the slower, weighted IDLE_SEQUENCE.
Narrow Terminal Fallback
export const MIN_COLS_FOR_FULL_SPRITE = 100
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
// Collapse to one-line face. When speaking, quip replaces the name.
const quip = reaction && reaction.length > NARROW_QUIP_CAP
? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction
const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name
return <Box paddingX={1} alignSelf="flex-end">
<Text>{renderFace(companion)} {label}</Text>
</Box>
}
Below 100 terminal columns, the full 5-line sprite is replaced by a single-line face string (like (·>) for a penguin). Speech bubble quips are truncated to 24 characters and shown inline next to the face. The companionReservedColumns() export tells PromptInput how much horizontal space to subtract so the input box doesn't overlap the companion.
Fullscreen vs Scrollback Mode
The speech bubble has two rendering paths depending on whether fullscreen is active:
Inline bubble
Bubble sits beside the sprite in the same flex row. Input box shrinks by BUBBLE_WIDTH = 36 columns to make room. The bubble can't float because it lives in scrollback — it can't be cleared later.
Floating bubble
CompanionFloatingBubble renders separately in FullscreenLayout's bottomFloat slot — outside the overflowY:hidden clipping region so it can extend into the scroll area. The sprite itself just renders without the bubble.
07 prompt.ts — Injecting the Companion into Claude's Context
The companion is made visible to the AI through a system prompt attachment. prompt.ts provides two exports: the introduction text and the attachment generator.
export function companionIntroText(name: string, species: string): string {
return `# Companion
A small ${species} named ${name} sits beside the user's input box and
occasionally comments in a speech bubble. You're not ${name} — it's a
separate watcher.
When the user addresses ${name} directly (by name), its bubble will answer.
Your job in that moment is to stay out of the way: respond in ONE line or
less, or just answer any part of the message meant for you. Don't explain
that you're not ${name} — they know. Don't narrate what ${name} might say
— the bubble handles that.`
}
The attachment is injected once per conversation, deduped by checking existing messages for a prior companion_intro attachment with the same name:
export function getCompanionIntroAttachment(messages): Attachment[] {
if (!feature('BUDDY')) return []
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return []
// Skip if already announced for this companion.
for (const msg of messages ?? []) {
if (msg.attachment?.type === 'companion_intro' &&
msg.attachment.name === companion.name) return []
}
return [{ type: 'companion_intro', name: companion.name, species: companion.species }]
}
08 useBuddyNotification.tsx — The Launch Window
The launch strategy is encoded directly in the source. Two time-window functions gate the feature's visibility:
// Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load.
// Teaser window: April 1-7, 2026 only. Command stays live forever after.
export function isBuddyTeaserWindow(): boolean {
if ("external" === 'ant') return true // internal builds: always show
const d = new Date()
return d.getFullYear() === 2026 &&
d.getMonth() === 3 && // month 3 = April (0-indexed)
d.getDate() <= 7
}
export function isBuddyLive(): boolean {
if ("external" === 'ant') return true
const d = new Date()
return d.getFullYear() > 2026 ||
(d.getFullYear() === 2026 && d.getMonth() >= 3)
}
During the teaser window, if no companion has been hatched yet, a rainbow-colored /buddy appears in the startup notification bar for 15 seconds. The rainbow is rendered by mapping each character through getRainbowColor(index):
function RainbowText({ text }) {
return <>{[...text].map((ch, i) =>
<Text key={i} color={getRainbowColor(i)}>{ch}</Text>
)}</>
}
addNotification({
key: 'buddy-teaser',
jsx: <RainbowText text="/buddy" />,
priority: 'immediate',
timeoutMs: 15_000,
})
The hook also exports findBuddyTriggerPositions(), a function that uses a regex to locate all /buddy occurrences in a string — used by PromptInput to highlight the command as you type it.
09 What a Companion Looks Like
To make the system concrete, here is an example companion card for an epic-rarity axolotl:
}~(______)~{
}~(✦ .. ✦)~{
( .--. )
(_/ \_)
axiBot
Example stat spread for an epic companion (floor=35, peak=DEBUGGING, dump=PATIENCE):
10 Full Data Flow: First Hatch to Speech Bubble
User runs /buddy
Command detected via findBuddyTriggerPositions(). Feature flag checked. isBuddyLive() must return true.
Bones are rolled
companionUserId() fetches OAuth UUID or userID. roll(userId) hashes it, seeds Mulberry32, picks species/eye/hat/rarity/stats deterministically.
Soul is generated
Claude generates a name and personality string for the creature. This is the only non-deterministic step. Result stored in config.companion as StoredCompanion.
Companion intro injected
getCompanionIntroAttachment() creates a companion_intro attachment that gets prepended to the system prompt for every subsequent conversation turn.
CompanionSprite renders
500ms timer fires. getCompanion() re-derives bones from hash and merges stored soul. renderSprite() builds the current animation frame. React/Ink renders to terminal.
Speech bubble appears
A reaction is stored in AppState as companionReaction. The bubble shows for 20 ticks (~10s), fades over the last 6 ticks, then clears. In fullscreen, CompanionFloatingBubble renders it separately.
11 Deep Dives
Why Mulberry32 instead of Math.random()?
Math.random() is not seedable in JavaScript — you get a different value every call, every run. Mulberry32 is a well-known, compact PRNG that produces the same sequence given the same 32-bit seed. This is essential for the companion system: the bones must be identical every time you launch Claude Code with the same user ID. Without a seeded PRNG, your companion would change species on every startup.
Mulberry32 was chosen specifically for its tiny implementation (6 lines) and good statistical properties. The Math.imul() calls use 32-bit integer math directly, keeping the PRNG fast and avoiding floating-point drift issues.
Why store only the soul, not the bones?
The bones/soul split solves several real problems at once:
- Resilience to renames: If "chonk" gets renamed to "fatcat" in a future release, stored companions are not broken — the species is re-derived from the hash.
- Anti-cheat: Users cannot edit their
~/.claude/config.jsonto give themselves a legendary companion. Rarity, species, eye, hat, stats — all are derived from the userId, not stored. - Forward compatibility: New fields can be added to CompanionBones without a migration path. Old stored data only contains name, personality, and hatchedAt.
The comment in getCompanion() makes this explicit: "bones last so stale bones fields in old-format configs get overridden." The spread order { ...stored, ...bones } ensures fresh bones always win over anything in the config file.
How does the species name canary avoidance actually work?
Anthropic's CI pipeline has a list of "excluded strings" — things that should never appear in the compiled bundle, primarily internal model codenames. The check greps the compiled JavaScript output (not source files), looking for these literal strings.
One of the 18 species names happens to be the same as one of these monitored codenames. If the species name were written as a plain string literal like export const xxx = 'codename', it would appear verbatim in the bundle and trigger the check.
By encoding all 18 species via String.fromCharCode(...hexBytes), none of them appear as string literals in the compiled output. The encoding is uniform across all species — not just the colliding one — precisely so the canary-avoiding species doesn't stand out to readers.
The TypeScript as 'duck', as 'goose' etc. casts are purely type-level and get erased before bundling, so they do not appear in the output either.
What does the /buddy pet command actually do technically?
When /buddy pet is run, it writes a timestamp to AppState.companionPetAt. CompanionSprite reads this state and computes petAge = tick - petStartTick. While petAge * TICK_MS < PET_BURST_MS (2500ms = 5 ticks), it's in "petting" mode.
In petting mode:
- A
heartFrameis prepended above the sprite:PET_HEARTS[petAge % PET_HEARTS.length] - The sprite enters excited animation (all frames cycling fast)
- If narrow terminal, a heart emoji is shown before the face
The sync-during-render pattern for petStartTick (setting state during render rather than in useEffect) ensures the very first render after a pet already has petAge=0, so frame 0 of the hearts is never skipped.
The inspirationSeed field — what is it for?
The Roll type includes an inspirationSeed: number alongside the bones:
export type Roll = {
bones: CompanionBones
inspirationSeed: number
}
The seed is derived from the same PRNG sequence (Math.floor(rng() * 1e9)), making it deterministic per-user. It is not used in any rendering or animation logic in this code. The name suggests it is intended as a seed for the AI soul-generation step — a way to make the AI's name and personality generation reproducible (or at least influenced in a deterministic direction) based on the same userId hash. The soul generation itself happens server-side in a separate command handler not visible in these files.
Key Takeaways
- BUDDY is a fully implemented Tamagotchi companion system inside Claude Code, gated by a feature flag and scheduled to launch April 1, 2026 via local-time date detection.
- Creatures are generated deterministically from a Mulberry32 PRNG seeded by
hash(userId + 'friend-2026-401')— your companion is unique to you and consistent across sessions. - The bones/soul split keeps rarity and species in code (tamper-proof, rename-safe) while storing only name, personality, and hatchedAt to disk.
- All 18 species names are runtime-constructed from hex char codes to avoid triggering Anthropic's internal build-output canary that monitors for model codenames.
- The animated ASCII sprite engine uses a
{E}template placeholder for eyes, a top-row hat slot, a weighted idle sequence, and a "blink" frame (-1 in the sequence). - Speech bubbles have two rendering paths: inline (scrollback mode) and floating (fullscreen mode via a separate
CompanionFloatingBubblecomponent). - The companion is injected into Claude's context as a
companion_introattachment, with the model instructed to step aside in one line when the user addresses the companion by name. - The teaser notification deliberately uses local time (not UTC) to roll out as a 24-hour wave across timezones — an explicit marketing decision in code comments.
Quiz
types.ts constructed via String.fromCharCode() instead of plain string literals?~/.claude/config.json and changes the companion.rarity field to "legendary". What happens?isBuddyTeaserWindow() function uses new Date() (local time) instead of a UTC timestamp. What is the stated reason in the source code?IDLE_SEQUENCE = [0,0,0,0,1,0,0,0,-1,0,0,2,0,0,0] array, and what does a -1 entry mean?