The Buddy System: A Virtual Pet Game Inside Your Codebase

A deep dive into Claude Code's Buddy easter egg — deterministic RNG, Mulberry32 PRNG, rarity-weighted distributions, Bones vs Soul

The Problem

In a serious CLI coding tool, you type /buddy, and a duck wearing a crown pops up in your terminal, its eyes rendered as symbols, with ★★★★★ legendary displayed next to it. It has a name, a personality, and five stat values. Every time you open Claude Code, the same duck appears — it's yours.

This isn't a joke. Claude Code's Buddy system is a fully-featured deterministic companion generator that uses a PRNG (Pseudo-Random Number Generator) to derive a unique virtual pet from your userId. The system touches on hash functions, weighted probability distributions, Bones/Soul persistence separation, and other serious engineering topics.

Let's look at the engineering design behind this "April Fools' easter egg."


System Architecture Overview

...

The system splits into two entirely different data flows:

  • Bones — Deterministically derived, never persisted. Recomputing from the userId always yields the exact same result
  • Soul — Model-generated, stored in config after the first hatch. This is the only part that needs persistence

This separation is the most elegant architectural decision in the entire system, which we'll analyze in detail later.


Mulberry32: A Deterministic Random Number Generator

At the heart of the Buddy system is a PRNG called Mulberry32. It's only 6 lines of code, but it determines what every user's companion looks like.

src/buddy/companion.ts:16-25
TypeScript
16function mulberry32(seed: number): () => number {
17 let a = seed >>> 0
18 return function () {
19 a |= 0
20 a = (a + 0x6d2b79f5) | 0
21 let t = Math.imul(a ^ (a >>> 15), 1 | a)
22 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
23 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
24 }
25}

Why Mulberry32

In the world of PRNGs, there are many choices — xorshift128+, PCG, Mersenne Twister. Mulberry32's advantages are:

  1. Extremely compact — State is just a single 32-bit integer, captured by a closure
  2. Good output quality — Passes most tests in the BigCrush test suite (more than sufficient for this use case)
  3. Deterministic — The same seed always produces the same sequence

The source code comment is refreshingly direct: "good enough for picking ducks". This isn't cryptography — it's picking ducks.

Bit Operation Breakdown

Let's break down this algorithm line by line:

Text
1a |= 0 // Force to 32-bit signed integer
2a = (a + 0x6d2b79f5) | 0 // Add a large prime as step constant
3let t = Math.imul(a ^ (a >>> 15), 1 | a) // Mix: XOR right-shift + multiply
4t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t // Second round of mixing
5return ((t ^ (t >>> 14)) >>> 0) / 4294967296 // Normalize to [0, 1)

0x6d2b79f5 is a carefully chosen constant (decimal 1831565813) whose binary representation has a nearly uniform distribution of 0s and 1s. >>> 0 converts the result to an unsigned 32-bit integer, and dividing by 4294967296 (i.e., 2^32) maps it to the [0, 1) interval — matching the range of Math.random().

From userId to Seed

src/buddy/companion.ts:27-37
TypeScript
27function hashString(s: string): number {
28 if (typeof Bun !== 'undefined') {
29 return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
30 }
31 let h = 2166136261
32 for (let i = 0; i < s.length; i++) {
33 h ^= s.charCodeAt(i)
34 h = Math.imul(h, 16777619)
35 }
36 return h >>> 0
37}

There are two code paths here:

  • Bun environment — Uses Bun's built-in Bun.hash() (backed by wyhash), taking the lower 32 bits
  • Fallback path — A hand-written FNV-1a hash. The initial value 2166136261 and multiplier 16777619 are standard FNV parameters

The SALT constant 'friend-2026-401' is appended to the userId before hashing, ensuring that even if someone knows a user's userId, they can't predict the companion without knowing the salt:

src/buddy/companion.ts:84
TypeScript
84const SALT = 'friend-2026-401'

The 401 in the name hints at April 1st — April Fools' Day.


Species Encoding: A Trick to Bypass String Checks

The type definition file contains an interesting engineering decision. Look at how species are defined:

