← Back

Building a Provably Fair RNG with HMAC-SHA256

3/10/26

Building a provably fair RNG for an online casino

When I first started this project I was super interested in how massive companies like Stake had players who would trust their custom games.

The answer in the crypto world is something called “provable fairness.” The idea is that every single game outcome is determined by inputs that both the house and the player contribute to, and after the game is over the player can independently verify that the result wasn’t tampered with.

The basic idea

There are three inputs that go into generating every game outcome:

  • Server seed - a random 32-byte hex string the server generates. The player never sees this while games are active. They only see a SHA-256 hash of it upfront, so they can verify it later.
  • Client seed - a string the player can set to whatever they want. This is their contribution to the randomness.
  • Nonce - a counter that increments by 1 every game. Prevents the same inputs from producing the same output twice.

The actual random number comes from HMAC-SHA256(server_seed, "client_seed:nonce:round"). This follows the Stake standard which is kind of the industry norm at this point.

Turning bytes into floats

So HMAC-SHA256 gives you 32 bytes. But I need floats between 0 and 1 to use as random values in my games. How do you go from bytes to a float?

You take 4 bytes at a time and do this:

let float = bytes[0] as f64 / 256.0
    + bytes[1] as f64 / 65536.0
    + bytes[2] as f64 / 16777216.0
    + bytes[3] as f64 / 4294967296.0;

Each byte contributes to a more precise decimal place. The result is always in [0, 1) and never exactly 1.0. One HMAC round gives you 32 bytes, so 8 floats. If a game needs more than 8 random values you just increment the round counter and hash again.

pub fn generate_floats(server_seed: &str, client_seed: &str, nonce: u64, count: usize) -> Vec<f64> {
    let mut floats = Vec::with_capacity(count);
    let rounds_needed = (count * 4 + 31) / 32;
    let mut all_bytes = Vec::with_capacity(rounds_needed * 32);

    for round in 0..rounds_needed {
        let message = format!("{}:{}:{}", client_seed, nonce, round);
        let mut mac = HmacSha256::new_from_slice(server_seed.as_bytes())
            .expect("HMAC can take key of any size");
        mac.update(message.as_bytes());
        all_bytes.extend_from_slice(&mac.finalize().into_bytes());
    }

    for i in 0..count {
        let offset = i * 4;
        let float = all_bytes[offset] as f64 / 256.0
            + all_bytes[offset + 1] as f64 / 65536.0
            + all_bytes[offset + 2] as f64 / 16777216.0
            + all_bytes[offset + 3] as f64 / 4294967296.0;
        floats.push(float);
    }

    floats
}

Pretty simple function. But there’s a lot going on underneath that matters.

How a game actually uses this

Take mines as an example. It’s a 5x5 grid where some tiles have mines and you try to reveal safe ones. I need to randomly place, say, 5 mines on the 25-tile grid.

I generate 24 floats and use them to do a Fisher-Yates shuffle on the tile positions:

fn generate_mine_positions(server_seed: &str, client_seed: &str, nonce: u64, mines_count: usize) -> Vec<usize> {
    let floats = generate_floats(server_seed, client_seed, nonce, GRID_SIZE - 1);
    let mut positions: Vec<usize> = (0..GRID_SIZE).collect();

    for i in (1..GRID_SIZE).rev() {
        let j = (floats[GRID_SIZE - 1 - i] * (i + 1) as f64).floor() as usize;
        let j = j.min(i);
        positions.swap(i, j);
    }

    positions[..mines_count].to_vec()
}

The first N positions after the shuffle are the mine locations. Because the shuffle is deterministic given the same seeds and nonce, anyone can reproduce this exact result after the game.

The security part

Here’s what actually makes this work as a trust system.

Before any games are played, the player sees the SHA-256 hash of the server seed. They don’t see the seed itself. This is the commitment. It locks the server into a specific seed without revealing it.

The player sets their own client seed. The nonce auto-increments each game. So the player influences every outcome but can’t predict it (because they don’t know the server seed). And the server can’t manipulate outcomes after the commitment (because changing the seed would change the hash).

When the player wants to verify, they rotate their seed. This reveals the old server seed and they can:

  1. Check that SHA-256(revealed_seed) matches the hash they were shown
  2. Recompute HMAC-SHA256(revealed_seed, "their_client_seed:nonce:0")
  3. Run the same float generation and game logic
  4. Confirm the outcome matches what they experienced

The rotation endpoint refuses to reveal the seed if any game is still active. Same for changing the client seed. If a player could change their client seed mid-game they could brute force favorable outcomes, so both operations check for active games first with SELECT ... FOR UPDATE to prevent race conditions.

Testing it

I wrote tests that generate 100k floats and check they’re uniformly distributed. Determinism tests that verify the same inputs always produce the same outputs. Multi-round tests that confirm you can generate more than 8 floats correctly. Basic stuff but it matters when real money is on the line.

#[test]
fn test_deterministic() {
    let f1 = generate_floats(server_seed, client_seed, 1, 5);
    let f2 = generate_floats(server_seed, client_seed, 1, 5);
    assert_eq!(f1, f2);
}

Why this was interesting to build

Most developers never have to think about verifiable computation. In a normal app you just use rand::thread_rng() and move on. But in a project like this, rand is not enough and can be figured out.

The provably fair system is basically a lightweight commitment scheme. The server commits to a seed, the player contributes entropy, and the output is verifiable after the fact. It’s not fancy cryptography but it’s a real application of it.

© 2026 Colton Spyker