DEV Community

Cover image for A Random Button That Doesn't Feel Broken: Unbiased Picks, No Instant Repeats and Shuffle Bags in JavaScript
Abid Ali
Abid Ali

Posted on

A Random Button That Doesn't Feel Broken: Unbiased Picks, No Instant Repeats and Shuffle Bags in JavaScript

Meme Tap, the soundboard I built, has a page called Random. There is one button on it, Roll, and every tap pulls one sound button from the whole library: a title, a category, a play button. The page copy makes one small promise: roll again skips the one you just heard when it can.

That promise exists because truly random picks feel broken. If you roll twice and hear the same buzzer twice, you don't think "fair coin, these things happen." You think the button didn't work. People are very good at noticing repeats and very bad at believing they're chance, and a random button has to be built for people, not for a statistics textbook.

This post is the toolkit I'd reach for when building any "surprise me" button, whether it's sounds, quotes, recipes or quiz questions: a correct integer pick, an unbiased version with crypto, "anything but the last one", a short history window, a shuffle bag, and a seeded generator so all of it can be tested. Plain JavaScript modules, no libraries, and every function here has a passing node --test file behind it.

Step 1: get the integer right

Almost every random-pick bug starts with the one-liner:

const pick = items[Math.round(Math.random() * (items.length - 1))];
Enter fullscreen mode Exit fullscreen mode

It looks reasonable and it's quietly unfair. Math.round gives the first and last items half the chance of everything else, because only half a step of the range rounds to 0 and only half rounds to the top. The correct version floors a value in [0, n):

export function indexFrom(rand, n) {
  return Math.floor(rand() * n);
}

const pick = items[indexFrom(Math.random, items.length)];
Enter fullscreen mode Exit fullscreen mode

I pass the random source in as a function. In production it's Math.random; in tests it's a seeded generator (more on that below). That one parameter is what makes the rest of this post testable.

Step 2: when you want crypto, avoid modulo bias

Math.random() is fine for a soundboard. It's not suitable for anything where someone gains by predicting the next value: giveaways, raffles, shuffling a deck for real money. For those you want crypto.getRandomValues, which is available in every modern browser and in Node as globalThis.crypto.

The trap with crypto is the obvious conversion: take a 32-bit integer and do % n. Unless n divides 2^32 exactly, the low values come up slightly more often, because the last partial block of the range wraps around onto them. The fix is rejection sampling: throw away values from that partial block and draw again.

export function randomIndex(n, cryptoObj = globalThis.crypto) {
  if (!Number.isInteger(n) || n <= 0) throw new RangeError("n must be a positive integer");
  const limit = Math.floor(0x100000000 / n) * n; // largest multiple of n below 2^32
  const buf = new Uint32Array(1);
  for (;;) {
    cryptoObj.getRandomValues(buf);
    if (buf[0] < limit) return buf[0] % n;
  }
}
Enter fullscreen mode Exit fullscreen mode

The loop almost never runs twice for small n, but it makes the result exactly uniform. The cryptoObj parameter is there so a test can hand in a fake and prove the rejection happens: feed it 0xffffffff (which is past the last full multiple of 3) followed by 5, and it should return 5 % 3, having drawn twice.

Step 3: "anything but the last one"

This is the rule the Random page states, and it has a neat implementation that stays uniform. Instead of rolling and re-rolling until you get something different (which works, but has no upper bound on attempts and reads badly), pick from n - 1 slots and skip over the last index:

export function pickNotLast(n, last, rand = Math.random) {
  if (n <= 1) return 0;                       // a pool of one has to repeat
  if (last == null || last < 0 || last >= n) return indexFrom(rand, n);
  const i = indexFrom(rand, n - 1);
  return i >= last ? i + 1 : i;
}
Enter fullscreen mode Exit fullscreen mode

If the last pick was index 2 out of 5, the draw is over {0, 1, 2, 3} and anything from 2 up shifts by one, giving {0, 1, 3, 4}, each with exactly a quarter of the chance. One draw, always.

Note the n <= 1 line. That's the "when it can" in the page copy. If a category or a filtered search only has one clip, the honest answer is to play it again rather than loop forever or show an error.

Step 4: a short memory instead of a one-item memory

On a small pool, "not the last one" isn't enough. With four sounds you can get A, B, A, B, A and it still feels stuck. A history window blocks the last few picks, as long as the pool is big enough to allow it:

export function createRoller(items, { windowSize = 1, rand = Math.random } = {}) {
  const recent = [];
  return function roll() {
    const n = items.length;
    if (n === 0) return undefined;
    const keep = Math.min(windowSize, n - 1);   // never block the whole pool
    while (recent.length > keep) recent.shift();
    const blocked = new Set(recent);
    const open = [];
    for (let i = 0; i < n; i++) if (!blocked.has(i)) open.push(i);
    const pick = open[indexFrom(rand, open.length)];
    recent.push(pick);
    while (recent.length > keep) recent.shift();
    return items[pick];
  };
}
Enter fullscreen mode Exit fullscreen mode

Math.min(windowSize, n - 1) is the important line. Ask for a window of 5 on a pool of 2 and it quietly becomes a window of 1, so the roller alternates instead of running out of options.