src/buddy/types.ts:14-26
TypeScript
14const c = String.fromCharCode
15export const duck = c(0x64,0x75,0x63,0x6b) as 'duck'
16export const goose = c(0x67, 0x6f, 0x6f, 0x73, 0x65) as 'goose'
17export const blob = c(0x62, 0x6c, 0x6f, 0x62) as 'blob'
18export const cat = c(0x63, 0x61, 0x74) as 'cat'
19export const dragon = c(0x64, 0x72, 0x61, 0x67, 0x6f, 0x6e) as 'dragon'
20// ... 13 more species

Why not just write export const duck = 'duck'? The source comment explains:

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.

Anthropic has an excluded-strings.txt file, and the build pipeline scans artifacts for these restricted strings (typically model codenames). One species name happens to collide with a model codename. The solution wasn't to encode just that one species, but to uniformly encode all species names, keeping the code style consistent and avoiding collision concerns when adding species in the future.

The full list of 18 species: duck, goose, blob, cat, dragon, octopus, owl, penguin, turtle, snail, ghost, axolotl, capybara, cactus, robot, rabbit, mushroom, chonk.

The as 'duck' type assertion ensures TypeScript's type system still knows the literal types of these values — type assertions exist at compile time and don't appear in build artifacts.


Rarity System: Weighted Probability Distribution

src/buddy/types.ts:126-132
TypeScript
126export const RARITY_WEIGHTS = {
127 common: 60,
128 uncommon: 25,
129 rare: 10,
130 epic: 4,
131 legendary: 1,
132} as const satisfies Record<Rarity, number>

The total weight is 100, so these values directly represent percentage probabilities. The rollRarity() function implements weighted random selection:

src/buddy/companion.ts:43-51
TypeScript
43function rollRarity(rng: () => number): Rarity {
44 const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
45 let roll = rng() * total
46 for (const rarity of RARITIES) {
47 roll -= RARITY_WEIGHTS[rarity]
48 if (roll < 0) return rarity
49 }
50 return 'common'
51}
...

This is the classic "roulette wheel selection" algorithm. Each rarity occupies a segment on a number line, and whichever segment the random number lands in gets selected. The final return 'common' is a floating-point precision safety net — under normal circumstances it will never be reached.

How Rarity Affects the Companion

Rarity isn't just a label; it directly influences two attributes:

Hatcommon rarity companions don't get a hat:

src/buddy/companion.ts:97
TypeScript
97hat: rarity === 'common' ? 'none' : pick(rng, HATS),

Stat floor — Higher rarity means higher base values for all stats:

src/buddy/companion.ts:53-59
TypeScript
53const RARITY_FLOOR: Record<Rarity, number> = {
54 common: 5,
55 uncommon: 15,
56 rare: 25,
57 epic: 35,
58 legendary: 50,
59}

A legendary companion's dump stat (weakest attribute) is at least 50 - 10 + rand(15) = 40-54 points, while a common companion's peak stat (strongest attribute) is only 5 + 50 + rand(30) = 55-84 points.


Stat System: Peak/Dump Design

The five stat names are full of programmer humor:

src/buddy/types.ts:91-98
TypeScript
91export const STAT_NAMES = [
92 'DEBUGGING',
93 'PATIENCE',
94 'CHAOS',
95 'WISDOM',
96 'SNARK',
97] as const

DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK — these aren't game stats, they're a programmer personality test.

Stat allocation uses the classic RPG peak/dump pattern:

src/buddy/companion.ts:62-82
TypeScript
62function rollStats(
63 rng: () => number,
64 rarity: Rarity,
65): Record<StatName, number> {
66 const floor = RARITY_FLOOR[rarity]
67 const peak = pick(rng, STAT_NAMES)
68 let dump = pick(rng, STAT_NAMES)
69 while (dump === peak) dump = pick(rng, STAT_NAMES)
70
71 const stats = {} as Record<StatName, number>
72 for (const name of STAT_NAMES) {
73 if (name === peak) {
74 stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
75 } else if (name === dump) {
76 stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
77 } else {
78 stats[name] = floor + Math.floor(rng() * 40)
79 }
80 }
81 return stats
82}

