This article was written with the help of AI and checked against the site's real code. The test code was run before publishing.
A spin wheel looks like a physics toy, but the animation has nothing to do with the result. In the wheel I work on, the winner is chosen first. Then the wheel is told where to stop.
That split makes the code simple, and it moves the fairness question to one small function. Here is how that function works, including a change we made because true randomness can look broken.
The usual snippet is this:
const winner = Math.floor(Math.random() * entries.length);
For a party game this is fine. But Math.random() isn't meant to be a secure source of randomness, and if your wheel picks giveaway winners, you want a better answer when someone asks how it works.
The browser has one built in: crypto.getRandomValues(). It gives you random integers, so the tempting next step is %:
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const winner = buf[0] % n; // slightly biased
This has modulo bias. To see it, shrink the numbers. A random byte has 256 possible values. Split them across 7 entries with % 7, and you get this:
| Entry | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| Byte values that land on it | 37 | 37 | 37 | 37 | 36 | 36 | 36 |
256 doesn't divide evenly by 7, so the first four entries get a small edge. With 32-bit numbers the edge is tiny, but removing it takes only two lines.
The fix is rejection sampling. Throw away the leftover values at the top of the range and draw again:
function randInt(n) {
const limit = Math.floor(2 ** 32 / n) * n;
const buf = new Uint32Array(1);
do {
crypto.getRandomValues(buf);
} while (buf[0] >= limit);
return buf[0] % n;
}
limit is the largest multiple of n that fits in the range. Every value below it maps evenly onto the entries. The loop almost never runs twice.
Now the wheel needs to stop on the chosen slice. On a canvas, angle 0 points right and angles grow clockwise, so "the top" is 1.5π. If slices are drawn starting at the wheel's rotation, the middle of slice i sits at rotation + (i + 0.5) * slice. Solve for the rotation that puts it at the top:
const slice = (Math.PI * 2) / n;
const target = Math.PI * 1.5 - (winner + 0.5) * slice;
Landing dead center every time looks fake, so add a random nudge that stays inside the slice. Our wheel uses up to 40% of a slice either way:
const jitter = (randInt(1000) / 1000 - 0.5) * 0.8 * slice;
Then add a few whole turns so it looks like a real spin, and use the shortest forward distance to the target so it never runs backward:
const delta = ((target + jitter - rot) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2);
const end = rot + delta + Math.PI * 2 * (5 + randInt(3));
A quartic ease-out gives the slow, tense finish:
const e = 1 - Math.pow(1 - p, 4); // p goes from 0 to 1 over the spin
rot = start + (end - start) * e;
There's a catch. The last part of a quartic ease is so slow that the wheel looks finished a moment before the animation is. If your confetti waits for p === 1, the celebration feels late. So we end the spin once the remaining turn is too small to see, about half a degree:
if ((end - start) * (1 - e) < 0.009) p = 1;
If the user asked for reduced motion (prefers-reduced-motion), the spin is cut to 600 ms and the confetti is skipped.
With independent draws and 10 entries, the same entry wins twice in a row 10% of the time. That's correct math, but on a party wheel it looks broken: "It picked Sam again?"
So on lists of 5 or more, our wheel uses a shuffle bag. Each entry goes in the bag, picks come out without replacement, and the bag refills when it's empty:
function shuffleBag(n) {
let left = [];
return function next() {
if (!left.length) left = Array.from({ length: n }, (_, i) => i);
return left.splice(randInt(left.length), 1)[0];
};
}
This is a real trade-off, and it should be stated openly:
Choose based on what the wheel is for. If you need strictly independent draws, skip the bag.
You can't check fairness by looking at the animation, but you can test the picker:
import test from 'node:test';
import assert from 'node:assert/strict';
test('randInt is roughly even', () => {
const n = 7, runs = 700000, counts = Array(n).fill(0);
for (let i = 0; i < runs; i++) counts[randInt(n)]++;
for (const c of counts) {
assert.ok(Math.abs(c - runs / n) < (runs / n) * 0.02);
}
});
This can't detect tiny bias, and that isn't its job. It catches the real bugs: off-by-one errors, a wrong limit, or a slice that never wins.
The helpers above are in a small MIT-licensed repo: github.com/BestSpinnerWheel/fair-pick. To see them in a working wheel, ours is at bestspinnerwheel.com. View source if you like: it's a static page, and your entries stay in your browser.
If you've solved the "random doesn't feel random" problem another way, I'd like to hear how.