The Algorithm Behind Claude Code Buddy — FNV-1a & Mulberry32 PRNG Explained
[01]From UUID to Buddy: The Big Picture
Every Claude Code Buddy is deterministic. The same UUID always produces the same species, rarity, eyes, hat, shiny status, and stats. There's no server involved, no database lookup, no randomness from Math.random(). The entire generation happens client-side in a single function call.
The pipeline is elegantly simple:
UUID string + SALT → FNV-1a hash → 32-bit seed → Mulberry32 PRNG → sequential rolls
Let's break down each stage.
[02]Stage 1: Salting the Input
Before any hashing occurs, the system concatenates your UUID with a hardcoded salt string:
const rng = mulberry32(hashString(userId + SALT));
// SALT = 'friend-2026-401'
The salt serves three purposes:
| Purpose | Explanation |
|---|---|
| Prevent reverse engineering | Without knowing the salt, you can't predict which UUID maps to which buddy |
| Version control | Changing the salt in a future update would reshuffle all buddies — a "season reset" |
| Namespace isolation | The same UUID used in a different system wouldn't produce the same hash |
The salt 'friend-2026-401' hints at its origin: "friend" (buddy), "2026" (year), "401" (possibly April 1st, the launch date).
[03]Stage 2: FNV-1a Hash — Turning Strings into Numbers
FNV-1a (Fowler–Noll–Vo, variant 1a) is a non-cryptographic hash function created in 1991. It's chosen here for three reasons: it's fast, it has excellent distribution for short strings, and it fits in a single function.
Here's the exact implementation:
function hashString(s: string): number {
let h = 2166136261; // FNV offset basis
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i); // XOR with byte
h = Math.imul(h, 16777619); // multiply by FNV prime
}
return h >>> 0; // convert to unsigned 32-bit
}
Let's decode the magic numbers:
| Constant | Hex | Role |
|---|---|---|
| 2166136261 | 0x811c9dc5 | FNV-1a 32-bit offset basis — the initial hash value |
| 16777619 | 0x01000193 | FNV-1a 32-bit prime — chosen for optimal bit diffusion |
Why FNV-1a instead of FNV-1? The "a" variant XORs before multiplying, which produces better avalanche behavior — a single bit change in the input flips roughly half the output bits. FNV-1 multiplies first, which can leave the lower bits less mixed.
Why Math.imul? JavaScript numbers are 64-bit floats. Normal multiplication (*) would lose precision for large 32-bit integers. Math.imul performs true 32-bit integer multiplication, preserving the low 32 bits exactly as a C compiler would.
Why >>> 0? JavaScript's bitwise operators return signed 32-bit integers. The unsigned right shift by 0 converts the result to an unsigned 32-bit integer (0 to 4,294,967,295), which is what we need as a PRNG seed.
[04]Stage 3: Mulberry32 PRNG — The Random Number Factory
Mulberry32 is a 32-bit pseudorandom number generator designed by Tommy Ettinger. It has a period of 232 (about 4.3 billion values before repeating) and passes the gjrand testing suite for randomness quality.
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return function () {
a |= 0; // ensure signed 32-bit
a = (a + 0x6d2b79f5) | 0; // increment state
let t = Math.imul(a ^ (a >>> 15), 1 | a); // mix: shift, XOR, multiply
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; // further mixing
return ((t ^ (t >>> 14)) >>> 0) / 4294967296; // normalize to [0, 1)
};
}
Let's trace through the algorithm step by step:
| Step | Operation | Purpose |
|---|---|---|
| 1 | a = (a + 0x6d2b79f5) | 0 | Advance state by a large odd constant (Knuth's multiplicative hash increment). The | 0 keeps it as a signed 32-bit integer. |
| 2 | a ^ (a >>> 15) | XOR the state with itself shifted right by 15 bits. This mixes the upper bits into the lower bits. |
| 3 | Math.imul(..., 1 | a) | Multiply by an odd number derived from the state itself. The 1 | a ensures the multiplier is always odd (never zero). |
| 4 | t ^ (t >>> 7) | Another XOR-shift to further diffuse bits. |
| 5 | Math.imul(..., 61 | t) | Second state-dependent multiplication. 61 is prime, and 61 | t ensures the multiplier is always odd. |
| 6 | t ^ (t >>> 14) | Final XOR-shift for output whitening. |
| 7 | >>> 0 / 4294967296 | Convert to unsigned integer, then divide by 232 to get a float in [0, 1). |
Why not Math.random()? Math.random() is not seedable — you can't reproduce the same sequence. Mulberry32 is deterministic: the same seed always produces the same sequence, which is essential for making buddies reproducible.
Why not a cryptographic PRNG? Buddy generation doesn't need cryptographic security. Mulberry32 is orders of magnitude faster and produces statistically uniform output that's more than sufficient for game-like applications.
[05]Stage 4: The Roll Pipeline — Order Matters
With the PRNG initialized, rollBuddy makes a specific sequence of calls. The order is critical because each rng() call advances the internal state irreversibly:
export function rollBuddy(userId: string): BuddyResult {
const rng = mulberry32(hashString(userId + SALT));
const rarity = rollRarity(rng); // Step 1: 1+ RNG calls
const species = pick(rng, SPECIES); // Step 2: 1 RNG call
const eye = pick(rng, EYES); // Step 3: 1 RNG call
const hat = rarity === 'common' // Step 4: 0 or 1 RNG call
? 'none'
: pick(rng, HATS);
const shiny = rng() < 0.01; // Step 5: 1 RNG call
const stats = rollStats(rng, rarity); // Step 6: 7+ RNG calls
return { rarity, species, eye, hat, shiny, stats };
}
The cascade effect: Because Common buddies skip the hat roll (0 RNG calls consumed), their shiny check uses a different RNG value than non-Common buddies. This means rarity doesn't just affect hat eligibility — it subtly shifts every subsequent roll. A buddy that would have been shiny as Uncommon might not be shiny as Common, even with the same UUID.
Here's the exact RNG call count for each step:
| Step | Function | RNG Calls | Notes |
|---|---|---|---|
| 1 | rollRarity | 1 | Single weighted random roll |
| 2 | pick(SPECIES) | 1 | Uniform selection from 18 species |
| 3 | pick(EYES) | 1 | Uniform selection from 6 eyes |
| 4 | Hat | 0 or 1 | 0 if Common, 1 otherwise |
| 5 | Shiny check | 1 | Simple threshold: rng() < 0.01 |
| 6 | rollStats | 7–12 | 1 peak pick + 1–5 dump picks (with retries) + 5 stat rolls |
Total: 11–17 RNG calls per buddy. The variance comes from rollStats, where the dump stat must differ from the peak stat — if they collide, the RNG is called again.
[06]Deep Dive: Weighted Random Selection
The rarity system uses weighted random selection, a classic algorithm:
function rollRarity(rng: () => number): Rarity {
const total = 60 + 25 + 10 + 4 + 1; // = 100
let roll = rng() * total; // roll ∈ [0, 100)
for (const rarity of RARITIES) {
roll -= RARITY_WEIGHTS[rarity];
if (roll < 0) return rarity;
}
return 'common'; // fallback (unreachable in practice)
}
Visualized as a number line from 0 to 100:
0 60 85 95 99 100
| Common | Uncomm | Rare |Ep|L|
| 60% | 25% | 10% |4%|1%|
The algorithm generates a random number in [0, 100), then walks through the rarities, subtracting each weight. The first rarity that drives the counter below zero wins. This guarantees exact probability distribution regardless of the order of iteration.
Why not use a lookup table? With only 5 rarities, the linear scan is negligible. A binary search or alias table would be over-engineering for this use case.
[07]Deep Dive: The Stats Generation Algorithm
Stats generation is the most complex part of the pipeline, using a peak/dump asymmetric model:
function rollStats(rng, rarity) {
const floor = RARITY_FLOOR[rarity]; // 5/15/25/35/50
const peak = pick(rng, STAT_NAMES); // random best stat
let dump = pick(rng, STAT_NAMES); // random worst stat
while (dump === peak) dump = pick(rng, STAT_NAMES); // must differ
for (const name of STAT_NAMES) {
if (name === peak)
stats[name] = min(100, floor + 50 + random(0..29));
else if (name === dump)
stats[name] = max(1, floor - 10 + random(0..14));
else
stats[name] = floor + random(0..39);
}
}
The three formulas create distinct stat distributions:
| Stat Type | Formula | Common Range | Legendary Range |
|---|---|---|---|
| Peak | min(100, floor + 50 + rand(30)) | 55–84 | 100 (capped) |
| Dump | max(1, floor - 10 + rand(15)) | 1–9 | 40–54 |
| Normal | floor + rand(40) | 5–44 | 50–89 |
Key insight: Legendary buddies have such high floors that even their dump stat (40–54) exceeds most Common buddies' normal stats (5–44). And their peak stat is always capped at 100 because 50 + 50 + rand(30) always exceeds 100.
The dump stat retry loop: The while (dump === peak) loop ensures every buddy has a distinct weakness. With 5 stats, there's a 20% chance of collision per attempt, meaning the expected number of extra RNG calls is 0.25 (geometric distribution).
[08]Putting It All Together: A Worked Example
Let's trace through a real example. Suppose your UUID is abc-123:
// Step 0: Salt
input = 'abc-123' + 'friend-2026-401'
= 'abc-123friend-2026-401'
// Step 1: FNV-1a Hash
h = 2166136261
h = (h ^ 97) * 16777619 // 'a' = 97
h = (h ^ 98) * 16777619 // 'b' = 98
h = (h ^ 99) * 16777619 // 'c' = 99
... (continue for all 25 characters)
seed = h >>> 0 // unsigned 32-bit result
// Step 2: Initialize PRNG
rng = mulberry32(seed)
// Step 3: Roll sequence
rng() → 0.7234... → rarity = 'uncommon' (falls in 60-85 range)
rng() → 0.4521... → species = SPECIES[floor(0.4521 * 18)] = SPECIES[8] = 'turtle'
rng() → 0.8901... → eye = EYES[floor(0.8901 * 6)] = EYES[5] = '°'
rng() → 0.3712... → hat = HATS[floor(0.3712 * 8)] = HATS[2] = 'tophat'
rng() → 0.5623... → shiny = false (0.5623 ≥ 0.01)
rng() → ... → stats = { DEBUGGING: 42, PATIENCE: 67, ... }
Result: An Uncommon Turtle with ° (surprised) eyes, wearing a top hat, not shiny. Every time anyone enters abc-123, they get this exact same buddy.
Note: The numbers above are illustrative. The actual RNG outputs depend on the precise hash value.
[09]Why This Design Is Elegant
The buddy generation system makes several clever engineering choices that are worth highlighting:
| Design Choice | Benefit |
|---|---|
| Client-side only | Zero server load, instant results, works offline. No API calls, no database, no latency. |
| Deterministic from UUID | No need to store buddy data anywhere. The buddy is "computed" from the UUID on demand. |
| Single PRNG stream | One seed generates all attributes. No need for multiple hash functions or separate random sources. |
| Ordered pipeline | The fixed call order means each attribute is determined by a specific position in the RNG sequence, making the system predictable and debuggable. |
| Salt-based versioning | Changing the salt reshuffles all buddies without changing any code logic — perfect for seasonal events or resets. |
| Non-cryptographic hash | FNV-1a is fast enough for real-time use. Cryptographic hashes (SHA-256) would be overkill and slower. |
The entire system fits in about 50 lines of code, yet produces 18 species × 5 rarities × 6 eyes × 8 hats × 2 shiny states × billions of stat combinations = effectively infinite unique buddies.
Want to see the algorithm in action? Head to the Buddy Checker and enter your UUID. The code running in your browser is the exact implementation described in this article.