[KongchangAI]
· 3 min read· 1,747 words

Building a Fair Spinner Wheel with crypto.getRandomValues: Rejection Sampling and Chi-Square Testing

Building a Fair Spinner Wheel with crypto.getRandomValues: Rejection Sampling and Chi-Square Testing

Build a verifiably fair spinner wheel using crypto randomness, rejection sampling, and chi-square testing.

The article dissects the technical causes of unfair spinner wheels from the source of randomness, noting that Math.random() is not cryptographically secure and that float returns obscure how randomness is distributed—so use crypto.getRandomValues() instead. The core problem is modulo bias: when the random range isn't an integer multiple of n, low-index items get higher probabilities, with a million byte-modulo draws yielding a chi-square p-value near zero. The fix is rejection sampling—discard the uneven tail and retry—and replace the biased sort random shuffle with Fisher-Yates. Weighted selection calls unbiased randomInt over the total weight to guarantee exact probabilities. The article emphasizes picking the winner before animating so fairness is independent of rendering, and finally provides in-browser chi-square test code, noting a ~1% false positive rate at p<0.01 and that the test can only detect bias, not prove unbiasedness.

Spinner wheel giveaways are always suspected of being "rigged," but most unfairness stems from code details rather than intent. This article starts from the source of randomness, analyzes the pitfalls of Math.random(), explains how modulo bias arises, provides implementations of rejection sampling and weighted selection, and shows you how to prove fairness using a chi-square test. All code is pure browser JavaScript with no dependencies.

Spinner wheel fairness implementation diagram

Why Math.random() Is Unsuitable for Fair Draws

