Is Math.random random enough for loot drops?
Yes. Over 10,000,000 rolls Math.random hit a 1% drop 0.9998% of the time, as close as crypto.getRandomValues. The streaks players call broken are the maths of independent rolls — 13.5% of 100,000 simulated players got nothing in 200 kills at 1%, matching the 13.40% formula. A pity timer fixes that;
Yes: over 10,000,000 rolls, Math.random gave a 1% drop 0.9998% of the time, and none of the four generators tested would change a player's luck in any way they could notice. The streaks players report as a broken RNG are what fair loot drop randomness looks like. Of 100,000 simulated players doing 200 kills at a 1% drop, 13.54% got nothing at all. The geometric formula predicts 13.40%. The generator is not the problem, and swapping it does not help. A pity timer does help, and one popular design changed the effective drop rate far more than I expected.
Hardware: Apple M3, 8 cores, 16 GB, macOS 26.4.1 (build 25E253). Node v23.5.0, V8 12.9.202.28. Built-ins only. Other benchmarks were running on the machine at the same time (load average 2.6 to 5.4), so timings are medians of seven runs and are best read as ratios.
The short answer
- All four generators passed at game scale. Across 10,000,000 rolls at a 1% rate,
Math.random, mulberry32, a hand-written xorshift128+ andcrypto.getRandomValueseach landed between 0.9947% and 0.9998%. No player can see a difference that small. - Streaks are normal. At a 1% drop, 13.54% of 100,000 simulated players went 200 kills with nothing, and 73.23% had a dry run of 100 or more kills somewhere in those 200. Both figures match the exact calculation (13.40% and 73.21%).
- One player in a hundred needs about 460 kills for their first 1% drop. Simulated p99 was 461 kills with
Math.randomand 457 with mulberry32. The formula gives 459. - A "+1 point per miss" pity timer turns a 1% item into an 8.19% item. Average kills per drop fell from 99.76 to 12.20. A hard guarantee on the 100th kill moved the rate only to 1.579%, but it capped the worst streak at 100 kills. Without it, the worst streak was 1,394.
- Seeding is where RNG bugs actually live. 10,000 players seeded from
Date.now()in a loop got 1 or 2 distinct seeds across two runs, so they shared at most 2 loot histories between them.
Is Math.random actually biased?
Not at any level a game can detect. Each generator produced 10,000,000 values. I counted how many fell under each drop threshold and ran a chi-square test against the expected count. The test has 1 degree of freedom, and 3.84 is the 5% critical value: above it, a fair generator would produce that result by chance only one time in twenty. I also sorted the values into 100 equal buckets. That test has 99 degrees of freedom and a critical value of 123.2.
| Generator | 1% observed | 5% observed | 25% observed | χ² at 1% | χ² at 5% | χ² 100 buckets |
|---|---|---|---|---|---|---|
Math.random (V8) |
0.9998% | 5.0051% | 25.0147% | 0.01 | 0.54 | 94.4 |
| mulberry32 | 0.9998% | 5.0000% | 24.9889% | 0.00 | 0.00 | 85.9 |
| xorshift128+ (hand-written) | 0.9947% | 4.9747% | 24.9855% | 2.88 | 13.52 | 130.5 |
crypto.getRandomValues |
0.9975% | 4.9961% | 25.0096% | 0.63 | 0.32 | 96.8 |
My own xorshift128+ failed twice in that run, and my first thought was a bug. It was not. The output matched a BigInt reference implementation for the first 1,000 values, and V8 uses the same algorithm behind Math.random. It was the seed. I re-ran it with 10 fixed seeds, then 30 random ones. It failed the drop test 5 times out of 90, where chance alone predicts about 4.5. Math.random failed 2 of 90 and crypto 4 of 90. Seed 0 was just unlucky.
mulberry32 was less clean. Over 80 random seeds it failed 23 of 240 drop tests, where chance predicts 12. Its average chi-square was 1.20 to 1.41 instead of the ideal 1.00. Math.random averaged 0.84 to 1.19 on the same test. So there is weak evidence that mulberry32's counts spread slightly wider than ideal. At 10 million rolls, that still means differences in the third decimal place of a percentage. It is not something a player could ever see, and it would not change a single conclusion below.
Math.random is not "bad" for loot. It is bad for things it was never meant for: it cannot be seeded, so you cannot replay a run, and it is not cryptographically secure.
How long can a fair drop streak get?
This is the question behind "I killed 40 bosses and got nothing". I simulated 100,000 players doing 200 kills each at a 1% drop, then kept going for each player until their first drop.
| Measure (1% drop) | Simulated, Math.random | Simulated, mulberry32 | Calculated |
|---|---|---|---|
| Zero drops in 200 kills | 13.54% | 13.29% | 13.40% (0.99²⁰⁰) |
| A dry run of 40+ somewhere in 200 kills | 99.98% | 99.98% | 99.98% |
| A dry run of 100+ somewhere in 200 kills | 73.23% | 73.18% | 73.21% |
| Kills to first drop, median | 69 | 69 | 69 |
| Kills to first drop, p90 | 229 | 228 | 230 |
| Kills to first drop, p99 | 461 | 457 | 459 |
| Kills to first drop, worst of 100,000 | 1,188 | 1,206 | — |
The "calculated" column is not simulated. It comes from the geometric distribution, plus a small dynamic-programming count for the dry-run rows. The simulations match it to within a fifth of a percentage point.
Read the 40-boss row again. Almost every player hits a 40-kill dry spell in 200 kills. From any single starting point, the chance of 40 straight misses is 66.90%. And at 100,000 players, a thousand of them need more than 460 kills for a drop that reads as "1 in 100" on the wiki. Those players are the ones writing forum posts, and they are right about their numbers. They are wrong about the cause.
A "better" generator changes none of this. The distribution of streaks is set by the drop rate alone.
What does a pity timer actually change?
A pity timer is the only tool that changes the streak. Here are six systems, each run for 2,000,000 drops with a base chance of 1%:
| System | Kills per drop | Effective rate | Median gap | p99 gap | Worst gap |
|---|---|---|---|---|---|
| None (flat 1%) | 99.76 | 1.002% | 69 | 457 | 1,394 |
| Guaranteed on the 200th kill | 86.39 | 1.158% | 69 | 200 | 200 |
| Guaranteed on the 100th kill | 63.32 | 1.579% | 69 | 100 | 100 |
| Soft pity: +5% per miss after 75 | 55.32 | 1.808% | 69 | 86 | 94 |
| +0.1 point per miss | 31.80 | 3.144% | 29 | 86 | 170 |
| +1 point per miss | 12.20 | 8.194% | 12 | 29 | 47 |
I did not predict the last row. "+1 point per miss" sounds like a gentle nudge on a 1% item. In practice, the chance is already 12% on the twelfth kill, so most drops happen long before any real bad luck builds up. The item becomes eight times more common, and the median gap falls from 69 kills to 12. If your economy was balanced around 1%, this system breaks it without anyone noticing. Even +0.1 point per miss tripled the rate.
A hard pity at 100 is the gentle one. The simulated rate of 63.32 kills per drop matches the calculated (1 − 0.99¹⁰⁰) / 0.01 = 63.4. The median player never feels it: their gap is still 69 kills. It only rescues the unlucky 37% who go 99 kills dry. The soft pity after 75 does the same job and caps the worst case at 94 kills, for a 1.81% effective rate.
The rule of thumb: a guarantee caps the tail and barely touches the average. A ramp that starts from the first miss changes the average and should be balanced as a new drop rate.
What goes wrong with seeding?
This is where I have actually seen loot bugs. You seed per player so drops can be replayed or checked on a server, as in deterministic simulation. Then you seed from the clock:
| Seeding method | Players | Distinct seeds / collisions |
|---|---|---|
Date.now() in a tight loop |
10,000 | 2 distinct seeds (1 on a second run) |
performance.timeOrigin + now(), floored to ms |
10,000 | 3 distinct |
| Random 32-bit seed | 10,000 | 0 collisions (calculated ~0.01) |
| Random 32-bit seed | 100,000 | 2 collisions, then 1 (calculated ~1.16) |
| Random 32-bit seed | 1,000,000 | 121 collisions, then 126 (calculated ~116.42) |
A server that creates a batch of accounts in one tick gives at least 9,998 of 10,000 players a twin with the same 200-kill loot history, drop for drop. This is the one case where "the RNG is rigged" is literally true. A 32-bit seed chosen at random is fine for a single-player save. At a million players, though, the birthday bound gives about 120 pairs of identical streams. For per-player seeds at that scale, draw 64 bits or more from crypto.getRandomValues, and store the seed with the save as described in the save-format article.
Sequential seeds were not a problem for mulberry32. With seeds s and s+1, the first roll dropped at 1% for both in 12 of 100,000 pairs, against 10 expected if the two were independent.
Which generator is fastest?
| Generator | ns per call (median of 7) | vs Math.random |
|---|---|---|
| mulberry32 | 1.00 | 0.21x |
crypto.getRandomValues, 4,096-value buffer |
3.17 | 0.67x |
Math.random |
4.74 | 1.00x |
| xorshift128+ in JavaScript | 21.10 | 4.45x |
crypto.getRandomValues, one value per call |
632.47 | 133x |
Each generator ran in its own process. In a second pass the figures were 5.12, 1.02, 20.38 and 3.20 ns, in the same order.
My first speed run was wrong, and the reason matters for any micro-benchmark. With all generators timed in one process through a shared call site, mulberry32 measured 5.26 ns, not 1.00. A crypto buffer measured 7.03 ns, not 3.17. V8 had deoptimised the loop for seeing several different functions, so every generator ended up looking like Math.random. The same "measure each strategy in its own process" trap caught the object pooling measurements.
Two results I did not expect. First, my hand-written xorshift128+ is 4.5x slower than Math.random, which runs the same algorithm natively. Splitting 64-bit state into 32-bit halves in JavaScript is expensive. Second, a buffered crypto.getRandomValues beat Math.random. Calling crypto once per value cost 632 ns, so never do that. But none of these costs matter for loot: even the slowest option allows over a million rolls in a 16 ms frame.
What to actually build
Keep Math.random for anything that does not need replaying. Use a seeded mulberry32 when drops must be reproducible or checked on a server, since only the seed and a roll counter then need to cross the wire, as in syncing game state. Seed it from crypto.getRandomValues, never from the clock. Choose a pity rule for the worst case you are willing to ship, and measure its effective rate before it goes live, because a ramp can multiply the drop rate several times over.
If the drops need items to drop, the art for them is what Game Asset Generator is for.
Check it yourself
Save as loot.cjs and run node loot.cjs. No dependencies. It takes about 10 seconds, and node loot.cjs streak runs one section.
// node loot.cjs [dist|streak|pity|seed|speed|all]
// Is Math.random random enough for loot drops? No dependencies.
const { webcrypto } = require('node:crypto');
const which = process.argv[2] || 'all';
const on = s => which === 'all' || which === s;
const log = (...a) => console.log(...a);
// ---- generators: each factory returns () => float in [0, 1) -------------
const mathRandom = () => Math.random;
function mulberry32(a) {
return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
// xorshift128+ with 64-bit state held as 32-bit halves (same algorithm V8 uses)
function xorshift128p(seedHi = 0x9E3779B9, seedLo = 0x7F4A7C15) {
let s0h = seedHi >>> 0, s0l = seedLo >>> 0, s1h = 0x6A09E667, s1l = 0xBB67AE85;
return () => {
let xh = s0h, xl = s0l; const yh = s1h, yl = s1l;
s0h = yh; s0l = yl;
// x ^= x << 23
let th = (xh << 23) | (xl >>> 9), tl = xl << 23; xh ^= th; xl ^= tl;
// x ^= x >>> 17
tl = (xl >>> 17) | (xh << 15); th = xh >>> 17; xh ^= th; xl ^= tl;
// x ^= y ^ (y >>> 26)
tl = (yl >>> 26) | (yh << 6); th = yh >>> 26; xh ^= yh ^ th; xl ^= yl ^ tl;
s1h = xh >>> 0; s1l = xl >>> 0;
// return s0 + s1 (top 53 bits)
const lo = (s0l + s1l) >>> 0; const carry = lo < s0l ? 1 : 0;
const hi = (s0h + s1h + carry) >>> 0;
return (hi * 2097152 + (lo >>> 11)) / 9007199254740992;
};
}
function cryptoBuffered() {
const buf = new Uint32Array(4096); let i = buf.length;
return () => { if (i === buf.length) { webcrypto.getRandomValues(buf); i = 0; } return buf[i++] / 4294967296; };
}
function cryptoPerCall() {
const one = new Uint32Array(1);
return () => { webcrypto.getRandomValues(one); return one[0] / 4294967296; };
}
const GENS = {
'Math.random': mathRandom,
'mulberry32': () => mulberry32(12345),
'xorshift128+': () => xorshift128p(),
'crypto (4096 buffer)': cryptoBuffered,
};
// self-check xorshift128+ against a BigInt reference
{
const M = (1n << 64n) - 1n;
let a = 0x9E3779B97F4A7C15n, b = 0x6A09E667BB67AE85n;
const ref = () => { let x = a; const y = b; a = y; x ^= (x << 23n) & M; x ^= x >> 17n; x ^= y ^ (y >> 26n); b = x;
return Number(((a + b) & M) >> 11n) / 9007199254740992; };
const g = xorshift128p();
for (let i = 0; i < 1000; i++) if (g() !== ref()) { throw new Error('xorshift128+ mismatch at ' + i); }
}
// ---- 1. distribution at game scale --------------------------------------
if (on('dist')) {
const N = 10_000_000;
log(`\n[dist] ${N.toLocaleString()} rolls per generator; drop chi-square has 1 df (5% critical 3.84),`);
log(' 100-bucket uniformity chi-square has 99 df (5% critical 123.2)');
log('generator rate observed expected chi2(drop) chi2(100 buckets)');
for (const [name, make] of Object.entries(GENS)) {
const r = make();
const vals = new Float64Array(N); for (let i = 0; i < N; i++) vals[i] = r();
const buckets = new Float64Array(100); for (let i = 0; i < N; i++) buckets[(vals[i] * 100) | 0]++;
let u = 0; const e = N / 100; for (const b of buckets) u += (b - e) ** 2 / e;
for (const p of [0.01, 0.05, 0.25]) {
let hits = 0; for (let i = 0; i < N; i++) if (vals[i] < p) hits++;
const eh = N * p, em = N * (1 - p);
const chi = (hits - eh) ** 2 / eh + ((N - hits) - em) ** 2 / em;
log(name.padEnd(20), String(p * 100 + '%').padStart(5), (hits / N * 100).toFixed(4).padStart(9) + '%',
(p * 100).toFixed(4).padStart(9) + '%', chi.toFixed(2).padStart(10), p === 0.01 ? u.toFixed(1).padStart(12) : '');
}
}
}
// ---- 2. dry streaks: 100,000 players x 200 kills at 1% -------------------
if (on('streak')) {
const PLAYERS = 100_000, KILLS = 200, P = 0.01;
log(`\n[streak] ${PLAYERS.toLocaleString()} players, ${KILLS} kills each, ${P * 100}% drop`);
const pct = (arr, q) => arr[Math.min(arr.length - 1, Math.floor(q * arr.length))];
for (const name of ['Math.random', 'mulberry32']) {
const r = GENS[name]();
const longest = new Int32Array(PLAYERS); let zero = 0, over100 = 0, over40 = 0;
const firstDrop = new Int32Array(PLAYERS); // kills to first drop, unbounded
for (let p = 0; p < PLAYERS; p++) {
let run = 0, best = 0, got = 0;
for (let k = 0; k < KILLS; k++) { if (r() < P) { got++; if (run > best) best = run; run = 0; } else run++; }
if (run > best) best = run;
longest[p] = best; if (got === 0) zero++; if (best >= 100) over100++; if (best >= 40) over40++;
let n = 1; while (r() >= P) n++; firstDrop[p] = n;
}
longest.sort(); firstDrop.sort();
log(`${name}: zero drops in 200 kills ${(zero / PLAYERS * 100).toFixed(2)}% | a dry run >=40 ${(over40 / PLAYERS * 100).toFixed(2)}% >=100 ${(over100 / PLAYERS * 100).toFixed(2)}%`);
log(` longest dry run in 200 kills: median ${pct(longest, 0.5)} p90 ${pct(longest, 0.9)} p99 ${pct(longest, 0.99)} max ${longest[PLAYERS - 1]}`);
log(` kills to first drop: median ${pct(firstDrop, 0.5)} p90 ${pct(firstDrop, 0.9)} p99 ${pct(firstDrop, 0.99)} max ${firstDrop[PLAYERS - 1]}`);
}
// calculated, not simulated
const q = 1 - P;
const kFor = t => Math.ceil(Math.log(1 - t) / Math.log(q));
log(`formula: P(zero in 200) = 0.99^200 = ${(q ** 200 * 100).toFixed(2)}%; P(40 misses in a row from a start) = ${(q ** 40 * 100).toFixed(2)}%`);
log(`formula: geometric kills-to-first-drop median ${kFor(0.5)} p90 ${kFor(0.9)} p99 ${kFor(0.99)}`);
// exact P(some run of >= L misses within n kills), by dynamic programming
const runProb = (n, L) => { let st = new Float64Array(L); st[0] = 1; let hit = 0;
for (let k = 0; k < n; k++) { const nx = new Float64Array(L); for (let j = 0; j < L; j++) { if (!st[j]) continue;
nx[0] += st[j] * P; if (j + 1 >= L) hit += st[j] * q; else nx[j + 1] += st[j] * q; } st = nx; } return hit; };
log(`formula (DP): P(a run >=40 within 200) ${(runProb(200, 40) * 100).toFixed(2)}% P(a run >=100 within 200) ${(runProb(200, 100) * 100).toFixed(2)}%`);
}
// ---- 3. pity timers -------------------------------------------------------
if (on('pity')) {
const DROPS = 2_000_000, P = 0.01;
log(`\n[pity] ${DROPS.toLocaleString()} drops per system, base ${P * 100}%`);
const systems = {
'none (flat 1%)': m => P,
'hard pity at 100 misses': m => (m >= 99 ? 1 : P),
'hard pity at 200 misses': m => (m >= 199 ? 1 : P),
'+1% per miss': m => P + 0.01 * m,
'+0.1% per miss': m => P + 0.001 * m,
'soft pity: +5%/miss after 75': m => (m < 75 ? P : P + 0.05 * (m - 74)),
};
const r = mulberry32(987654321);
log('system kills/drop effective rate median p99 worst');
for (const [name, chance] of Object.entries(systems)) {
const gaps = new Int32Array(DROPS); let kills = 0;
for (let d = 0; d < DROPS; d++) { let m = 0; while (r() >= chance(m)) m++; gaps[d] = m + 1; kills += m + 1; }
gaps.sort();
log(name.padEnd(31), (kills / DROPS).toFixed(2).padStart(10), (DROPS / kills * 100).toFixed(3).padStart(13) + '%',
String(gaps[DROPS >> 1]).padStart(7), String(gaps[Math.floor(DROPS * 0.99)]).padStart(5), String(gaps[DROPS - 1]).padStart(7));
}
}
// ---- 4. seeding pitfalls ---------------------------------------------------
if (on('seed')) {
log('\n[seed]');
const PLAYERS = 10_000;
const seeds = []; for (let i = 0; i < PLAYERS; i++) seeds.push(Date.now());
const distinct = new Set(seeds).size;
log(`Date.now() in a tight loop: ${PLAYERS} players -> ${distinct} distinct seeds (${PLAYERS - distinct} players share a seed with someone)`);
// identical loot: first 20 rolls at 5% as a bitmask
const seq = s => { const g = mulberry32(s); let k = ''; for (let i = 0; i < 200; i++) k += g() < 0.05 ? '1' : '0'; return k; };
const seqs = new Set(seeds.map(seq));
log(` distinct 200-kill loot histories among those players: ${seqs.size}`);
const hr = []; for (let i = 0; i < PLAYERS; i++) hr.push(Math.floor(performance.timeOrigin + performance.now()));
log(`performance.timeOrigin+now() floored to ms: ${new Set(hr).size} distinct`);
// 32-bit random seeds: birthday collisions
for (const n of [10_000, 100_000, 1_000_000]) {
const set = new Set(); let coll = 0; const buf = new Uint32Array(1);
for (let i = 0; i < n; i++) { webcrypto.getRandomValues(buf); if (set.has(buf[0])) coll++; else set.add(buf[0]); }
log(`random 32-bit seeds, ${n.toLocaleString()} players: ${coll} collisions (expected ~${(n * (n - 1) / 2 / 2 ** 32).toFixed(2)})`);
}
// sequential seeds: does player 1's and player 2's stream look related?
let same = 0; const T = 100_000;
for (let s = 1; s <= T; s++) { const a = mulberry32(s)() < 0.01, b = mulberry32(s + 1)() < 0.01; if (a && b) same++; }
log(`mulberry32 seeds s and s+1, first roll both drop at 1%: ${same} of ${T} (independent expectation ${T * 0.0001})`);
}
// ---- 5. speed ---------------------------------------------------------------
// Each generator is timed in its OWN process: sharing one call site across
// generators made V8 deoptimise it and hid a 5x difference.
if (which === 'speed1') {
const name = process.argv[3], RUNS = 7;
const make = name === 'crypto (per call)' ? cryptoPerCall : GENS[name];
const n = name.includes('per call') ? 500_000 : 5_000_000, out = [];
for (let run = 0; run < RUNS; run++) {
const r = make(); let s = 0; for (let i = 0; i < 100000; i++) s += r();
const t0 = process.hrtime.bigint(); for (let i = 0; i < n; i++) s += r();
out.push(Number(process.hrtime.bigint() - t0) / n);
}
out.sort((a, b) => a - b); log(out[RUNS >> 1]);
}
if (on('speed')) {
const { execFileSync } = require('node:child_process');
log('\n[speed] ns per call, median of 7 runs, each generator in its own process');
const res = {};
for (const name of [...Object.keys(GENS), 'crypto (per call)'])
res[name] = Number(execFileSync(process.execPath, [__filename, 'speed1', name]).toString());
for (const [name, ns] of Object.entries(res))
log(name.padEnd(22), ns.toFixed(2).padStart(8), 'ns', (ns / res['Math.random']).toFixed(2).padStart(8) + 'x Math.random');
}
On the machine above it prints:
[dist] 10,000,000 rolls per generator; drop chi-square has 1 df (5% critical 3.84),
100-bucket uniformity chi-square has 99 df (5% critical 123.2)
generator rate observed expected chi2(drop) chi2(100 buckets)
Math.random 1% 0.9952% 1.0000% 2.37 85.6
Math.random 5% 4.9917% 5.0000% 1.43
Math.random 25% 24.9923% 25.0000% 0.32
mulberry32 1% 0.9998% 1.0000% 0.00 85.9
mulberry32 5% 5.0000% 5.0000% 0.00
mulberry32 25% 24.9889% 25.0000% 0.65
xorshift128+ 1% 0.9947% 1.0000% 2.88 130.5
xorshift128+ 5% 4.9747% 5.0000% 13.52
xorshift128+ 25% 24.9855% 25.0000% 1.11
crypto (4096 buffer) 1% 1.0003% 1.0000% 0.01 118.8
crypto (4096 buffer) 5% 4.9937% 5.0000% 0.84
crypto (4096 buffer) 25% 24.9881% 25.0000% 0.75
[streak] 100,000 players, 200 kills each, 1% drop
Math.random: zero drops in 200 kills 13.51% | a dry run >=40 99.98% >=100 73.09%
longest dry run in 200 kills: median 124 p90 200 p99 200 max 200
kills to first drop: median 70 p90 230 p99 467 max 1230
mulberry32: zero drops in 200 kills 13.29% | a dry run >=40 99.98% >=100 73.18%
longest dry run in 200 kills: median 124 p90 200 p99 200 max 200
kills to first drop: median 69 p90 228 p99 457 max 1206
formula: P(zero in 200) = 0.99^200 = 13.40%; P(40 misses in a row from a start) = 66.90%
formula: geometric kills-to-first-drop median 69 p90 230 p99 459
formula (DP): P(a run >=40 within 200) 99.98% P(a run >=100 within 200) 73.21%
[pity] 2,000,000 drops per system, base 1%
system kills/drop effective rate median p99 worst
none (flat 1%) 99.76 1.002% 69 457 1394
hard pity at 100 misses 63.32 1.579% 69 100 100
hard pity at 200 misses 86.39 1.158% 69 200 200
+1% per miss 12.20 8.194% 12 29 47
+0.1% per miss 31.80 3.144% 29 86 170
soft pity: +5%/miss after 75 55.32 1.808% 69 86 94
[seed]
Date.now() in a tight loop: 10000 players -> 1 distinct seeds (9999 players share a seed with someone)
distinct 200-kill loot histories among those players: 1
performance.timeOrigin+now() floored to ms: 3 distinct
random 32-bit seeds, 10,000 players: 0 collisions (expected ~0.01)
random 32-bit seeds, 100,000 players: 1 collisions (expected ~1.16)
random 32-bit seeds, 1,000,000 players: 126 collisions (expected ~116.42)
mulberry32 seeds s and s+1, first roll both drop at 1%: 12 of 100000 (independent expectation 10)
[speed] ns per call, median of 7 runs, each generator in its own process
Math.random 4.91 ns 1.00x Math.random
mulberry32 1.02 ns 0.21x Math.random
xorshift128+ 20.63 ns 4.20x Math.random
crypto (4096 buffer) 3.13 ns 0.64x Math.random
crypto (per call) 635.20 ns 129.42x Math.random
Random-seeded rows (Math.random, crypto and the collision counts) will differ slightly from run to run. The mulberry32 rows and the pity table are seeded and should match exactly.