Provably-fair dice & mines: mapping a SHA-256 hash to a game outcome

In earlier posts I walked through how provably-fair systems work for crash games and Plinko. The core commitment scheme is always the same — server seed, client seed, nonce, HMAC-SHA256 — but the part that differs between games is how you turn the resulting hash into an actual outcome. Dice needs a single number between 0.00 and 99.99. Mines needs to scatter M bombs across a 5 × 5 grid. Both start from the same 64-character hex string, and the mapping from hash to result is surprisingly simple once you see the bytes.

When I implemented the fairness layer for ZimBet, getting the commitment scheme right was the easy part. The fiddly bit was making sure the outcome-derivation code was identical between the server and the public verifier — one off-by-one in a byte offset and every result diverges. This post is the reference I wish I'd had: the exact byte extraction for dice, the full Fisher-Yates shuffle for mines, and Python code you can paste into a terminal to verify any round yourself.

The shared foundation: seeds → HMAC stream

Every provably-fair game on every major platform (Stake, Primedice, Roobet, Blaze — and ZimBet) starts with the same three inputs:

  • Server seed — a random string generated by the house, committed in advance by publishing its SHA-256 hash. You see the hash before you bet; the raw seed is revealed only when you rotate to a new seed pair.
  • Client seed — a string you set yourself (or accept as a default). Because it's yours, the server can't pre-compute the combined output.
  • Nonce — an integer that increments with every bet, so each wager within a seed pair produces a unique hash.

The game concatenates the client seed and nonce into a single message — typically client_seed:nonce:cursor — and runs HMAC-SHA256(server_seed, message). The result is 32 bytes (64 hex characters) of deterministic, uniformly distributed data. The cursor starts at 0; if a game needs more than 32 bytes, it increments the cursor and calls HMAC again, giving an effectively unlimited stream.

HMAC-SHA256 → Game Outcome Pipeline Server Seed (secret key) Client Seed (your string) Nonce (bet counter) HMAC-SHA256( server_seed, client:nonce:cursor ) a1 b2 c3 d4 e5 f6 ... (32 bytes = 64 hex chars) 🎲 Dice Take first 4 bytes → uint32 ÷ 2³² → float → × 10001 → 0.00–99.99 💣 Mines Consume 4 bytes per swap → Fisher-Yates shuffle [0..24] → first M = mines cursor++ if > 32 bytes needed (mines often does)
Both games start from the same HMAC-SHA256 output — the difference is how they read the bytes.

Dice: 4 bytes → a number between 0.00 and 99.99

Dice is the simplest provably-fair game to verify because it only needs four bytes from the HMAC stream. Here's the exact procedure, which is the standard used by Stake, Primedice and most crypto-dice platforms:

  1. Compute HMAC-SHA256(server_seed, "client_seed:nonce:0"). This gives you 32 bytes expressed as 64 hex characters.
  2. Take the first 8 hex characters (= 4 bytes). For example, if the hash starts with a1b2c3d4..., your 4 bytes are a1b2c3d4.
  3. Parse those 8 hex characters as an unsigned 32-bit integer. 0xa1b2c3d4 = 2,712,847,316.
  4. Divide by 232 (4,294,967,296) to get a float between 0 (inclusive) and 1 (exclusive).
  5. Multiply by 10,001 and floor it, then divide by 100. The result is your roll: a number from 0.00 to 99.99 with two-decimal precision.

The floor(float × 10001) / 100 formula produces exactly 10,001 distinct outcomes (0.00, 0.01, … 99.99, 100.00 — but 100.00 is effectively unreachable because the float never hits exactly 1). Each outcome is equally likely within the resolution of a 32-bit integer, which is more than enough entropy for a 10,000-slot wheel.

Mines: a Fisher-Yates shuffle on a 5 × 5 grid

Mines is more interesting. The game has a 5 × 5 grid (25 tiles), and you choose how many mines to place — say 5. The question is: given an HMAC hash, how do you deterministically place those 5 mines so that every arrangement is equally likely?

The answer is a Fisher-Yates shuffle. You start with an array [0, 1, 2, … 24] representing the 25 tile positions, then shuffle it using random values drawn from the HMAC stream. After the shuffle, the first M elements are the mine positions.

Here's the step-by-step:

  1. Generate the HMAC stream: HMAC-SHA256(server_seed, "client_seed:nonce:0"). You'll consume 4 bytes per shuffle step — with 25 tiles that's up to 100 bytes, so you'll need multiple cursor rounds (cursor 0, 1, 2, 3).
  2. For each index i from 24 down to 1 (the standard reverse Fisher-Yates), consume the next 4 bytes from the stream and convert them to a uint32.
  3. Compute j = uint32 % (i + 1). This gives a uniformly distributed index between 0 and i.
  4. Swap tiles[i] and tiles[j].
  5. After the shuffle, tiles[0] through tiles[M-1] are the mine positions.

The modulo bias is negligible here. A 32-bit integer divided into at most 25 buckets introduces a bias on the order of 25 / 232 ≈ 0.000000006 — effectively zero. Some implementations use rejection sampling to eliminate it entirely, but for a 25-tile grid it genuinely doesn't matter.

The cursor trick: when 32 bytes aren't enough

A single HMAC-SHA256 call produces 32 bytes. Dice only needs 4, so one call is more than enough. But a full Fisher-Yates shuffle of 25 tiles needs 24 swaps × 4 bytes = 96 bytes — three full HMAC blocks.

