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.
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:
- Compute
HMAC-SHA256(server_seed, "client_seed:nonce:0"). This gives you 32 bytes expressed as 64 hex characters. - Take the first 8 hex characters (= 4 bytes). For
example, if the hash starts with
a1b2c3d4..., your 4 bytes area1b2c3d4. - Parse those 8 hex characters as an unsigned 32-bit
integer.
0xa1b2c3d4= 2,712,847,316. - Divide by
232(4,294,967,296) to get a float between 0 (inclusive) and 1 (exclusive). - 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:
- 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). - For each index
ifrom 24 down to 1 (the standard reverse Fisher-Yates), consume the next 4 bytes from the stream and convert them to auint32. - Compute
j = uint32 % (i + 1). This gives a uniformly distributed index between 0 andi. - Swap
tiles[i]andtiles[j]. - After the shuffle,
tiles[0]throughtiles[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.