I put together a small number picker for an office raffle: type a minimum, type a maximum, hit generate, optionally pull several numbers with no repeats so you can draw multiple winners at once. It felt like a five-minute job. It mostly was — right up until I typed the range in backwards and the tool told me a perfectly valid request was impossible.
Letting min and max be typed in either order
The core random-pick function doesn't trust that the "minimum" field actually holds the smaller number:
function random() {
let real_min = Math.min(min.value, max.value);
let real_max = Math.max(min.value, max.value);
let real_res =
Math.floor(Math.random() * (real_max - real_min + 1)) + real_min;
return real_res;
}
Math.min/Math.max normalize the two inputs before doing anything else, so if you type 99 into "min" and 1 into "max," it just works — no error telling you to swap the fields. The actual random pick is the standard inclusive-integer-range formula: Math.random() gives you [0, 1), multiplying by (range + 1) and flooring spreads that across every integer in the range with equal probability, then you shift up by the minimum. At the range sizes this tool deals with (double-digit or triple-digit spans), there's no meaningful Math.random() bias to worry about — that only becomes a real concern at ranges in the billions, where floating-point precision starts running out of distinct multiples.
The validation check that forgot about the swap
Here's where it got interesting. The "generate multiple, no duplicates" path validates the range before generating anything:
function getRandom() {
let real_res = [];
if (max.value - min.value + 1 < multiple.value) {
return alert(t("random.error1"));
}
if (isMultiple.value) {
if (multiple.value * 1 > 100) multiple.value = 100;
for (let i = 0; i < multiple.value; i++) {
let now = random();
if (!real_res.includes(now)) {
real_res.push(now);
} else {
i--;
}
}
}
...
Look closely at that first if. It computes max.value - min.value + 1 — the raw field values, with no Math.min/Math.max normalization. random() happily tolerates a swapped min/max, but this check doesn't. Type min=99, max=1, ask for 5 unique numbers, and the check computes 1 - 99 + 1 = -97, which is less than 5, so it rejects a request that's actually completely fine — there are 99 valid numbers to draw from. The tool is inconsistent with itself: it's forgiving about swapped input everywhere else, then strict about it in exactly the one place where being strict produces a wrong answer.
Retry until it's unique, not shuffle-and-slice
The no-duplicates mode itself is worth a second look, because there are two very different ways to implement "give me N unique numbers from a range," and this uses the simpler-sounding one:
for (let i = 0; i < multiple.value; i++) {
let now = random();
if (!real_res.includes(now)) {
real_res.push(now);
} else {
i--;
}
}
This is rejection sampling: draw a number, check if it's already in the result array, and if it is, decrement the loop counter so the same slot gets tried again. It's unbiased — every still-available number is equally likely on each draw — and it needs zero extra memory beyond the result array itself.
The alternative would be shuffle-and-slice: build an array of the entire range, Fisher–Yates shuffle it, and take the first N. That's guaranteed to finish in time proportional to the range size no matter what, but it has to allocate an array covering the whole range even if you only want 3 numbers out of it.
Retry-until-unique flips that trade-off: cheap when N is small relative to the range, but it gets worse the closer N gets to the range size. Near the end, most of the range is already taken, so a growing fraction of draws come back as duplicates and get thrown away — the classic coupon-collector problem. On top of that, each real_res.includes(now) check is a linear scan, so as the result array grows the checks themselves get slower too. This tool caps multiple at 100, so in practice you'd have to set a very tight range (like 1–100 while asking for 100 numbers) to actually feel the slowdown — but it's the kind of thing that would matter a lot if someone reused this loop for a bigger range without reusing the cap.
Limitations
A few things worth knowing if you actually use this:
- The min/max-swap validation bug above is real — if your "no duplicates" request gets rejected as impossible, try swapping which number you put in which field before assuming your range is actually too small.
- That
multiple.value * 1 > 100clamp happens after the range check already ran with whatever number you originally typed, and it silently caps your request to 100 with no message. Ask for 500 unique numbers from a range of 10,000 and you'll quietly get 100 back, not 500. -
Math.random()is a pseudorandom generator, not a cryptographically secure one. That's completely fine for raffles, games, and sampling, but it's not the right tool for generating anything security-sensitive, like password seeds or real-money draws.
I cleaned up the version I built for the raffle into a small free tool: Random Number Generator (RNG). No sign-up, works for any range you throw at it — just watch which field you put the bigger number in.
Available in other languages
- 隨機亂數產生器 — 繁體中文
- 在线随机数生成器 (RNG) — 简体中文
- Random Number Generator (RNG) — English
- 乱数ジェネレーター (RNG) — 日本語
- 랜덤 숫자 생성기 — 한국어
- Générateur de nombres aléatoires — Français
- Генератор случайных чисел — Русский
- Zufallszahlengenerator (RNG) — Deutsch
- Pembangkit Angka Acak — Bahasa Indonesia
- Generador de Números Aleatorios — Español
- Trình Tạo Số Ngẫu Nhiên — Tiếng Việt
- เครื่องมือสร้างเลขสุ่ม — ไทย
- Generator Liczb Losowych — Polski
- Rastgele Sayı Üretici — Türkçe
- Generatore di Numeri Casuali — Italiano
- Gerador de Números Aleatórios — Português
- Willekeurige Nummergenerator — Nederlands
- Генератор Випадкових Чисел — Українська
Top comments (0)