Math
Fair dice in a browser: modulo bias and why I care on a d20
On this page 0%
A die with 20 faces should not prefer 7. That sounds obvious until you write rand() % 20 on a generator whose range is not a multiple of 20. The leftover values pile on the low faces. The bias is small. Tabletop arguments are not. I built the dice roller with rejection sampling on crypto.getRandomValues so I could stop having that argument in Discord.
This post is modulo bias, why Math.random() is the wrong tool if you asked for fair, how rejection sampling works, and why a d20 is where people notice.
What modulo bias is
Suppose you draw a uniform integer from 0 to 255 (one random byte). You want a number from 0 to 19. byte % 20 maps 256 possible inputs onto 20 buckets. 256 / 20 is 12 remainder 16. So residues 0–15 get 13 source values. Residues 16–19 get 12. Face 1 (if you add 1) is slightly hot. Face 17 is slightly cold. On a d6 with a full 32-bit integer the bias is tiny. On a d20 with a small source range it is larger. On a d3 from a byte it is embarrassing.
The fix is not “use a bigger rand.” A 32-bit integer mod 20 still has a remainder. The remainder is a smaller fraction of 2^32, so you will not see it in a living-room sample. It is still ugly in a library. I would rather the code be correct when someone copies it into a server that rolls millions of loot tables.
Rejection sampling
Pick the largest multiple of n that fits in the source range. For a byte and n = 20, that is 240. Draw a byte. If it is ≥ 240, throw it away and draw again. If it is < 240, then % 20 is fair. Expected retries are small. The loop is not an infinite theoretical risk in practice; you can cap retries if you are paranoid and then fail, but in a browser roller I just loop. A few extra getRandomValues calls are cheaper than a forum thread.
You can also draw a 32-bit unsigned integer and reject above Math.floor(2^32 / n) * n. Same idea. I like 32-bit for dice because n is tiny (4, 6, 8, 10, 12, 20) and one call fills several rolls if you slice bytes carefully. Clarity first. I still reject per roll rather than invent a clever bit buffer that I will mis-index.
Why crypto.getRandomValues
Math.random() is a PRNG. Fast, not a secret, implementation-defined across engines. Fine for a shuffle of demo quotes. Weak as a fairness claim. crypto.getRandomValues is the Web Crypto CSPRNG. You fill a Uint32Array. You do not seed it. You do not inspect the engine’s xorshift. For a dice page that advertises fair rolls, that is the API I am willing to put in a sentence.
It is not a hardware tabletop die. It is not proof against a compromised browser. It is the correct browser primitive. If your threat model is a cheating player with DevTools, no online roller wins. Play in person. If your threat model is “does this modulo suck,” we can win.
d20 culture
People argue about physical d20s more than d6s because a 5% face is a character sheet. Online, they screenshot 1s. Confirmation bias is real. A fair generator will still clump. Runs of 1s happen. I show a history and a total so the clump is visible and boring instead of mythical.
Advantage and disadvantage (roll twice, take high or low) are not a reason to bias a single die. Generate two independent fair d20s, then pick. If you generate one roll and fudge, you are writing a story, not a roller.
Percentile dice (d100) are two d10s or a 0–99 draw. Modulo bias on 100 from a 32-bit int is still a remainder problem. Same rejection. Do not special-case 100 with a float Math.floor(Math.random() * 100) while the d20 path is crypto. One method.
Floats are a trap
Math.floor(Math.random() * 20) + 1 is the Stack Overflow die. Math.random() is in [0, 1). Multiplication can, in ugly edge cases, sit on rounding that makes the distribution not quite uniform. Combined with a non-crypto PRNG, I do not want it on a page that says fair. Integer draws plus rejection. No floats in the face mapping.
Seeding is for replays. A CSPRNG in the browser is not seedable on purpose. If I need a seeded demo, that is a different generator with an explicit PRNG and a label that says “demo, not crypto.” Mixing those in one button is how you lie.
Table use
Roller supports d4, d6, d8, d10, d12, d20, up to twelve at once, with high, low, and sum. That covers most night-of-game needs. If you want a full character sheet, use a VTT. If you want a night that also picks a film, pair this with the game night pack post: dice, trivia, movies.
Do not use dice to generate passwords. Diceware is a real method with a word list. A d20 is not a word list. Use the password tool. Do not use dice as UUIDs. See v4 vs v7.
What I tell the rules lawyer
The roller is fair in the modulo sense with a CSPRNG. Physical dice have manufacturing bias. Neither is a court of law. If a campaign’s loot depends on a 1% item, roll in the open, log the history, and agree before the session which roller you trust. Switching generators after a 1 is the actual cheat.
More than one die
Two d6 are not a d12. The distribution of the sum peaks in the middle. If a game says 2d6, generate two fair d6 and add. If you generate a uniform 2–12, you changed the game. I have seen online rollers that offered “2d6” and used a single uniform draw. That is a different weapon. The Randomly UI shows individual faces plus the total so you can see snake eyes as snake eyes, not as a 2 that might have been a hidden d12.
Exploding dice, keep-highest, and Fudge dice are extra rules. I would rather a simple roller that is correct than a menu of variants that reintroduce Math.random() in a helper someone pasted. If you need exploding d6, roll a d6, and if it is 6 roll again and add. Each face still goes through the same rejection sampler.
Physical vs digital is a social contract. Some tables want the sound of resin. Use resin. The browser die is for the night you forgot the bag, or the remote player on a bad webcam, or the argument about whether the floor die was cocked. Call cocked before you look. Same as real life.
Frequently asked questions
Is the bias of % 20 on a 32-bit int noticeable?
Not in a session. It is still the wrong formula. Rejection is cheap. Use it.
Why not use crypto.getRandomValues and then a float?
Because you went back to floats. Stay in integers.
Can I get the same roll as my friend?
Not with this CSPRNG path. Share the history screenshot, or sit at the same table.
Does Randomly store my rolls?
No. They stay in the page. Privacy for analytics on visits, not on your d20s.