0%
#algorithm#technical#fnv1a#prng#deep-dive

The Algorithm Behind Claude Code Buddy — FNV-1a & Mulberry32 PRNG Explained

Published 2026-04-0410 min read

[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:

PurposeExplanation
Prevent reverse engineeringWithout knowing the salt, you can't predict which UUID maps to which buddy
Version controlChanging the salt in a future update would reshuffle all buddies — a "season reset"
Namespace isolationThe 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:

ConstantHexRole
21661362610x811c9dc5FNV-1a 32-bit offset basis — the initial hash value
167776190x01000193FNV-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:

StepOperationPurpose
1a = (a + 0x6d2b79f5) | 0Advance state by a large odd constant (Knuth's multiplicative hash increment). The | 0 keeps it as a signed 32-bit integer.
2a ^ (a >>> 15)XOR the state with itself shifted right by 15 bits. This mixes the upper bits into the lower bits.
3Math.imul(..., 1 | a)Multiply by an odd number derived from the state itself. The 1 | a ensures the multiplier is always odd (never zero).
4t ^ (t >>> 7)Another XOR-shift to further diffuse bits.
5Math.imul(..., 61 | t)Second state-dependent multiplication. 61 is prime, and 61 | t ensures the multiplier is always odd.
6t ^ (t >>> 14)Final XOR-shift for output whitening.
7>>> 0 / 4294967296Convert 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:

StepFunctionRNG CallsNotes
1rollRarity1Single weighted random roll
2pick(SPECIES)1Uniform selection from 18 species
3pick(EYES)1Uniform selection from 6 eyes
4Hat0 or 10 if Common, 1 otherwise
5Shiny check1Simple threshold: rng() < 0.01
6rollStats7–121 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 TypeFormulaCommon RangeLegendary Range
Peakmin(100, floor + 50 + rand(30))55–84100 (capped)
Dumpmax(1, floor - 10 + rand(15))1–940–54
Normalfloor + rand(40)5–4450–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 ChoiceBenefit
Client-side onlyZero server load, instant results, works offline. No API calls, no database, no latency.
Deterministic from UUIDNo need to store buddy data anywhere. The buddy is "computed" from the UUID on demand.
Single PRNG streamOne seed generates all attributes. No need for multiple hash functions or separate random sources.
Ordered pipelineThe fixed call order means each attribute is determined by a specific position in the RNG sequence, making the system predictable and debuggable.
Salt-based versioningChanging the salt reshuffles all buddies without changing any code logic — perfect for seasonal events or resets.
Non-cryptographic hashFNV-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.

// COMMENTS

github_discussions.sh

Sign in with GitHub to leave a comment.

Ready to find your buddy?

CHECK YOUR BUDDY

Built by the community. Not affiliated with Anthropic.

All computation is local. No data is collected or transmitted.

> EOF