The design logic:

  1. Randomly select a peak stat — it gets a value of floor + 50 + rand(30)
  2. Randomly select a dump stat, which cannot be the same as the peak — it gets a value of max(1, floor - 10 + rand(15))
  3. All remaining stats get floor + rand(40)

The while (dump === peak) dump = pick(rng, STAT_NAMES) loop guarantees the dump and peak won't be the same stat. In theory this loop could execute multiple times, but each iteration has a 4/5 chance of picking a different stat, so it averages about 1.25 iterations.


Bones vs Soul: An Elegant Persistence Separation

This is the most engineering-rich design in the Buddy system. First, the type definitions:

src/buddy/types.ts:100-124
TypeScript
100// Deterministic parts — derived from hash(userId)
101export type CompanionBones = {
102 rarity: Rarity
103 species: Species
104 eye: Eye
105 hat: Hat
106 shiny: boolean
107 stats: Record<StatName, number>
108}
109
110// Model-generated soul — stored in config after first hatch
111export type CompanionSoul = {
112 name: string
113 personality: string
114}
115
116export type Companion = CompanionBones &
117 CompanionSoul & {
118 hatchedAt: number
119 }
120
121// What actually persists in config. Bones are regenerated from hash(userId)
122// on every read so species renames don't break stored companions and users
123// can't edit their way to a legendary.
124export type StoredCompanion = CompanionSoul & { hatchedAt: number }

The StoredCompanion persisted to config only contains the Soul part (name, personality, hatchedAt). The Bones part is never persisted — it's recomputed from the userId every time it's needed.

This Design Solves Three Problems

1. Anti-cheating — Users can edit ~/.claude/config.json, but modifying the rarity field is pointless because it will be overwritten by the recomputed value:

src/buddy/companion.ts:127-133
TypeScript
127export function getCompanion(): Companion | undefined {
128 const stored = getGlobalConfig().companion
129 if (!stored) return undefined
130 const { bones } = roll(companionUserId())
131 // bones last so stale bones fields in old-format configs get overridden
132 return { ...stored, ...bones }
133}

In { ...stored, ...bones }, bones comes last, so even if the config contains old bones fields, they get overwritten by the freshly computed values.

2. Safe upgrades — If the development team renames a species (e.g., changing blob to slime), or reorders the SPECIES array, no data migration is needed. The old config doesn't contain species information at all — regeneration naturally produces the new values.

3. Format evolutionStoredCompanion has only three fields and is very stable. In the future, Bones can freely add new attributes (such as new hat types) without affecting existing persisted data.


Roll Cache: Hot Path Optimization

src/buddy/companion.ts:105-113
TypeScript
105// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput,
106// per-turn observer) with the same userId → cache the deterministic result.
107let rollCache: { key: string; value: Roll } | undefined
108export function roll(userId: string): Roll {
109 const key = userId + SALT
110 if (rollCache?.key === key) return rollCache.value
111 const value = rollFrom(mulberry32(hashString(key)))
112 rollCache = { key, value }
113 return value
114}

The comment identifies three hot paths:

  1. 500ms sprite tick — The companion sprite's animation frame updates
  2. per-keystroke PromptInput — Input box rendering on every keystroke
  3. per-turn observer — The observer for each conversation turn

All three paths need the current companion's information, but the userId never changes during a session. A simple single-value cache (not a Map, not an LRU, just a single variable) is sufficient — because under normal use the key is always the same.

