DEV Community

Cover image for I Built a Client-Side Decision Wheel: The Randomness Bit
bowen Tse
bowen Tse

Posted on Originally published at toolkitloop.blogspot.com

I Built a Client-Side Decision Wheel: The Randomness Bit

Group decisions are where friendships go to stall. "Where do we eat?" "Who goes first?" "Who does the dishes?" — everyone has a preference, nobody wants to be the decider. So I built a small spinning wheel that takes the call: type a question, spin, get YES or NO. Three seconds, zero arguments.

The spin animation is the fun part. The interesting part — the part that actually matters if anyone's running a giveaway — is how the wheel picks.

Disclosure: I built this tool at toolkitloop.com/yes-no-wheel. It's one of 30 free client-side tools I maintain. The code patterns below are general; steal them.

The naive version everyone writes

The first implementation of any wheel looks like this:

const segments = ["YES", "NO"];
function spin() {
  return segments[Math.floor(Math.random() * segments.length)];
}
Enter fullscreen mode Exit fullscreen mode

Math.random() is fine for picking lunch. But it has two properties that should make you pause before using it for anything people care about:

  1. It's not cryptographically secure. The sequence is deterministic given the internal state. Nobody's going to reverse-engineer your lunch wheel, but "provably fair" giveaway draws deserve better.
  2. Modulo bias. Math.floor(Math.random() * n) is almost uniform, but with certain ranges the distribution isn't perfectly even. For a 2-segment wheel the effect is negligible; for a 47-entrant giveaway wheel, tiny biases are exactly the kind of thing a sore loser will (correctly) point at.

The fair version: crypto.getRandomValues + rejection sampling

The Web Crypto API gives us real entropy from the OS. The trick is converting random bytes into a uniform index without modulo bias — that's what rejection sampling does:

// Unbiased random index in [0, n) using Web Crypto
function fairIndex(n) {
  const max = 0xffffffff;
  // Largest multiple of n that fits in 32 bits — reject anything above it
  const limit = max - (max % n);
  const buf = new Uint32Array(1);
  let x;
  do {
    crypto.getRandomValues(buf);
    x = buf[0];
  } while (x >= limit);
  return x % n;
}

function spinWheel(segments) {
  return segments[fairIndex(segments.length)];
}
Enter fullscreen mode Exit fullscreen mode

The loop rejects the leftover range that would make some indices slightly more likely, then maps the remainder uniformly. The expected number of iterations is barely above 1 — the do...while almost never repeats. This is a ~15-line function, runs entirely client-side, and gives you a draw you can defend.

Separating selection from animation

One design decision that made the code much cleaner: the animation is theater; the selection is math. The wheel's final resting angle is computed from the already-chosen index, and the spin animation just plays out to that angle:

// Selection happens first; animation follows
const winnerIndex = fairIndex(segments.length);
const sliceAngle = 360 / segments.length;
// Land the pointer in the middle of the winning slice,
// after a few full rotations for drama
const finalAngle = 360 * 5 + (winnerIndex * sliceAngle) + sliceAngle / 2;
wheel.style.transform = `rotate(${finalAngle}deg)`;
Enter fullscreen mode Exit fullscreen mode

This separation has a nice side effect: you can unit-test fairness (run 100k spins, check the distribution) without touching any animation code. If you're building any kind of picker, test the distribution — chi-square is overkill, but eyeballing a histogram of 10k draws catches real bugs.

Everything stays on the device

The whole thing — entropy, selection, rendering — happens in the browser. No server round-trip, no list of entrant names uploaded anywhere. For a giveaway tool this isn't just a nice privacy story; it removes an entire class of "what did you do with my email list?" questions.

Try it / take it apart

The tool is free and needs no account: toolkitloop.com/yes-no-wheel — yes/no mode for quick calls, custom segments for name wheels and giveaways.

Question for the comments: have you ever caught Math.random()-style bias causing a real problem — a lopsided A/B test, a "random" shuffle that wasn't, a giveaway dispute? I'd love to hear the war stories. 👇

Top comments (0)