cd /news/developer-tools/picking-a-winner-fairly-in-a-browser… · home › topics › developer-tools › article
[ARTICLE · art-162805] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

Picking a winner fairly in a browser spin wheel: modulo bias, rejection sampling and a shuffle bag

A developer building a browser spin wheel implemented unbiased winner selection using crypto.getRandomValues() with rejection sampling to eliminate modulo bias, then computed the target rotation to land the wheel on the chosen slice with a random jitter of up to 40% of a slice. To avoid the appearance of broken randomness, the wheel uses a shuffle bag on lists of five or more entries, drawing without replacement so the same entry cannot win twice in a row until the bag refills.

by read5 min views3 publishedNov 2, 2026

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.

── more in #developer-tools 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/picking-a-winner-fai…] indexed:0 read:5min 2026-11-02 · —