The elegance of this caching strategy lies in:

  • Zero dependencies (no need for lodash's memoize)
  • Minimal memory footprint (caches only one result)
  • Natural invalidation (if the userId changes — e.g., switching accounts — it automatically recomputes)

Sprite System: ASCII Art Animation

Each species has three animation frames, where each frame is a 5-row by 12-wide ASCII character matrix:

src/buddy/sprites.ts:27-49
TypeScript
27const BODIES: Record<Species, string[][]> = {
28 [duck]: [
29 [
30 ' ',
31 ' __ ',
32 ' <({E} )___ ',
33 ' ( ._> ',
34 ' `--´ ',
35 ],
36 [
37 ' ',
38 ' __ ',
39 ' <({E} )___ ',
40 ' ( ._> ',
41 ' `--´~ ', // Tail wagged
42 ],
43 [
44 ' ',
45 ' __ ',
46 ' <({E} )___ ',
47 ' ( .__> ', // Beak extended
48 ' `--´ ',
49 ],
50 ],

{E} is an eye placeholder, replaced at render time with the companion's eye type (·, , ×, , @, °).

The hat system renders as an overlay on row 0:

src/buddy/sprites.ts:443-452
TypeScript
443const HAT_LINES: Record<Hat, string> = {
444 none: '',
445 crown: ' \\^^^/ ',
446 tophat: ' [___] ',
447 propeller: ' -+- ',
448 halo: ' ( ) ',
449 wizard: ' /^\\ ',
450 beanie: ' (___) ',
451 tinyduck: ' ,> ',
452}

The rendering logic has a subtle detail:

src/buddy/sprites.ts:454-468
TypeScript
454export function renderSprite(bones: CompanionBones, frame = 0): string[] {
455 const frames = BODIES[bones.species]
456 const body = frames[frame % frames.length]!.map(line =>
457 line.replaceAll('{E}', bones.eye),
458 )
459 const lines = [...body]
460 // Only replace with hat if line 0 is empty
461 if (bones.hat !== 'none' && !lines[0]!.trim()) {
462 lines[0] = HAT_LINES[bones.hat]
463 }
464 // Drop blank hat slot when no hat and frame isn't using it
465 if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift()
466 return lines
467}

Two key checks:

  1. Only place hats on empty rows — Some animation frames have effects on row 0 (dragon's ~ smoke, robot's * antenna flash); these frames must not be overwritten by hats
  2. Remove blank rows — If there's no hat and all frames have an empty row 0, remove it to save space. But this only happens when all frames have an empty row; otherwise the height would jump between frames

CompanionSprite: Animation in React Components

The companion sprite is rendered in the terminal as a React component (Ink). Key constants define the animation behavior:

src/buddy/CompanionSprite.tsx:16-23
TypeScript
16const TICK_MS = 500;
17const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms
18const FADE_WINDOW = 6; // last ~3s the bubble dims
19const PET_BURST_MS = 2500; // how long hearts float after /buddy pet
20
21const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0];

IDLE_SEQUENCE is a 15-frame looping sequence:

  • 0 — Rest frame (10/15 = 67% of the time, mostly resting)
  • 1 — Slight movement (2/15 of the time)
  • 2 — Larger movement (1/15 of the time)
  • -1 — Blink effect (1/15 of the time, overlaid on the rest frame)

Each tick is 500ms, so one full cycle takes 7.5 seconds. This pacing makes the companion feel "alive" without being too distracting.

The speech bubble has a fade-out effect: it displays for 20 ticks (10 seconds), with the last 6 ticks (3 seconds) gradually dimming. This gives the user a visual cue that the bubble is about to disappear, rather than vanishing abruptly.


Prompt Integration: How the Companion Interacts with the AI

src/buddy/prompt.ts:7-12
TypeScript
7export function companionIntroText(name: string, species: string): string {
8 return `# Companion
9
10A 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.
11
12When 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.`
13}

This prompt tells Claude AI:

  1. You are not the companion — The companion is a separate entity
  2. The user knows the difference — No need to explain
  3. When the user talks to the companion, step aside — Respond in no more than one line

This is a carefully designed prompt boundary that prevents the AI from trying to role-play as the companion or conflict with the companion's speech bubble.

The introduction system also includes deduplication logic:

src/buddy/prompt.ts:15-36
TypeScript
15export function getCompanionIntroAttachment(
16 messages: Message[] | undefined,
17): Attachment[] {
18 if (!feature('BUDDY')) return []
19 const companion = getCompanion()
20 if (!companion || getGlobalConfig().companionMuted) return []
21
22 // Skip if already announced for this companion.
23 for (const msg of messages ?? []) {
24 if (msg.type !== 'attachment') continue
25 if (msg.attachment.type !== 'companion_intro') continue
26 if (msg.attachment.name === companion.name) return []
27 }
28
29 return [
30 {
31 type: 'companion_intro',
32 name: companion.name,
33 species: companion.species,
34 },
35 ]
36}

It checks the message history for an existing intro attachment with the same companion name, avoiding redundant companion introductions in long conversations that would waste tokens.


Release Timeline: Teaser Window Design

src/buddy/useBuddyNotification.tsx:12-21
TypeScript
12export function isBuddyTeaserWindow(): boolean {
13 if ("external" === 'ant') return true;
14 const d = new Date();
15 return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7;
16}
17export function isBuddyLive(): boolean {
18 if ("external" === 'ant') return true;
19 const d = new Date();
20 return d.getFullYear() > 2026 || d.getFullYear() === 2026 && d.getMonth() >= 3;
21}
Timeline
April 1, 2026
Apr 1-7: Teaser Window
Apr 8 onward: Permanently Available
Total ≈ 3 steps (parallel = faster)
Teaser Window Behavior
On Startup
"Already hatched?"
Rainbow-colored /buddy prompt
No display
Disappears after 15s
Total ≈ 5 steps (parallel = faster)

Two time functions define the release strategy:

  1. Teaser window (April 1-7) — If the user hasn't hatched a companion yet, a rainbow-colored /buddy prompt appears on startup and disappears after 15 seconds
  2. Permanently live (April 8 onward) — The /buddy command is always available, but no longer proactively prompted

The source comment explains the design rationale for using local time instead of UTC:

Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter buzz instead of a single UTC-midnight spike, gentler on soul-gen load.

Using local time means users in Tokyo see the easter egg about 14 hours before users in New York, creating a "discovery wave" spanning 24 hours instead of everyone flooding in at once — which is friendlier to the backend's soul generation (which requires calling the Claude model to generate names and personalities).

"external" === 'ant' is a build-time constant check. In external builds this is always false (because the string "external" doesn't equal "ant"), but in internal builds this value may differ — allowing Anthropic employees to test early.


Rarity Visual System

src/buddy/types.ts:134-148
TypeScript
134export const RARITY_STARS = {
135 common: '★',
136 uncommon: '★★',
137 rare: '★★★',
138 epic: '★★★★',
139 legendary: '★★★★★',
140} as const satisfies Record<Rarity, string>
141
142export const RARITY_COLORS = {
143 common: 'inactive',
144 uncommon: 'success',
145 rare: 'permission',
146 epic: 'autoAccept',
147 legendary: 'warning',
148} as const satisfies Record<Rarity, keyof import('../utils/theme.js').Theme>

The color mapping reuses Claude Code's existing theme colors:

  • common uses inactive (gray) — the most frequent, no need to be eye-catching
  • legendary uses warning (gold/orange) — the most prominent in any theme

The shiny flag has a 1% chance:

src/buddy/companion.ts:98
TypeScript
98shiny: rng() < 0.01,

Combined probability makes a shiny legendary one in ten thousand: 1% * 1% = 0.01%.


A Microcosm of Engineering Culture

The Buddy system may be an easter egg, but its engineering quality is no less rigorous:

Type safety — All constant arrays use as const and satisfies constraints. The type of RARITY_WEIGHTS is Record<Rarity, number>, so if a new rarity tier is added but its weight is forgotten, the compiler will flag the error.

Separation of concerns — Bones (deterministic data), Soul (persisted data), Sprites (rendering logic), and Prompt (AI interaction) are four modules, each with clear responsibilities and no coupling between them.

Defensive programming — The return 'common' at the end of rollRarity() is a floating-point precision safety net; while (dump === peak) prevents peak and dump from overlapping; bones last in the object spread prevents stale data from polluting the result.

Performance awareness — A single-value cache optimizes three hot paths; ASCII sprite matrices use pre-defined arrays rather than runtime generation; feature flags eliminate dead code at build time.

Release strategy — Rather than "ship when done," the team carefully designed a teaser window, timezone-rolling discovery, internal early testing, and other mechanisms.

This system uses roughly 800 lines of code (excluding CompanionSprite's UI code) to embed a complete virtual pet easter egg into a serious development tool. No third-party dependencies, no network requests (except for initial soul generation), no additional processes — just pure deterministic math plus a bit of ASCII art.

This is Anthropic's engineering culture: even an April Fools' easter egg is built with the same rigor as a production feature.