Building the open array is O(n) per roll. For a few hundred items that's nothing. For a big library you wouldn't hold every item in the browser anyway, which brings us to where the pick should happen.

Step 5: shuffle bags, for "I want to hear everything"

Sometimes the requirement is stronger: in a quiz round, a playlist or a classroom game, people expect every item to come up once before anything repeats. That's a shuffle bag. Shuffle the indexes with Fisher–Yates, hand them out one by one, and reshuffle when the bag is empty.

export function shuffle(arr, rand = Math.random) {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = indexFrom(rand, i + 1);
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}

export function createBag(items, rand = Math.random) {
  let bag = [];
  let last;
  return function next() {
    if (items.length === 0) return undefined;
    if (bag.length === 0) {
      bag = shuffle(items.map((_, i) => i), rand);
      // we pop from the end: if the first pop would repeat the last item, swap it away
      if (items.length > 1 && bag[bag.length - 1] === last) {
        const j = indexFrom(rand, bag.length - 1);
        [bag[bag.length - 1], bag[j]] = [bag[j], bag[bag.length - 1]];
      }
    }
    last = bag.pop();
    return items[last];
  };
}
Enter fullscreen mode Exit fullscreen mode

Two details matter. First, the shuffle must be indexFrom(rand, i + 1), not indexFrom(rand, n). Swapping with any position on every step is the classic "naive shuffle", and it produces some orders more often than others. Second, there's a seam between rounds: the last item of one bag can be the first of the next, which is exactly the repeat you were trying to avoid. The swap after reshuffling fixes it without disturbing anything else.

Step 6: seed it, then test it

Randomness is where untested code goes to hide, because a wrong result looks like bad luck. The fix is a tiny seeded generator. mulberry32 is a few lines, fast, and good enough for tests and demos (not for security):

export function mulberry32(seed) {
  let a = seed >>> 0;
  return function next() {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
Enter fullscreen mode Exit fullscreen mode

Now the tests are deterministic. Here is the one I care about most, the shuffle bag across hundreds of seeds:

import test from "node:test";
import assert from "node:assert/strict";
import { createBag, mulberry32 } from "./roll.mjs";

test("createBag plays everything once per round with no repeat at the seam", () => {
  const items = ["a", "b", "c", "d", "e"];
  for (let seed = 1; seed < 300; seed++) {
    const next = createBag(items, mulberry32(seed));
    const out = Array.from({ length: 25 }, next);
    for (let r = 0; r < 5; r++) {
      assert.deepEqual(out.slice(r * 5, r * 5 + 5).sort(), items);
    }
    for (let k = 1; k < out.length; k++) {
      assert.notEqual(out[k], out[k - 1], `seed ${seed} at ${k}`);
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Without the seam fix, this fails within the first handful of seeds. That's the point of looping over seeds: it turns "rare" into "certain".

The rest of the file checks that randomIndex stays in range and covers every value, that it redraws on a value past the last full multiple, that pickNotLast never repeats over 40,000 rolls and lands each of five counts within a loose band around the expected 8,000, that the history window never returns either of the last two picks, and that shuffle keeps every element. Seven tests, all passing on Node 20 with node --test.

A note on that distribution test: with a fixed seed it's not a statistical proof, it's a guard against the off-by-one mistakes from Step 1. If you swap Math.floor for Math.round, the counts for the first and last index drop to roughly half and the test fails loudly.

Where the pick should happen

For a large library of clips, don't ship the whole list to the browser just to roll. Let the server pick and send one item back, and have the client send the id it just played so the server can exclude it:

const res = await fetch(`/api/random?exclude=${encodeURIComponent(lastId ?? "")}`);
const clip = await res.json();
Enter fullscreen mode Exit fullscreen mode

On the database side, avoid ORDER BY random() LIMIT 1 on a big table: it sorts every row to throw all but one away. Cheaper options include counting the eligible rows and fetching one at a random offset, or picking a random id in the known range and taking the first row at or after it (fast, but slightly favours ids after gaps). The exclude parameter is the server-side twin of pickNotLast.

The button itself

A few small UI choices make a random button feel solid:

  • Disable while loading, not before. Set aria-busy and ignore taps until the new clip arrives, so a double tap doesn't fire two requests and leave the page showing one while playing the other.
  • Announce the result. Put the new title in an aria-live="polite" region. A screen reader user pressing Roll otherwise hears nothing until they go hunting.
  • Don't autoplay the pick. Show it and let the person press Play. A roll that blasts audio is a surprise in the wrong way, and browsers may block it anyway without a fresh user gesture.
  • Let people keep a good one. A random roll is a discovery tool. Once someone finds a clip they like, a favourite or a copy-link button turns that luck into something they can find again.

If you want to see the behaviour from the user's side, tap Roll a few times on the Meme Tap Random page. The whole thing is a few small, well-tested functions and one honest rule: no instant repeats when the pool allows it.


I'm Abid Ali, a freelance full-stack developer in Karachi working with TypeScript, Next.js, React and Node.js. I built Meme Tap, a free meme soundboard and meme maker that runs in the browser.

Top comments (0)