Math.random() is fine for generating confetti effects, but it's unreliable when it comes to results people might scrutinize. The specification doesn't mandate a particular algorithm or quality; engines use fast PRNGs (like V8's xorshift128+), which are not cryptographically secure—given enough output, the internal state can be recovered and future values predicted. It returns a float, and developers often write Math.floor(Math.random() * n), which obscures how randomness is distributed among items.

Browsers already have a better source built in: crypto.getRandomValues(). It fills a typed array using the operating system's cryptographically secure generator, available in modern browsers and Node (globalThis.crypto). But a good random source is only the first step—the next step is where most implementations quietly go wrong.

The key difference between a cryptographically secure pseudorandom number generator (CSPRNG) and an ordinary PRNG lies in whether it can be efficiently predicted. An ordinary PRNG (like xorshift128+) has a small state space and a public algorithm; an attacker who observes enough output can reverse-engineer the seed and predict future values. A CSPRNG is typically seeded from the kernel entropy pool and designed so that even knowing the algorithm, it's hard to infer internal state from output. crypto.getRandomValues() requires at the specification level that implementations use a system-level cryptographically secure source, but it's important to be clear: it guarantees unpredictability, not statistically absolute uniformity—uniformity still requires post-processing like rejection sampling. It's also capped at 65536 bytes per call, so large blocks of random data must be filled in multiple passes.

Modulo Bias: The Bug Hidden in x % n

With a random integer and 10 items, the intuitive approach is randomInteger % entries.length. Whenever the random range isn't an integer multiple of n, bias arises. Take random bytes (0-255, 256 values) and 10 items: 256 = 25×10 + 6, so 0-249 divide evenly (25 each), and the remainder 250-255 maps to items 0-5. These six items each get a 26/256≈10.16% chance, while the other four get 25/256≈9.77%. The first six names gain about a 4% relative advantage.

The author measured a million byte-modulo draws: items 0-5 landed around 101,000 times, items 6-9 around 97,500 times, with a chi-square p-value near zero. The bias with 32-bit integers is smaller (the six items get about a 0.00000000004 chance advantage), but still nonzero, and the fix costs almost nothing.

The chi-square goodness-of-fit test determines whether observed frequencies match theoretical frequencies: the statistic χ² = Σ(Oᵢ−Eᵢ)²/Eᵢ, under the null hypothesis (all items equally likely) approximately follows a chi-square distribution with k−1 degrees of freedom. Degrees of freedom equals the number of categories that can vary freely, because once the total is fixed, the count in the last category is determined by the others. The article uses the Wilson-Hilferty transformation to approximate χ² as a normal distribution for estimating p-values—a common dependency-free alternative to lookup tables in the browser; rigorous scenarios should use the regularized incomplete gamma function instead. Note that the chi-square test is sensitive to systematic bias but has sample-size requirements: expected frequency per category should generally be at least 5, otherwise the approximation fails. In a spinner context, spins should far exceed the number of items.

The Fix: Rejection Sampling

The idea is to find the largest multiple of n within the random range, and if the random number falls above that limit, discard and redraw. This way every retained value maps to exactly the same number of outcomes.

const RANGE = 2 ** 32;
function randomInt(n) {
  if (!Number.isInteger(n) || n < 1 || n > RANGE) throw new RangeError("n must be an integer from 1 to 2^32");
  const limit = RANGE - (RANGE % n);
  const buf = new Uint32Array(1);
  let x;
  do {
    crypto.getRandomValues(buf);
    x = buf[0];
  } while (x >= limit);
  return x % n;
}

The rejected tail is less than n, negligible relative to 4.29 billion values—in practice a real spinner will almost never retry more than once. Picking the winner is just one line: const winnerIndex = randomInt(entries.length);.

The same helper also fixes another classic bug: entries.sort(() => Math.random() - 0.5) is not a uniform shuffle—the result depends on the sorting algorithm and is biased. Use Fisher-Yates with randomInt instead:

function shuffle(arr) {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = randomInt(i + 1);
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}

The correctness of the Fisher-Yates shuffle relies on uniformly drawing one of the remaining unprocessed elements at each step and swapping with the current position, for a total of n−1 passes, producing one of n! equally likely permutations. The problem with sort(() => Math.random() - 0.5) is that a comparison function returning random values violates the sorting algorithm's assumption of a consistent comparison relation; different engine implementations (e.g., V8 uses insertion sort for short arrays and TimSort for long ones) produce different bias patterns, and there's no guarantee every permutation is equally likely. With an unbiased randomInt(i + 1), Fisher-Yates is strictly uniform—which is why it's the standard shuffling algorithm.

Weighted Slices: Making Probabilities Visible

Prize wheels often need unequal probabilities—for example, "Try again" should be three times as likely as "Free coffee." Give each item an integer weight, draw a number from the total weight, and iterate through the list:

function pickWeighted(entries) {
  const total = entries.reduce((sum, e) => sum + e.weight, 0);
  let r = randomInt(total);
  for (let i = 0; i < entries.length; i++) {
    r -= entries[i].weight;
    if (r < 0) return i;
  }
}
pickWeighted([{ label: "Free coffee", weight: 1 }, { label: "Try again", weight: 3 }]); // "Try again" gets about 75%

Because randomInt is unbiased, each item's probability is exactly its weight / total. Render the slices at the same proportions so visual size matches true probability.

Pick the Winner First, Then Animate

Counterintuitive but crucial: the spin animation doesn't determine the winner. The moment the user clicks, use randomInt to pick the winner—the animation just calculates how to land on that slice. This way fairness doesn't depend on rendering, and click timing, device performance, or a background tab can't change the result. It's also easy to test—you can call it a million times without drawing anything.

Convert the chosen slice into a final rotation angle (angles measured clockwise from the top, pointer at the top):

const randomFloat = () => randomInt(RANGE) / RANGE;
function targetRotation(slice, currentRotation, extraTurns = 6) {
  const margin = (slice.end - slice.start) * 0.1;
  const landAt = slice.start + margin + randomFloat() * (slice.end - slice.start - 2 * margin);
  const mod = (a, m) => ((a % m) + m) % m;
  const offset = mod(-(landAt + currentRotation), 360);
  return currentRotation + extraTurns * 360 + offset;
}

Use an easeOutCubic curve to animate from the current angle to the target angle—the wheel spins fast then slows naturally to a stop. Visual effects (like peg clicks or bounce-back) don't affect probability as long as the final resting position is the preset target.

easeOutCubic is one of the easing function family, with the form f(t) = 1 − (1 − t)³, where t goes from 0 to 1 as animation progress. Its derivative is 3(1 − t)², giving maximum speed at the start and approaching zero at the end—so the wheel exhibits a "fast then slow, natural stop" feel. Easing functions only affect the angle-vs-time curve, not the final target angle—which is exactly the premise of the "pick the winner first" design: interpolation details, duration, or whether the tab is throttled in the background won't change the already-determined winning index, so fairness is completely decoupled from rendering.

Proving Fairness: Chi-Square Testing in the Browser

"Trust me, it's random" is unconvincing. The chi-square goodness-of-fit test determines whether observed counts are consistent with equal probabilities. For k items and N spins, expected E = N/k, statistic χ² = Σ(observed−E)²/E, degrees of freedom k−1. For a quick browser check, use the Wilson-Hilferty approximation:

function erf(x) {
  const sign = Math.sign(x); x = Math.abs(x);
  const t = 1 / (1 + 0.3275911 * x);
  const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
  return sign * y;
}
function chiSquareTest(counts) {
  const n = counts.reduce((a, b) => a + b, 0);
  const expected = n / counts.length;
  const chi2 = counts.reduce((s, o) => s + (o - expected) ** 2 / expected, 0);
  const df = counts.length - 1;
  const z = (Math.cbrt(chi2 / df) - (1 - 2 / (9 * df))) / Math.sqrt(2 / (9 * df));
  const p = 0.5 * (1 - erf(z / Math.SQRT2));
  return { chi2, df, p };
}

Running 100,000 spins on 5 items gave counts [20044, 19882, 19929, 20156, 19989], χ²=2.27, p=0.69—each item deviates from expected by about 0.8%, completely normal. Compare with the byte-modulo version: χ²≈340, p≈0.

Interpreting the p-value: if the generator is fair, p is uniformly distributed between 0 and 1. A common threshold is p<0.01 for suspicion, meaning a fair wheel will falsely alarm about 1% of the time. If you occasionally get a low p, rerun; if it's persistently low, something's wrong. Note that the chi-square test can detect bias but cannot prove unbiasedness—it's a sanity check, not certification; regulated lotteries require independent audits. Wilson-Hilferty is an approximation; rigorous p-values should use the regularized incomplete gamma function.

Key Takeaways

  • For results people might argue about, use crypto.getRandomValues() instead of Math.random().
  • Never use random % n alone—use rejection sampling to eliminate modulo bias.
  • Shuffle with Fisher-Yates, not sort(() => Math.random() - 0.5).
  • Pick the winner first, then animate the wheel to land on it.
  • Self-test with a chi-square test, and expect a fair wheel to "fail" at p<0.01 about 1% of the time.

This code runs in the browser—you can open developer tools and read it directly. Fairness isn't a slogan; it's verifiable engineering practice.

#crypto.getRandomValues#rejection sampling#chi-square test#Math.random bias#fair spinner wheel#Fisher-Yates shuffle
Share:

Related articles