Back to News & Insights
JavaScript September 25, 2026 · 4 min read

A No-Repeat Random Draw Looks Trivial Until Round 70

Rejection sampling on a shrinking set is a performance bug and a fairness bug. Here is the version I ship instead, plus how to validate the persisted state on read.

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.

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.

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.

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.

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.

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

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.

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:

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):

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

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

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 pattern generalizes past bingo: whenever you have a finite pool, a draw without replacement, and persisted state: Sample from the remaining pool, not by retrying. Inject the randomness source. Bound every retry loop and fail loudly. Validate and deduplicate persisted state on every read. Recompute derived values instead of trusting stored ones.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation