Provably-Fair Dice
The server is authoritative over the dice — but it commits to its randomness before it sees yours, and reveals everything after the game, so anyone can recompute every roll and confirm it was neither chosen nor changed to favour a side. This page is the verification procedure in full: the guarantee, the three published values, and code you can run.
The guarantee
Section titled “The guarantee”Each roll is an HMAC keyed by a secret server seed, mixing in both players’ client seeds and the ply index. The trick is the ordering:
- Commit. At game creation the server publishes
commit = SHA-256(serverSeed). It is now locked to a server seed it cannot change without breaking the hash. - Contribute. After the commit, each side submits its own
clientSeed. Because the commit was published first, the server could not have chosen its seed to steer the rolls given yours — and because your seed is folded into every roll, neither could a player who saw the commit. - Roll. Every roll is
HMAC-SHA256(serverSeed, message)reduced to three unbiased dice. - Reveal. At game end the server reveals
serverSeedand both client seeds. Anyone can now recompute every roll and check thatSHA-256(serverSeed)equals thecommit.
Neither side can grind the dice: the server is bound by the commitment before it sees the client seeds, and no player controls the server seed.
The three published values
Section titled “The three published values”| Value | Where | When |
|---|---|---|
commit |
create response, and every Snapshot.state.commit |
game start (constant for the game) |
seed |
GameEnded.seed, and GET /games/{id} after the end |
game end (the revealed server seed, hex) |
clientSeeds |
GameEnded.clientSeeds, and GET /games/{id} |
game end ({"white": "...", "black": "..."}) |
If a seat never submitted a seed, the server folds in that seat’s public external id as the fallback, and clientSeeds shows exactly that value — so the transcript is still fully reproducible.
The roll function
Section titled “The roll function”For ply index n and the two client seeds, the message is a canonical, length-prefixed byte string (so different seed splits can never collide):
message(n) = uint32be(len(clientWhite)) ++ clientWhite ++ uint32be(len(clientBlack)) ++ clientBlack ++ int64be(n)Lengths are UTF-8 byte counts; all integers are big-endian. The three dice are drawn from the HMAC output by rejection sampling, which removes modulo bias:
- Compute
HMAC-SHA256(serverSeed, message(n) ++ int32be(block)), starting atblock = 0. - Scan the output bytes. For each byte
b(0..255): ifb < 252(the largest multiple of 6 below 256), it yields the die(b mod 6) + 1; otherwise it is rejected and skipped. - Collect three dice. If a block runs out first, increment
block, re-key, and keep scanning — so the result is always complete and unbiased.
Verify it yourself
Section titled “Verify it yourself”This dependency-free Python reproduces the server exactly. Feed it the revealed seed, the clientSeeds, and a ply index, and it returns the same three dice the server rolled.
import hashlibimport hmacimport struct
def roll(server_seed_hex: str, client_white: str, client_black: str, ply: int) -> list[int]: server_seed = bytes.fromhex(server_seed_hex) w, b = client_white.encode("utf-8"), client_black.encode("utf-8") message = ( struct.pack(">I", len(w)) + w + struct.pack(">I", len(b)) + b + struct.pack(">q", ply) # int64be )
dice: list[int] = [] block = 0 while len(dice) < 3: digest = hmac.new( server_seed, message + struct.pack(">i", block), hashlib.sha256 ).digest() for byte in digest: if len(dice) == 3: break if byte < 252: # reject to avoid modulo bias dice.append(byte % 6 + 1) block += 1 return dice
def commit_matches(server_seed_hex: str, commit_hex: str) -> bool: return hashlib.sha256(bytes.fromhex(server_seed_hex)).hexdigest() == commit_hexThe full verification of a finished game is then:
- Confirm
commit_matches(seed, commit)— the revealed seed is the one committed at the start. - For each roll in the game (in order,
ply = 0, 1, 2, …), callroll(seed, clientWhite, clientBlack, ply)and check it equals thediceyou saw on thatDiceRolledevent.
ply is the monotonic roll counter the server folds in; reproduce the rolls in the order they occurred. If every roll matches and the commit checks out, the dice were provably fixed before either seed was known.
The mirror-pair exception (withheld reveal)
Section titled “The mirror-pair exception (withheld reveal)”There is one case where seed and clientSeeds come back null even though the game has ended:
{ "GameEnded": { "over": { "result": { "Draw": {} }, "termination": "Aborted" }, "seed": null, "clientSeeds": null } }This happens only for a server-paired ladder rematch — two games that share one seed and client-seed pair with the colours swapped, played for common-random-numbers scoring on the rating ladder. Revealing on whichever game ends first would hand away the still-running partner’s future rolls, so the reveal is withheld until both games of the pair have concluded.
To verify such a game, poll GET /games/{id} again after both have ended: the seed and clientSeeds become available there even though the live GameEnded event showed null. A normal (non-ladder) game always reveals immediately.