The solution is simple: append a cursor counter to the HMAC message. The first block uses client_seed:nonce:0, the second uses client_seed:nonce:1, and so on. Each call produces a fresh 32 bytes, so you can generate as many deterministic random bytes as you need. The cursor is part of the verifiable input — anyone with the three seeds can reconstruct the entire stream.

Verify it yourself: Python code

Here's a minimal Python script that reproduces both dice and mines results. Paste in your own seeds and nonce from any platform that uses the standard algorithm:

import hmac, hashlib

def hmac_stream(server_seed, client_seed, nonce):
    """Yield 4-byte chunks from the HMAC-SHA256 stream."""
    cursor = 0
    while True:
        msg = f"{client_seed}:{nonce}:{cursor}"
        h = hmac.new(server_seed.encode(), msg.encode(),
                     hashlib.sha256).hexdigest()
        for i in range(0, len(h), 8):  # 8 hex chars = 4 bytes
            yield int(h[i:i+8], 16)
        cursor += 1

def dice_roll(server_seed, client_seed, nonce):
    gen = hmac_stream(server_seed, client_seed, nonce)
    uint32 = next(gen)
    return round((uint32 / 4_294_967_296) * 10001) // 1 / 100

def mines_positions(server_seed, client_seed, nonce, num_mines):
    gen = hmac_stream(server_seed, client_seed, nonce)
    tiles = list(range(25))
    for i in range(24, 0, -1):
        j = next(gen) % (i + 1)
        tiles[i], tiles[j] = tiles[j], tiles[i]
    return sorted(tiles[:num_mines])

# Example — replace with your real seeds
server = "your_server_seed_here"
client = "your_client_seed_here"
nonce  = 0

print(f"Dice roll:      {dice_roll(server, client, nonce)}")
print(f"Mines (5 mines): {mines_positions(server, client, nonce, 5)}")

If your result matches what the casino showed you, the round was fair. If it doesn't, either you entered the wrong seed/nonce or the platform cheated — and now you have the proof.

What can't be verified

Provably-fair verification proves one thing: the outcome was determined before you bet and was not altered afterwards. It does not prove:

  • That the house edge is what the platform claims — the edge lives in the payout table, not the RNG.
  • That the server seed was generated with sufficient entropy. If the server uses a weak random source, outcomes could be predictable — but the commitment scheme prevents the house from selectively choosing outcomes against you.
  • That your client seed wasn't replaced. Always set your own client seed — don't accept the default silently — and confirm it displays in the UI before you play.

These are the same caveats I mentioned in the provably-fair vs RNG certification comparison. The cryptographic guarantee is real and strong, but it covers outcome integrity, not business honesty.

Why the algorithm is always the same

One thing that surprised me when I implemented dice and mines for ZimBet was how little custom logic each game needs. The HMAC stream is identical — it's a generic pseudorandom byte generator seeded by the three inputs. The only game-specific part is the mapping:

  • Dice: 4 bytes → 1 float → 1 roll number.
  • Mines: 96 bytes → Fisher-Yates shuffle → first M positions.
  • Crash: 4+ bytes → float → multiplier via max(1, floor(232 / (float + 1)) / 100) (see the Aviator post).
  • Plinko: 1 bit per peg (see the Plinko post).

Once you've built the stream generator and the commitment scheme, adding a new game is just writing one small function that reads bytes and returns a result. That's why every major provably-fair platform uses essentially the same architecture — it's the natural, minimal design.

The takeaway

Provably-fair dice and mines both rest on one clean idea: deterministic randomness from HMAC-SHA256, committed before you bet. Dice reads 4 bytes and scales to a number. Mines reads ~96 bytes and shuffles a grid. The maths is simple enough that anyone with a Python terminal can verify any round in seconds — and that's exactly the point.

Related
→ Can you predict a provably-fair crash game? How Aviator works → How Plinko's provably-fair RNG works (and how to verify a drop) → Provably-fair vs RNG certification: what actually makes a casino game fair → ZimBet — provably-fair betting platform
How does a provably-fair dice game work?

The server commits to a secret seed by showing you its SHA-256 hash before you bet. Your client seed and a nonce are combined with the server seed to produce an HMAC-SHA256 hash. The first 4 bytes are converted to a 32-bit integer, divided by 2³² to get a float between 0 and 1, then scaled to 0.00–99.99. After you rotate seeds, the raw server seed is revealed so you can recompute every past roll.

How are mine positions determined in a provably-fair mines game?

The same HMAC-SHA256 hash is generated from server seed, client seed and nonce. The hash bytes seed a Fisher-Yates shuffle of all 25 tile indices (0–24), consuming 4 bytes per swap. The first M tiles in the shuffled array become the mine positions. The result is deterministic and verifiable once the server seed is revealed.

Can you verify provably-fair dice and mines results yourself?

Yes. Once the server seed is revealed (after you rotate to a new seed pair), you have all three inputs. Run the HMAC-SHA256 computation yourself in Python, JavaScript, or even a browser console. If your result matches what the casino showed, the round was fair. If it doesn't match, either you have the wrong inputs or the game was tampered with.

What happens if the HMAC stream runs out of bytes?

A single HMAC-SHA256 call produces 32 bytes. When the game needs more (mines uses ~96 bytes for a full shuffle), it increments a cursor counter appended to the input — HMAC(server_seed, client_seed:nonce:1), then :2, and so on. Each call produces a fresh 32-byte block, giving an effectively unlimited deterministic stream.