Skip to main content
← Writing

Splitmix32: Thirteen Lines of Beautiful Randomness

· 6 min read
On this page

I was building interactive widgets for this blog that needed reproducible randomness. A simulation showing greedy vs. random algorithms, where both strategies operate on the same sequence of inputs. In Python, you write random.seed(42) and you get the same sequence every time. Deterministic. Replayable. Checkpointable. JavaScript gives you Math.random(), which provides none of that: no seed parameter, no way to replay a sequence, no way to share one across environments.

I went looking for a drop-in replacement and found splitmix32. It stopped me in my tracks. Not because it was fast (it is), or because it was small (it is), but because every single line of it is doing something beautiful. Thirteen lines of TypeScript, and each one earns its place.

The full implementation

splitmix32.ts
export function splitmix32(seed: number):
  () => number /* [0, 1) */ {
    let state = seed | 0;
    return () => {
      state |= 0;
      state = state + 0x9e3779b9 | 0;
      let t = state ^ state >>> 16;
      t = Math.imul(t, 0x21f0aaad);
      t = t ^ t >>> 15;
      t = Math.imul(t, 0x735a2d97);
      return ((t = t ^ t >>> 15) >>> 0) / 4294967296;
    };
  }

That is the entire thing. Feed in a seed, get back a closure that produces deterministic floats in [0,1)[0, 1). Each call advances the internal state and returns the next value. Same seed, same sequence, every time. The function is a direct port of the 32-bit variant from the SplitMix family introduced by Steele, Lea, and Flood at OOPSLA 2014, with mixing constants from Chris Wellons’ hash-prospector project.

The function has two moving parts: a Weyl sequence that drives the state forward, and a mixing function that destroys patterns. The best way to build intuition is to watch it execute.

Watch it run

Enter a seed below and step through each line. The variable inspector on the right tracks state and t in both decimal and hex. For XOR-shift steps, a bit-level breakdown shows exactly which bits flipped. Use arrow keys to step forward and back.

Splitmix32 Debugger

Step through one call of the splitmix32 inner function. Each click executes the highlighted line. After the last step, click Next call to generate another number from the same sequence.

Call #1
Ready
Code
1state |= 0;
2state = state + 0x9e3779b9 | 0;
3let t = state ^ state >>> 16;
4t = Math.imul(t, 0x21f0aaad);
5t = t ^ t >>> 15;
6t = Math.imul(t, 0x735a2d97);
7t = t ^ t >>> 15;
8return (t >>> 0) / 4294967296;
Variables
state0x0000002a
42
t
output

If you stepped through the code, you noticed two unusual JavaScript operators: >>> and Math.imul. Both are essential to how splitmix32 works at the bit level.

Two operators you should know

The unsigned right shift: >>>

JavaScript has two right-shift operators. The familiar >> is an arithmetic shift: it preserves the sign bit, filling vacated positions with copies of it. A negative number stays negative after >>. The triple-arrow >>> is a logical shift: it always fills with zeros, regardless of the sign bit. The result is always non-negative.

Splitmix32 uses >>> everywhere because it treats all 32 bits as data. There is no sign bit; there are just 32 bits of state being shuffled around. The >>> 0 at the end of the return line is particularly important: it reinterprets the final value as an unsigned integer before dividing by 2322^{32}, ensuring the output lands in [0,1)[0, 1) rather than [0.5,0.5)[-0.5, 0.5).

Unsigned Right Shift (>>>)

JavaScript has two right-shift operators. >> preserves the sign bit (arithmetic shift). >>> fills with zeros (logical shift). The default value is negative; notice how the results diverge. Switch to a positive value to see them agree.

signed: -1234567 / unsigned: 4293732729
16
input
11111111111011010010100101111001
0xffed2979 = -1234567 (signed)
>> 16 (arithmetic / signed)
>> 16
11111111111111111111111111101101
0xffffffed = -19 (sign preserved, fills with 1s)
>>> 16 (logical / unsigned)
>>> 16
00000000000000001111111111101101
0x0000ffed = 65517 (always fills with 0s, always non-negative)
The results differ because the input is negative. >> copies the sign bit (1) into vacated positions, keeping the result negative. >>> fills with 0, producing a large positive number. Splitmix32 uses >>> because it treats all 32 bits as data, not sign + magnitude.

Why Math.imul instead of *

JavaScript numbers are IEEE 754 doubles: 64 bits of floating-point with 53 bits of integer precision. For small numbers, * works fine. For two large 32-bit integers, the product can exceed 2532^{53}, and JavaScript silently rounds the result. The low-order bits vanish.

Math.imul computes the true low 32 bits of the product, as if both operands were 32-bit integers multiplied with infinite precision and then truncated. No float promotion, no silent rounding. The mixer constants in splitmix32 are large enough that every single multiply would lose bits without it.

