{"slug": "picking-a-winner-fairly-in-a-browser-spin-wheel-modulo-bias-rejection-sampling-a", "title": "Picking a winner fairly in a browser spin wheel: modulo bias, rejection sampling and a shuffle bag", "summary": "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.", "body_md": "*This article was written with the help of AI and checked against the site's real code. The test code was run before publishing.*\n\nA 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.\n\nThat 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.\n\nThe usual snippet is this:\n\n``` js\nconst winner = Math.floor(Math.random() * entries.length);\n```\n\nFor 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.\n\nThe browser has one built in: `crypto.getRandomValues()`. It gives you random integers, so the tempting next step is `%`:\n\n``` js\nconst buf = new Uint32Array(1);\ncrypto.getRandomValues(buf);\nconst winner = buf[0] % n; // slightly biased\n```\n\nThis 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:\n\n| Entry | 0 | 1 | 2 | 3 | 4 | 5 | 6 | \n|---|---|---|---|---|---|---|---|\n| Byte values that land on it | 37 | 37 | 37 | 37 | 36 | 36 | 36 | \n\n256 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.\n\nThe fix is rejection sampling. Throw away the leftover values at the top of the range and draw again:\n\n``` js\nfunction randInt(n) {\n  const limit = Math.floor(2 ** 32 / n) * n;\n  const buf = new Uint32Array(1);\n  do {\n    crypto.getRandomValues(buf);\n  } while (buf[0] >= limit);\n  return buf[0] % n;\n}\n```\n\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.\n\nNow 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:\n\n``` js\nconst slice = (Math.PI * 2) / n;\nconst target = Math.PI * 1.5 - (winner + 0.5) * slice;\n```\n\nLanding 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:\n\n``` js\nconst jitter = (randInt(1000) / 1000 - 0.5) * 0.8 * slice;\n```\n\nThen 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:\n\n``` js\nconst delta = ((target + jitter - rot) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2);\nconst end = rot + delta + Math.PI * 2 * (5 + randInt(3));\n```\n\nA quartic ease-out gives the slow, tense finish:\n\n``` js\nconst e = 1 - Math.pow(1 - p, 4); // p goes from 0 to 1 over the spin\nrot = start + (end - start) * e;\n```\n\nThere'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:\n\n```\nif ((end - start) * (1 - e) < 0.009) p = 1;\n```\n\nIf the user asked for reduced motion (`prefers-reduced-motion`), the spin is cut to 600 ms and the confetti is skipped.\n\nWith 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?\"\n\nSo 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:\n\n``` js\nfunction shuffleBag(n) {\n  let left = [];\n  return function next() {\n    if (!left.length) left = Array.from({ length: n }, (_, i) => i);\n    return left.splice(randInt(left.length), 1)[0];\n  };\n}\n```\n\nThis is a real trade-off, and it should be stated openly:\n\nChoose based on what the wheel is for. If you need strictly independent draws, skip the bag.\n\nYou can't check fairness by looking at the animation, but you can test the picker:\n\n``` python\nimport test from 'node:test';\nimport assert from 'node:assert/strict';\n\ntest('randInt is roughly even', () => {\n  const n = 7, runs = 700000, counts = Array(n).fill(0);\n  for (let i = 0; i < runs; i++) counts[randInt(n)]++;\n  for (const c of counts) {\n    assert.ok(Math.abs(c - runs / n) < (runs / n) * 0.02);\n  }\n});\n```\n\nThis 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.\n\nThe helpers above are in a small MIT-licensed repo: [github.com/BestSpinnerWheel/fair-pick](https://github.com/BestSpinnerWheel/fair-pick). To see them in a working wheel, ours is at [bestspinnerwheel.com](https://bestspinnerwheel.com/). View source if you like: it's a static page, and your entries stay in your browser.\n\nIf you've solved the \"random doesn't feel random\" problem another way, I'd like to hear how.", "url": "https://wpnews.pro/news/picking-a-winner-fairly-in-a-browser-spin-wheel-modulo-bias-rejection-sampling-a", "canonical_source": "https://dev.to/bestspinnerwheel/picking-a-winner-fairly-in-a-browser-spin-wheel-modulo-bias-rejection-sampling-and-a-shuffle-bag-4aik", "published_at": "2026-11-02 23:14:33+00:00", "updated_at": "2026-11-02 23:22:27.082426+00:00", "lang": "en", "topics": ["developer-tools"], "entities": [], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/picking-a-winner-fairly-in-a-browser-spin-wheel-modulo-bias-rejection-sampling-a", "markdown": "https://wpnews.pro/news/picking-a-winner-fairly-in-a-browser-spin-wheel-modulo-bias-rejection-sampling-a.md", "text": "https://wpnews.pro/news/picking-a-winner-fairly-in-a-browser-spin-wheel-modulo-bias-rejection-sampling-a.txt", "jsonld": "https://wpnews.pro/news/picking-a-winner-fairly-in-a-browser-spin-wheel-modulo-bias-rejection-sampling-a.jsonld"}}