JAVASCRIPT / NUMBERS, MATH, AND BIGINT
Random numbers with Math.random
Produce uniform floats, inclusive integer ranges, and random list picks with Math.random, and know when a seeded PRNG or crypto is the right tool instead.
What you will learn
- Read Math.random() as a uniform double in [0, 1): 0 is possible, 1 is not.
- Write inclusive ranges as Math.floor(Math.random() * (max - min + 1)) + min.
- Explain why Math.round halves the frequency of the two end values.
- Swap in crypto.getRandomValues for anything an attacker should not guess.
Understanding Random numbers with Math.random
Math.random() takes no arguments and returns a double-precision float in the half-open interval [0, 1): zero is a legal result, one never is. Each call advances an internal generator state that the language deliberately keeps out of reach, so there is no seed and no way to rewind. The specification only promises an implementation-chosen, approximately uniform value; V8 happens to run xorshift128+ and refill a small buffer of values at a time, but nothing in your code should depend on that.
Every integer recipe is the same three moves: stretch, slice, shift. Multiplying by span = max - min + 1 stretches [0, 1) into [0, span), Math.floor cuts that into span slices exactly one unit wide so each of 0..span-1 is equally likely, and adding min slides the block into position. The excluded upper endpoint is what makes this airtight: because the product never reaches span, Math.floor can never return span, so max + 1 is unreachable without an extra guard.
Uniform does not mean evenly spread. Independent draws clump, so twelve rolls of a six-sided die that repeat a value or skip a face are normal, and the only honest way to check a generator is to tally tens of thousands of samples into buckets. Independence cuts the other way too: an observer who sees a handful of outputs can solve for the internal state, which is why Math.random belongs in games, jitter, sampling, and placeholder data, but never in tokens, keys, or anything worth money.
const r = Math.random();
console.log(r >= 0 && r < 1); // the range is [0, 1), always
function randomInt(min, max) {
const span = max - min + 1; // how many integers we want, not the biggest one
return Math.floor(Math.random() * span) + min;
}
const counts = [0, 0, 0, 0, 0, 0];
for (let i = 0; i < 60000; i++) {
counts[randomInt(1, 6) - 1]++;
}
console.log(counts.reduce((a, b) => a + b, 0));
console.log(counts.every(c => c > 9000 && c < 11000)); // each face near 10000
console.log(randomInt(3, 3)); // span of 1: the only possible answerMath.random() gives one uniform double in [0, 1), and every other random value you need comes from stretching that interval and cutting it into equal-width slices with Math.floor.
Worked examples
Why Math.round skews the ends
Measures the bias you get from rounding instead of flooring when converting a float to a bucket.
const round = [0, 0, 0, 0, 0, 0];
const floor = [0, 0, 0, 0, 0, 0];
for (let i = 0; i < 60000; i++) {
round[Math.round(Math.random() * 5)]++; // buckets 0 and 5 are half-width
floor[Math.floor(Math.random() * 6)]++; // six buckets of equal width
}
// how many times more likely is bucket 1 than bucket 0?
console.log(Math.round(round[1] / round[0]));
console.log(Math.round(floor[1] / floor[0]));Example explained
Line 1Math.round(Math.random() * 5) sends [0, 0.5) to 0 and [4.5, 5) to 5, so those two buckets catch half a unit of the interval while 1 through 4 catch a full unit each.
Line 2That makes bucket 1 roughly twice as likely as bucket 0, which the printed ratio of 2 confirms over 60,000 draws.
Line 3Multiplying by 6 and flooring produces six one-unit slices, so the same ratio comes out as 1.
Line 4The defect hides at small sample sizes: over 60 rolls, 2 hits versus 6 hits reads as ordinary noise.
Picking an element from an array
Uses the array length as the span, and shows what an empty array returns instead of throwing.
const colors = ['red', 'green', 'blue'];
function pick(list) {
return list[Math.floor(Math.random() * list.length)];
}
const seen = new Set();
for (let i = 0; i < 1000; i++) seen.add(pick(colors));
console.log(seen.size);
console.log([...seen].every(c => colors.includes(c)));
console.log(pick([]));Example explained
Line 1list.length is exactly the number of valid indices, so multiplying by it and flooring lands in 0..length-1 and never past the end.
Line 2Over 1000 picks from three colors, missing one is effectively impossible, so the Set fills up to 3.
Line 3For an empty list, Math.random() * 0 is 0, Math.floor(0) is 0, and [][0] is undefined, so you get a silent undefined rather than an error.
Line 4Guard the length yourself if an empty collection is possible in your data.
Reproducible randomness needs your own generator
Shows that a seeded PRNG replays a stream while Math.random ignores any argument you pass it.
function mulberry32(seed) {
let state = seed | 0;
return function () {
state = (state + 0x6d2b79f5) | 0;
let t = state ^ (state >>> 15);
t = Math.imul(t, t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const three = seed => {
const rand = mulberry32(seed);
return [rand(), rand(), rand()].join();
};
console.log(three(7) === three(7));
console.log(three(7) === three(8));
console.log(three(7).split(',').every(v => Number(v) >= 0 && Number(v) < 1));
console.log(Math.random(7) === Math.random(7));Example explained
Line 1mulberry32 keeps its state in a closure you own, so starting again from seed 7 replays the identical stream.
Line 2A different seed walks a different part of the state space, so three(7) and three(8) do not match.
Line 3Dividing an unsigned 32-bit integer by 2 ** 32 reproduces the [0, 1) range, which makes the generator a drop-in stand-in for Math.random.
Line 4Math.random(7) neither throws nor seeds anything: the argument is discarded and the two calls return unrelated numbers.
Important notes
Math.random is not cryptographically secure; its internal state can be reconstructed from a few outputs, so use crypto.getRandomValues or crypto.randomUUID for tokens, password resets, and shuffles that carry real stakes.
0 is a legal return value while 1 is not, which is why Math.ceil(Math.random() * n) has a legal path to returning 0 that testing will never surface, and why floor-based formulas are the safe default.
Common mistakes
Writing Math.round(Math.random() * (max - min)) + min: the range looks right, but min and max each appear about half as often as the middle values, quietly skewing dice, spawn points, and A/B splits.
Passing a seed as in Math.random(42) and expecting repeatable values: the argument is silently ignored, so tests written around 'the same random numbers' fail intermittently and look flaky rather than wrong.
Multiplying by arr.length - 1 instead of arr.length: the last element can never be selected, and because every returned value is still valid the bug survives any amount of manual clicking.
Try it yourself
Change, predict, then run
In a browser console, build a deck from a 13-element rank array and a 4-element suit array, write drawCard() that indexes both with Math.floor(Math.random() * length), then draw 5000 cards into a Set and confirm its size is 52 with no undefined entries.
Open the JavaScript workspaceCheck your understanding
A game picks a level with Math.round(Math.random() * 10). Over a million runs, levels 0 and 10 each appear about half as often as level 5. Why?
- Math.random() returns values clustered near 0.5, so extremes are naturally rare.
- Rounding maps only a half-unit-wide slice of the range to 0 and to 10, while each of 1 through 9 gets a full unit.
- Math.round breaks ties upward, so 0 loses half its values to 1 and 10 gains none.
- Math.random() excludes 1, so the top bucket is missing half of its inputs.
Show answer
Math.random() * 10 spans [0, 10), and rounding assigns [0, 0.5) to 0 and [9.5, 10) to 10 while every integer in between owns a full unit such as [4.5, 5.5) — half the width means half the probability. Option 4 is tempting because the interval really does exclude 1, but that removes a single value with essentially zero probability mass; the bias comes from unequal bucket widths, not from the missing endpoint.