Math.imul vs Plain Multiplication

JavaScript's * operator uses floating-point math with 53 bits of integer precision. Multiplying two large 32-bit values can produce a result beyond 253, silently corrupting the low bits. Math.imul computes the true 32-bit product without float promotion.

First multiply in splitmix32(42). The product exceeds 253, so plain JS loses precision.

0x9e37e7c4 = 2,654,463,940
0x21f0aaad = 569,420,461
a * bPRECISION LOST
Full result: 1,511,506,080,422,676,200 (exceeds 253)
Truncated to 32 bits via | 0: 0x976fc700 = -1754282240
Math.imul(a, b)EXACT
32-bit product: 0x976fc774 = -1754282124
The full product is 1,511,506,080,422,676,200, which exceeds 253 (9,007,199,254,740,991). JavaScript's float multiplication already corrupts the low-order bits before | 0 truncates, producing 0x976fc700 instead of the correct 0x976fc774. A difference of 116 in the low bits.

The Weyl sequence: 0x9e3779b9

The state advances by a fixed increment on each call:

weyl-step.ts
a = a + 0x9e3779b9 | 0;

0x9e3779b9 is 232/φ\lfloor 2^{32} / \varphi \rfloor, where φ1.618\varphi \approx 1.618 is the golden ratio. This forms a Weyl sequence: a progression s,  s+γ,  s+2γ,  s, \; s+\gamma, \; s+2\gamma, \; \ldots modulo 2322^{32}. The golden ratio has the slowest-converging continued fraction of any irrational number, which means stepping by this constant spreads values as uniformly as possible across the full 32-bit range. The period is exactly 2322^{32}; every possible state is visited once before the sequence repeats.

A Weyl sequence alone is deterministic and full-period, but its output has obvious structure: consecutive values differ by a constant. The mixer exists to destroy that structure.

The mixing function

Three rounds of xorshift-multiply transform the predictable Weyl output into something that passes statistical randomness tests:

mixer.ts
// Round 1: break up high-bit patterns
let t = a ^ a >>> 16;
t = Math.imul(t, 0x21f0aaad);

// Round 2: scramble remaining correlations
t = t ^ t >>> 15;
t = Math.imul(t, 0x735a2d97);

// Round 3: final diffusion
t = t ^ t >>> 15;

Each x ^ x >>> n copies high bits into lower positions, creating dependencies between bit groups. The subsequent multiply amplifies those dependencies across the full word. Flip a single input bit, and roughly half the output bits flip with it. This property is called avalanche, and it is the core reason the output looks random.

The constants 0x21f0aaad and 0x735a2d97 were discovered computationally. Wellons’ hash-prospector found them through hill climbing and genetic algorithms, optimizing for the lowest possible avalanche bias. These constants achieve a bias of 0.1076\approx 0.1076, slightly beating the original MurmurHash3 fmix32 finalizer (0x85ebca6b / 0xc2b2ae35). The shift widths 16, 15, and 15 were co-optimized alongside the multipliers.

Why it is a bijection

The mixer is a composition of bijections on 32-bit integers. XOR-right-shift by nn bits is invertible because the top nn bits remain unchanged and can reconstruct the rest, one group at a time. Multiplication by any odd number mod232\bmod 2^{32} is also invertible, since odd numbers are coprime to 2322^{32} (they have a multiplicative inverse in the ring Z/232Z\mathbb{Z}/2^{32}\mathbb{Z}). Both 0x21f0aaad and 0x735a2d97 are odd. The consequence: every input maps to a unique output. No collisions, no wasted states.

When to use it, when not to

splitmix32Math.random()crypto.getRandomValues()
SeedableYesNoNo
ReproducibleYes, same seed = same sequenceNoNo
Period2322^{32} (~4.3 billion)Implementation-dependentN/A
State size32 bitsImplementation-dependent128+ bits
Speed~1 ns/call~1 ns/call~50-200 ns/call
CryptographicNoNoYes
DependenciesZeroBuilt-inBuilt-in

Use splitmix32 when you need a deterministic sequence and security is not a concern: simulations that must be reproducible across runs, procedural generation where the same seed should always produce the same world, property-based tests that need a replayable source of randomness, or seeding other, larger PRNGs. The 64-bit SplitMix is the default seeder for Java’s SplittableRandom and for the xoshiro/xoroshiro generators.

Do not use it for anything security-sensitive. With only 32 bits of state, an attacker who observes a single output can brute-force the seed in under a second on modern hardware. Use crypto.getRandomValues() instead. If your application needs trillions of draws, the 2322^{32} period is also too short; consider a 64-bit generator like xoshiro256** instead.