Randomness and RNG

How we built an unbiased lottery number generator

A complete walk-through of the generator behind our tool: OS-grade randomness, exact rejection sampling, and an unbiased Fisher–Yates draw without replacement. The pseudocode is on the page — check it against the running code.

Most "lucky number" tools ask you to trust them. Ours asks you to check. This article documents, in full, the design of the generator behind /tools/number-generator — every stage, every constant, and why each choice is the one a cryptographer would make. The code runs in your browser, so you can open developer tools and verify that what ships matches what's described here.

The problem has three parts, and each has a classic failure mode:

  1. Get unpredictable bits (failure mode: using Math.random()).
  2. Map bits onto a range like 1–45, exactly uniformly (failure mode: modulo bias).
  3. Draw several distinct numbers, all sets equally likely (failure mode: naive shuffles and re-roll loops that skew combinations).

Stage 1 — the entropy source

We use crypto.getRandomValues(), the browser primitive the W3C Web Cryptography specification requires to be cryptographically strong and which every major browser implements on top of the operating system's CSPRNG — getrandom() on Linux, BCryptGenRandom on Windows (details). Three properties matter:

  • Unpredictable: predicting output means breaking OS-level cryptography.
  • Unseedable: there is no API for us (or anyone) to set its state — we couldn't rig it if we wanted to.
  • Local: numbers are generated on your machine and never sent to our servers.

We request 32-bit words (Uint32Array), giving 2^32 = 4,294,967,296 equally likely raw values per draw.

Stage 2 — exact range mapping by rejection sampling

Folding 2^32 values onto 45 outcomes with plain % is biased, because 2^32 mod 45 = 31 — thirty-one leftover values that would favour numbers 1–31 (the full arithmetic). So we reject the leftovers (why this is exact, not approximate):

function secureIntInclusive(max):          # uniform integer in 1..max
    limit = 2^32 - (2^32 mod max)          # for max=45: 4,294,967,265
    repeat:
        x = one fresh 32-bit word from crypto.getRandomValues
    until x < limit
    return (x mod max) + 1

For max = 45, limit is 4,294,967,265 = 45 × 95,443,717 — a perfect multiple of 45 — so each accepted word maps onto every number exactly 95,443,717 ways: probability exactly 1/45 each. The rejection zone is just 31 values wide, so a retry happens about once per 4,294,967,296 / 31 ≈ 139 million calls. Exactness costs essentially nothing at 32 bits.

Stage 3 — draw without replacement: partial Fisher–Yates

A lottery line needs, say, 6 distinct numbers from 45, with every one of the 8,145,060 possible sets equally likely. The tempting shortcut — "pick a number, re-roll if you've seen it" — works but wastes draws; the classic correct tool is the Fisher–Yates shuffle in its modern form, published as Durstenfeld's Algorithm 235 (CACM, 1964): walk through an array once, swapping each position with a uniformly chosen position at or after it. Run over a whole array it produces every permutation with equal probability; we only need the first 6 positions, so we stop after 6 swaps — a partial Fisher–Yates:

function secureDraw(pool, count):          # e.g. pool=45, count=6
    balls = [1, 2, 3, ..., pool]
    for i from 0 to count-1:
        j = i + secureIntInclusive(pool - i) - 1   # uniform in i..pool-1
        swap balls[i], balls[j]
    return sort(balls[0..count-1])

Why this is unbiased: at step i, every ball not yet drawn has an equal chance of being swapped into position i, because secureIntInclusive(pool − i) is exactly uniform (stage 2) over the remaining slots. Multiply along the steps and every ordered sequence of 6 distinct balls has probability 1/45 × 1/44 × 1/43 × 1/42 × 1/41 × 1/40 — so after sorting, every 6-number set has probability exactly 720 × (1/5,864,443,200) = 1/8,145,060. The same probability every real lottery machine aims for.

Games with a supplementary pool (a separate "Powerball"-style barrel) get an independent second secureDraw over that pool, mirroring how the physical draw uses a second machine.

The whole pipeline

crypto.getRandomValues        →  raw 32-bit words   (OS CSPRNG)
secureIntInclusive(max)       →  exact uniform 1..max (rejection sampling)
secureDraw(pool, count)       →  unbiased distinct set (partial Fisher–Yates)

Three stages, each with a one-line correctness argument, composed so that the final claim — every possible line is equally likely — follows from the stages rather than from our say-so.

What we deliberately did not build

  • No seeding, no "lucky mode": any feature that lets a seed or a preference influence output would break the equal-probability proof.
  • No server-side generation: numbers made on our servers would require you to trust our infrastructure. Numbers made in your browser only require you to trust published, inspectable code plus your own OS.
  • No hot/cold weighting: past draws don't change future probabilities, and a generator that pretended otherwise would be selling astrology (the site's position on hot numbers is in the myths cluster).

Audit us

We mean this literally. The pseudocode above corresponds line-for-line to the shipped implementation. Ways to check it:

  1. Read the code. Open /tools/number-generator, view the page source or dev-tools, and find secureIntInclusive and secureDraw. Compare against this page.
  2. Test the output. Generate a large batch and feed the frequencies into /tools/randomness-tester — a chi-square test against uniform should behave exactly as our testing article predicts for a fair source.
  3. Check the constants. 2^32 mod 45 = 31; limit = 4,294,967,265; C(45,6) = 8,145,060. All verifiable with a calculator.

One honest caveat to close on: an unbiased generator gives you fair numbers, not better ones. Every line — quick pick, birthday numbers, or the output of this pipeline — has the same 1 in 8,145,060 chance in a 6-from-45 game. What this generator guarantees is narrower and more defensible: nobody, including us, can predict or skew what it gives you.

Try it yourself

Keep reading

Sources

Last verified: 2026-08-29