DEV Community

asrocky010
asrocky010

Posted on

A seeded shuffle for 580 items: reproducible sets without a server

The bug report that made me delete Math.random()

A user wrote two sentences: "I got the same question twice in one set, and the answer order changed when I clicked back."

I opened the page, ran the app, got ten questions, and could not reproduce either problem. Closed the tab, reopened it, got a different set. Of course I did. That is the whole problem with randomness in a quiz: every bug report arrives with a seed that no longer exists.

This was the shuffle:

function shuffle(a) {
  return a.sort(function () { return Math.random() - 0.5; });
}
~~~{% endraw %}

Three lines, looks fine, wrong twice. The comparator is inconsistent (it can return a < b and b < a in the same sort), so the engine's result depends on its internal algorithm and array length, not on a probability you can reason about. It is also biased: elements near the middle of the array move less than elements at the ends. And because the comparator runs an unpredictable number of times, you cannot replay it even if you log the output.

## Fisher-Yates is the shuffle you actually want{% raw %}

~~~js
function shuffle(a) {
  for (var i = a.length - 1; i > 0; i--) {
    var j = Math.floor(Math.random() * (i + 1));
    var t = a[i]; a[i] = a[j]; a[j] = t;
  }
  return a;
}
~~~

Every permutation is equally likely, it is O(n), and — this is the part that matters — it makes exactly n-1 calls to the random source. That predictable call count is what lets you swap the source for a seeded one.

## Swapping the source: 12 lines of PRNG

~~~js
function xmur3(str) {
  for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) {
    h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
    h = (h << 13) | (h >>> 19);
  }
  return function () {
    h = Math.imul(h ^ (h >>> 16), 2246822507);
    h = Math.imul(h ^ (h >>> 13), 3266489909);
    return (h ^= h >>> 16) >>> 0;
  };
}

function mulberry32(a) {
  return function () {
    a |= 0; a = (a + 0x6D2B79F5) | 0;
    var t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

var seedFn = xmur3(seedString);
var rand = mulberry32(seedFn());
~~~

Neither function is cryptographic and neither needs to be. They need three properties: same string in, same sequence out; uniform enough that 580 items do not clump; and fast enough that reshuffling ten options costs nothing.

## Where the seed comes from

Three sources, one function:

- **A per-tab seed in sessionStorage.** Generated once, so a reload keeps the set but a new tab does not.
- **A seed in the query string** (`?seed=8f2c1a`). This is the one that pays for itself. A bug report now contains the exact set, and "I got the same question twice" becomes reproducible in one click.
- **A date string for the daily set** (`2026-10-20`). Everyone who opens the warm-up that day gets the same ten questions, which makes the answer explanations shareable.

~~~js
function seedForRun(opts) {
  if (opts.seed) return opts.seed;
  if (opts.daily) return new Date().toISOString().slice(0, 10);
  var k = 'quiz-seed';
  var v = sessionStorage.getItem(k);
  if (!v) { v = Math.random().toString(36).slice(2, 8); sessionStorage.setItem(k, v); }
  return v;
}
~~~

## The three things that broke after I "fixed" it

**1. Calling the generator during render.** I built the RNG inside the render path, so every re-render (a timer tick, a tooltip, a resize handler) advanced the sequence. The question order changed while the user was reading. Fix: build the queue once, store it as data, render from the array.

**2. Reusing one seed for every new set.** "New set" called the same seeded shuffle with the same seed and handed back the identical ten questions. The user thought the button was broken. Fix: the seed is per run, the RNG object is per run, and "new set" mints a new seed and writes it to the URL with replaceState — so the URL always describes what is on screen.

**3. Shuffling the options separately from the bank.** The bank order was seeded, the option order was not, so option B was "the right answer" in 73% of the items in our own data. A quiz-taker who notices that pattern stops reading the options. Fix: one RNG per run, used for both, and a build-time check that flags any answer position above ~30%.

That last number is the only one from this project worth generalising: if answer positions are not shuffled, or are shuffled with a bad comparator, they drift. Ours drifted to 73% on B across 580 questions. Nobody wrote that on purpose; it accumulated.

## What it bought

- Bug reports became replayable: a six-character seed in the URL.
- Screenshot tests became stable: the same URL renders the same question and the same option order.
- A teacher can hand out one link and know the whole room sees the same ten items.
- The daily set is cacheable and shareable, because its seed is a date.

None of this needs a server, a database, or a build step — about 40 lines of vanilla JS and one rule: **the random source is an argument, not a global.**


---

The dataset used for the checks in this post is 17 rows of temperature and cooling windows; a fixture that small is enough to catch a biased comparator, because a bad shuffle shows up as a repeated row long before it shows up in production.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)