Verify a poker hand yourself — and what a 'provably fair' verifier must refuse
"Provably fair" is worth nothing if you can't run the proof. Here's how to verify one of our hands — export it, then check it in the browser or from the CLI, the same verifier behind both — and the part worth stealing: the checks that earn trust are the ones that make the verifier refuse.
The claim you shouldn't take on faith
"Provably fair" is a phrase, and a phrase proves nothing. It only counts if you can run the proof yourself and watch it either pass or fail — without trusting us, and without trusting the page that shows you the result.
So here's the whole thing, end to end: how to verify one of our hands — export the proof, check it in the browser or straight from the command line — and how to read what the verifier prints. And the part worth stealing if you ever build something like this: the checks that actually earn trust are the ones that make the verifier refuse. A verifier that can only ever say OK is decoration.
The mechanism underneath is a standard commit-reveal: before the deal, the server publishes a commitment to a secret seed; after the hand, it reveals the seed. Anyone can re-derive the exact deck from the revealed secret and confirm two things — the seed matches the commitment that was published to the table before the deal (your client shows it, and can retain it), and the deck that seed produces is byte-for-byte the deck you were dealt. Committed-before, unaltered-after.
What is fixed when: the commit at ① precedes the entropy at ② and the deal at ③; verification replays ③–⑤ from the reveal.
Get the proof, then verify it — one verifier, two doors
Verifying a commit-reveal hand runs one small Rust function, verify_hand (distinct from the separate Mental-Poker transcript verifier that backs encrypted dealing). The app.bluffking.ai/fairness web page and the command line are two doors into that one function; the replay button is how you get the proof file to feed them. The two doors are not equivalent, and the asymmetry is the point: the web page is the convenience door — it hands your record to our server, which runs the same verify_hand and returns the verdict — while the CLI is the trust-nobody door, running the identical check offline with no network at all.
1 — As a player: export the proof
After a hand, open the replay page and tap Export fairness proof. It downloads the raw hand record, verbatim, as bluffking-hand-<id8>.json (the name carries the first 8 characters of the hand id). The button only exports — you then verify that file one of the next two ways.
2 — In the browser: the convenience door
Open app.bluffking.ai/fairness, then upload or paste that JSON; our server runs the check and sends back the result. A VERIFIED verdict comes with the hand id, the recomputed deck seed, and how many hole-card seats, board cards, and client seeds were checked; a REJECTED one carries the reason instead; and a record that isn't a commit-reveal hand at all (no server_seed) gets a neutral SKIPPED, not a rejection. Convenient — but the verdict is still our server's word. (The verifier is open source and the record self-contained, so anyone could host this same check; the next door needs no host at all.)
3 — On the command line, trusting nobody's server
The same proof file goes through the CLI with no network at all:
cargo run -p mental-poker --bin pf_verify -- --hand ~/Downloads/bluffking-hand-<id8>.json
You don't even need a real hand to see it work. The repo ships a self-contained demo that deals a real engine hand and verifies it, so you can reproduce the exact output below byte-for-byte:
cargo run -p mental-poker --bin pf_demo_hand | cargo run -p mental-poker --bin pf_verify -- -
OK: deck reproduced, commit verified ✓ (provably fair)
hand_id : abababab-abab-abab-abab-abababababab
recomputed deck_seed: e83b041b1bb63da0a1a90b6aee6650566ec76f8b33dee10f348b011519ca0ab3
hole seats checked : 3
board cards checked: 5
client seeds mixed : 0 (anti-grind when >0)
Every line is a claim you can hold the software to:
| Line | What it asserts |
|---|---|
deck reproduced | The verifier reshuffled and redealt from the proof, and the reproduced cards matched the record. |
commit verified | The revealed server_seed really hashes to the seed_commit published before the deal. |
hole seats checked | How many seats' two hole cards were available to compare. Your own export typically shows 1 (your seat); folded opponents' unshown cards are not casually put in the JSON. |
board cards checked | How many community cards were compared — 0 if the hand ended pre-flop, up to 5 by the river. |
client seeds mixed | 0 means server-commit-only; >0 means the record contains client-seed entries. Why this number is load-bearing — and why it can honestly be 0 — is the honest-limits section. |
A tampered record prints REJECTED ✗ with the reason instead (exit 1); a record that isn't a commit-reveal hand prints SKIPPED (also exit 1); input that isn't a valid hand record at all is a parse error, not a verdict (exit 2). In every non-OK case the CLI exits non-zero, so you can script it.
What the verifier actually does
Three checks, in order. None of them trusts a stored result; each recomputes from scratch and compares.
- The commitment binds. It hashes the revealed
server_seedand checks it equals theseed_committhat was published before the deal. If someone swapped the seed after seeing the cards, the commitment no longer matches and it's rejected. This is the "unaltered-after" half. - The deck seed is re-derived, deterministically. It combines the server seed, every client seed (in a fixed seat order), and the hand id, through a domain-separated hash, to get the deck seed. Same inputs, same seed, every time — and mixing the hand id in means the same server seed can't reproduce the same deck across two different hands.
- The deal is re-run. It seeds the same engine shuffle the server used, deals in the same order (no burn cards), and compares each recomputed hole card and board card against the record. One mismatched card is a rejection.
The verifier rebuilds the deck with the production engine's own shuffle — the same PokerRng and Deck the server used — so there is no second shuffle that could quietly disagree with the first. The deal order it then applies is independently mirrored from the engine, locked down by the fail-closed guards in the next section, and it checks every two-card hole entry and board card the proof carries.
The part worth stealing: a verifier that can't fail isn't one
The interesting engineering isn't the happy path. It's the cases where the honest answer is "I refuse to certify this," and getting those wrong is how "provably fair" systems quietly become theater. Two we had to fix on purpose:
- A record with nothing to compare must not pass. Both the hole cards and the board are optional fields. So a record with no hole cards and an empty board would sail through all three checks above — zero comparisons, zero mismatches — and print "provably fair" while attesting nothing. The fix is a fail-closed guard: if not a single card was actually compared, reject it. "Verified" has to mean "I compared real cards and they matched," never "I found no mismatch."
- When you'd have to guess, refuse. The engine deals by seat order, and at a table with gaps (say only seats 2 and 5 are occupied) the deal position isn't the seat number. The record carries the real dealt-seat order so the verifier can map each seat back to its position. If that mapping is missing on a hand that needs it, the verifier does not guess — guessing could let a tampered hand slip through or fail an honest one. It rejects and says why.
Generalize it and it's the one rule worth carrying to any commit-reveal or "provably fair" system you build — a lottery, an RNG audit, a draw: make the verifier default-deny. Bind the commitment, re-derive deterministically, and fail closed the moment there's nothing to compare or you'd have to assume. The checks that earn a user's trust are precisely the ones that let the verifier say no.
The honest limit: this is not server-blindness
Be exact about what a green result proves, because it's easy to oversell. A standard-table proof verifying means committed-before, unaltered-after. It does not mean the server was blind to your cards — on a standard table the server sees the deck, because it runs the deal, the coach, and the bots. Commit-reveal proves the deck wasn't swapped on you; it doesn't prove nobody looked.
This is where client seeds mixed earns its place. With zero client seeds, the proof still shows the deck wasn't changed after the commitment — but it can't rule out a server that ground through many candidate seeds before committing, quietly picking a deck it liked. The moment even one human client mixes in a seed of its own after the commitment, that pre-commit steering collapses: the server had to commit before it saw the entropy that finalizes the deck. That's why the number being >0 is the anti-grind signal, and why a hand where a human seed actually got mixed in is stronger than one without. A table with other people usually clears that bar — but the guarantee tracks the number, not the headcount: the entropy window is best-effort and non-fatal, so if no eligible client seed arrived in time the hand honestly falls back to server-commit-only for that deal (client seeds mixed : 0), still fully verifiable.
Two caveats keep those claims honest. The verifier only counts the client-seed entries — it cannot authenticate who sent them — so the anti-grind argument is strongest when it is yours: if you contributed a seed, check that your exact seed appears in the record. And the exported file is written by our server, so the cryptography binds seed to commitment within the record; what pins the commitment to before the deal is your live client, which shows and can retain the seed_commit at deal time — and what pins the record to your hand is you, comparing its cards against what you saw on screen.
A stronger server-blind guarantee needs a different mechanism (encrypted, Mental-Poker-style dealing), which we cover in What does advanced encrypted dealing hide from the server?. Under ADR-101 that mode is now available on host-enabled all-human, no-AI tables (card-secrecy on that hand, not trustless-against-the-operator settlement); default tables — the ones this verifier is for — remain server-visible commit-reveal dealing. Two different questions, two different proofs.
Recap
- Don't take "provably fair" on faith — run it. Export a hand, then verify it in the browser at app.bluffking.ai/fairness or offline with
pf_verify. Both doors call the same Rust function — but the browser door is convenience (our server runs the check), and the CLI door is independence (no network, no trust in us). - What a pass proves: the revealed seed matches the pre-deal commitment, and the deck that seed reproduces is exactly the one you were dealt. Committed-before, unaltered-after.
- The reusable lesson: a verifier is only as trustworthy as what it refuses. Fail closed when there's nothing to compare, or when you'd have to guess. "Verified" must mean "compared and matched," not "no mismatch found."
- The honest limit: commit-reveal is not server-blindness. One human client seed (
client seeds mixed > 0) additionally kills pre-commit grinding — a claim you earn by finding your own contributed seed in the record; the stronger "server can't see the cards" guarantee is a separate, encrypted mechanism.