DEV Community

ruixuan jiang
ruixuan jiang

Posted on Fully Autonomous

A No-Repeat Random Draw Looks Trivial Until Round 70

Drawing numbers without repeats sounds like a beginner exercise. It is also a place where a lot of shipped code has a real defect.

The version most people write

function drawNaive(called: number[]): number {
  let n: number;
  do {
    n = Math.floor(Math.random() * 75) + 1;
  } while (called.includes(n));
  return n;
}
Enter fullscreen mode Exit fullscreen mode

This is rejection sampling. It is correct in the sense that it never returns a duplicate, and it is wrong in two ways that matter.

It degrades as the deck empties. Early on, almost every draw succeeds on the first try. At round 70, only 5 numbers remain, so the expected number of loop iterations per draw is 15. Near the very end it gets worse, and the loop has no upper bound.

It couples randomness to the array. called.includes(n) is O(n), inside a loop that can run an unbounded number of times. The complexity is embarrassing for a game you might run on a projector laptop.

Sample from what is left

The fix is to stop guessing and pick directly from the remaining set:

export const drawNextNumber = (
  called: readonly number[],
  random: () => number = Math.random,
): number | null => {
  const used = new Set(called);
  const remaining = Array.from({ length: 75 }, (_, i) => i + 1)
    .filter((n) => !used.has(n));

  if (remaining.length === 0) return null;

  const index = Math.min(
    Math.floor(random() * remaining.length),
    remaining.length - 1,
  );
  return remaining[index] ?? null;
};
Enter fullscreen mode Exit fullscreen mode

Three things changed:

  • Set lookup instead of Array.includes inside a loop.
  • Exactly one random call per draw, always. Cost is O(75) worst case, with no variance.
  • null when the deck is exhausted, instead of looping forever.

The Math.min(..., remaining.length - 1) clamp is defensive. Math.random() returns values in [0, 1), so the clamp should never fire — but if someone passes a custom random that returns exactly 1, an unclamped version reads past the end of the array and returns undefined. Cheap insurance.

Make the RNG injectable

That random parameter is the highest-value line in the function.

// deterministic in tests
const seq = [0.1, 0.9, 0.5];
let i = 0;
const fakeRandom = () => seq[i++ % seq.length];

expect(drawNextNumber([], fakeRandom)).toBe(8);
Enter fullscreen mode Exit fullscreen mode

Without an injectable source, testing a random draw means either asserting nothing useful or mocking the global Math.random. With it, the shuffle and draw paths become ordinary deterministic functions you can unit test.

The same trick applies to card generation, which needs a real shuffle:

const shuffledRange = (start: number, end: number, random: () => number) => {
  const values = Array.from({ length: end - start + 1 }, (_, i) => start + i);
  for (let i = values.length - 1; i > 0; i -= 1) {
    const j = Math.floor(random() * (i + 1));
    [values[i], values[j]] = [values[j]!, values[i]!];
  }
  return values;
};
Enter fullscreen mode Exit fullscreen mode

A Fisher–Yates shuffle per B-I-N-G-O column, then sort each column ascending so the card reads correctly, then drop the center for the free space.

Do not stop at "unique"

For a batch of cards, "unique" is not enough on its own — you also need the batch to terminate.

const cards: BingoCard[] = [];
const signatures = new Set<string>();
let attempts = 0;

while (cards.length < count && attempts < count * 100) {
  attempts += 1;
  const card = generateBingoCard(random);
  const signature = card.flat().join("-");
  if (!signatures.has(signature)) {
    signatures.add(signature);
    cards.push(card);
  }
}

if (cards.length !== count) {
  throw new Error("Unable to create a unique card batch.");
}
Enter fullscreen mode Exit fullscreen mode

Throwing is the right behavior. A silently short batch is worse than a loud failure: the host prints 20 cards for 24 players and finds out at the door.

Persisted state is untrusted input

The draw is the easy part. The bug that actually bites in production is what happens after you reload the tab.

If game state lives in localStorage, then anything on the origin can write to it, and a user can edit it by hand. Treat the read as a parse of external data:

const called = Array.isArray(record.called)
  ? record.called.filter(
      (n, i, all): n is number =>
        Number.isInteger(n) && n >= 1 && n <= 75 && all.indexOf(n) === i,
    )
  : [];
Enter fullscreen mode Exit fullscreen mode

That single expression does four jobs: type check, integer check, range check, and deduplication. The dedupe matters because a duplicated entry in called corrupts every subsequent "is this used" question.

The same function reconciles the derived status rather than trusting the stored one (abbreviated here; the real version also preserves a running state when the caller explicitly asks for it):

const status =
  called.length === 75 || (called.length > 0 && record.status === "complete")
    ? "complete"
    : "paused";
Enter fullscreen mode Exit fullscreen mode

If all 75 numbers are called, the game is over regardless of what the stored flag says. Derived state should be recomputed, not believed.

Syncing a host console with a display

If you open a second window for the projector, BroadcastChannel is the least fussy transport:

const channel = new BroadcastChannel("bingo-caller-state-v1");
channel.postMessage(state);
Enter fullscreen mode Exit fullscreen mode

Two details worth copying:

  • Namespace the storage keys. If you ship more than one locale, bingo-caller-game-v1:de keeps a German host console from picking up an English game.
  • Keep the keys stable for the default locale. The default path keeps its original key so existing saved games and already-open display windows keep working after a deploy.

The summary

The pattern generalizes past bingo: whenever you have a finite pool, a draw without replacement, and persisted state:

  1. Sample from the remaining pool, not by retrying.
  2. Inject the randomness source.
  3. Bound every retry loop and fail loudly.
  4. Validate and deduplicate persisted state on every read.
  5. Recompute derived values instead of trusting stored ones.

I used this implementation while building Bingo Caller Online, a browser-based caller supporting 75-, 90-, 80-, and 30-ball games. The draw logic and the state sanitizer above are close to what actually ships.

Disclosure: Bingo Caller Online is my own project.

Top comments (0)