Every time someone runs a giveaway on a spin wheel, somebody in the comments says it was rigged.
Usually it wasn't. But a surprising number of wheel implementations are slightly unfair, not on purpose, but because of two small mistakes that are easy to make in JavaScript. I build WheelySpin, a set of free spin-the-wheel tools, and this post walks through how we pick a winner so that every slice has the same odds, and how you can check that yourself.
Here's what it covers:
Why Math.random() isn't the right tool for picks where fairness matters
Modulo bias, the bug hiding in x % n
Rejection sampling, the fix
Weighted slices
Deciding the winner before the animation, and making the wheel land on it
Testing it with a chi-square test
All the code is plain browser JavaScript with no dependencies.
- Why not Math.random()? Math.random() is fine for confetti. It isn't designed for anything where someone might care about the result:
The spec doesn't require any particular algorithm or quality. Engines use fast PRNGs (V8 uses xorshift128+) that are not cryptographically secure. With enough outputs, the internal state can be recovered and future values predicted.
It returns a float, so you end up writing Math.floor(Math.random() * n), which hides how the randomness is actually being split between entries.
The browser already ships a better source: crypto.getRandomValues(). It fills a typed array with random integers from the operating system's cryptographically secure generator, and it's available in every modern browser and in Node (as globalThis.crypto).
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
console.log(buf[0]); // a random integer from 0 to 4,294,967,295
A good random source is only half the job, though. The next step is where most implementations quietly go wrong.
- Modulo bias: the bug in x % n You have a random integer and 10 entries. The obvious move is:
const winner = randomInteger % entries.length;
That's biased whenever the size of the random range isn't a multiple of n.
Take a random byte (0 to 255, so 256 possible values) and 10 entries. 256 doesn't divide evenly by 10:
256 = 25 × 10 + 6
So values 0 to 249 split evenly (25 each), but the leftover values 250 to 255 map to entries 0 to 5. Those six entries get 26 chances out of 256, and the other four get 25:
Entries Probability
0 to 5 26/256 = 10.16%
6 to 9 25/256 = 9.77%
That's a 4% relative advantage for the first six names on the wheel. You don't have to take my word for it. I ran a million byte-modulo picks across 10 entries:
[101966, 101648, 100987, 101079, 101897, 101299, 97790, 97552, 97575, 98207]
Entries 0 to 5 all land around 101,000 and entries 6 to 9 around 97,500. A chi-square test (section 6) gives a p-value of effectively zero, which means it's clearly not fair.
With a 32-bit integer the bias is far smaller (six entries get one extra chance out of about 430 million), but it's still not zero, and the fix costs almost nothing.
- The fix: rejection sampling The idea: find the largest multiple of n that fits in your random range, and if the random number lands above it, throw it away and draw again. Every value you keep then maps to exactly the same number of outcomes.
const RANGE = 2 ** 32; // Uint32 gives values from 0 to 2^32 - 1
/**
- Uniform random integer in [0, n), with no modulo bias. */ function randomInt(n) { if (!Number.isInteger(n) || n < 1 || n > RANGE) { throw new RangeError("n must be an integer from 1 to 2^32"); }
// Largest multiple of n that fits in the range.
// Values at or above it would give the low entries an extra chance.
const limit = RANGE - (RANGE % n);
const buf = new Uint32Array(1);
let x;
do {
crypto.getRandomValues(buf);
x = buf[0];
} while (x >= limit); // reject the uneven tail and draw again
return x % n;
}
Doesn't the loop make it slow? No. The rejected tail is always smaller than n out of 4.29 billion values, so for any realistic wheel the chance of even one retry is tiny. You'll almost never loop more than once.
Picking an entry is now one line:
const winnerIndex = randomInt(entries.length);
Bonus: shuffling without bias
The same helper fixes the other classic bug. This popular one-liner does not produce a uniform shuffle:
// Don't do this: the result depends on the sort algorithm and is biased
entries.sort(() => Math.random() - 0.5);
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;
}
- Weighted slices Sometimes you want unequal odds, for example a prize wheel where "Try again" should come up three times as often as "Free coffee". Give each entry an integer weight, draw one number across the total, and walk 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" about 75% of the time
Because randomInt is unbiased, each entry's odds are exactly weight / total. Draw the slices at the same proportions so the wheel shows the real odds. If a slice looks bigger, it should be more likely, and vice versa.
- Decide first, animate second This is the part people find surprising: the spin animation doesn't choose the winner. The winner is picked with randomInt the moment you click, and the animation is then calculated to land on it.
Why do it this way?
Fairness doesn't depend on rendering. If the winner came from a simulated physics spin (random start speed, friction, frame timing), the odds would depend on floating-point details, frame rate and how slices are drawn. That's very hard to prove uniform.
Nobody can game it. Click timing, a slow device or a background tab can't change the result.
It's easy to test. You can call the picking function a million times without drawing anything.
Here's how to turn a chosen slice into a final rotation. Angles are in degrees, measured clockwise from the top, with the pointer at the top:
// A random float in [0, 1). This is only used to choose a spot inside
// the winning slice, so it's cosmetic and doesn't affect the odds.
const randomFloat = () => randomInt(RANGE) / RANGE;
function targetRotation(slice, currentRotation, extraTurns = 6) {
// Land somewhere inside the slice, away from the edges
// so the pointer never sits right on a dividing line.
const margin = (slice.end - slice.start) * 0.1;
const landAt = slice.start + margin + randomFloat() * (slice.end - slice.start - 2 * margin);
// Rotating the wheel clockwise by R moves the point at angle landAt
// to (landAt + R) mod 360. We want that to be 0, the pointer.
const mod = (a, m) => ((a % m) + m) % m;
const offset = mod(-(landAt + currentRotation), 360);
return currentRotation + extraTurns * 360 + offset;
}
Then animate from the current rotation to the target with an ease-out curve, so the wheel starts fast and slows down naturally:
function spinTo(wheelEl, from, to, durationMs = 6000) {
return new Promise((resolve) => {
const start = performance.now();
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
function frame(now) {
const t = Math.min((now - start) / durationMs, 1);
const angle = from + (to - from) * easeOutCubic(t);
wheelEl.style.transform = `rotate(${angle}deg)`;
if (t < 1) requestAnimationFrame(frame);
else resolve(to);
}
requestAnimationFrame(frame);
});
}
Putting it together:
let rotation = 0;
async function spin(slices) {
const winner = randomInt(slices.length); // the decision happens here
const target = targetRotation(slices[winner], rotation);
rotation = await spinTo(wheelEl, rotation, target); // this part is just for show
showResult(slices[winner].label);
}
You can add as much visual flair as you like (ticks on each peg, a little bounce at the end, random spin lengths). As long as everything ends on the predetermined target, none of it touches the odds.
- Proving it: a chi-square test in the browser "Trust me, it's random" isn't very convincing. A chi-square goodness-of-fit test checks whether observed counts are consistent with equal odds.
For k entries and N spins, each entry should get about E = N / k wins. The statistic is:
χ² = Σ (observed − E)² / E
with k − 1 degrees of freedom. Turning that into a p-value needs the chi-square distribution. For a quick browser check, the Wilson-Hilferty approximation is accurate enough:
// Abramowitz & Stegun 7.1.26 approximation of erf
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;
// Wilson-Hilferty: (chi2/df)^(1/3) is roughly normal
const z = (Math.cbrt(chi2 / df) - (1 - 2 / (9 * df))) / Math.sqrt(2 / (9 * df));
const p = 0.5 * (1 - erf(z / Math.SQRT2)); // upper-tail p-value
return { chi2, df, p };
}
Run 100,000 spins over 5 entries:
const counts = new Array(5).fill(0);
for (let i = 0; i < 100_000; i++) counts[randomInt(5)]++;
console.log(counts, chiSquareTest(counts));
One run I got:
[20044, 19882, 19929, 20156, 19989]
{ chi2: 2.27, df: 4, p: 0.69 }
Every entry is within about 0.8% of the expected 20,000, and p = 0.69 is completely unremarkable. Compare that with the byte-modulo version in section 2, which scored χ² ≈ 340 and p ≈ 0.
How to read p: if the generator is fair, p is spread evenly between 0 and 1. A common threshold is p < 0.01 means "suspicious". That also means a perfectly fair wheel will trip the alarm about 1 run in 100. If you see one low p-value, run the test again. If it's consistently low, something's wrong.
Two caveats:
A chi-square test can show bias. It can't prove there's none. It's a sanity check, not a certification. Regulated draws and gambling need independently audited systems.
Wilson-Hilferty is an approximation. For a rigorous p-value, use a proper regularized incomplete gamma function (for example from a stats library).
If you'd rather not write it yourself, WheelySpin has a Randomness Audit page that runs this test on our wheel in your browser, with up to 100,000 simulated spins.
TL;DR
Use crypto.getRandomValues(), not Math.random(), for anything people might argue about.
Never do random % n on its own. Use rejection sampling to remove modulo bias.
Shuffle with Fisher-Yates, not sort(() => Math.random() - 0.5).
Pick the winner first, then animate the wheel to land on it.
Test with a chi-square test, and expect a fair wheel to "fail" about 1% of the time at p < 0.01.
If you want to see all of this running, WheelySpin is free with no sign-up, and the picking code runs in your browser, so you can read it in dev tools. I'd love to hear how you handle randomness in your own projects, or if you spot something I got wrong.
Top comments (1)
The decide-first, animate-second separation is the part I would copy first, because it makes the fairness claim testable without touching the rendering at all. One question on the chi-square section: since a fair generator should still produce p < 0.01 about 1% of the time, do you run the test once with a fixed large N, or repeat it several times and look at the distribution of p-values before concluding there is bias? A single low p-value seems easy for readers to misread as proof of rigging, when it is really just the expected false-positive rate.