BJ21

Offline Round Verify

Paste a finished round’s audit JSON and this page re-derives every card that was dealt on your own machine. If they match, the deck was fixed before anyone acted — and you didn’t have to trust the game server to know it.

  1. 1
    Finish a round. In the game, click 📋 Audit JSON — enabled once the round is over and the server seed is revealed.
  2. 2
    Paste it below. Or open a shared ?audit=… link, which fills in and runs everything for you.
  3. 3
    Press Verify. Green means every card came from seeds locked in before the round. Red checks explain themselves.

Round data

Nothing you paste leaves this page — there is no network code in it at all.

dealtCards and pinned power the strongest checks. It still runs without them — the report just marks those checks as unproven rather than passed.

Enter seeds by hand. Box number is required for each seat — it feeds into the lane key. Leaving a commitment empty makes that box’s check fail, by design: a seed always matches a hash you compute from it, so only a pre-published commitment proves anything.

⌘/Ctrl + Enter

Result

Every check says what it proves. A ❌ always comes with a note telling you whether it is a gap in your input — you had no pinned copy, say — or a genuine mismatch. Only a genuine mismatch indicts the round.

No round checked yet — paste audit JSON above and press Verify round.

Lanes — first 12 cards per stream

How it works

What this page does and doesn’t provestart here

✓ Proven here

The server seed is linked to the published chain, every client seed was locked in advance, and every dealt card can be reproduced in order from its lane. Your box’s cards depend only on your seed.

✗ Not proven here

Whether an opponent is human or a bot. It doesn’t change your outcome: a bot cannot affect your seed, your cards, or your card flow. What the house can still do is cancel a whole round — visible to everyone through the cancellation rate and burned seeds.

Everything runs locally. Save this file and open it with no internet at all, or host your own copy on a domain you control — it will give the same answer.

Glossarythe words used on this page
Seed
A random 64-character hex string. The server has one per round; every box (seat) has its own. Cards are computed from these — and nothing else.
Commitment
The SHA-256 hash of a seed, published before the round. Hashing is one-way, so the hash proves the seed existed in advance without revealing it.
Hash chain & chain tip
The server’s seeds are pre-built as a chain: each seed is the hash of the next one. Only the tip is published at startup, and every revealed seed must hash to the previous link — so the sequence cannot be reordered or replaced mid-game.
Pinned copy
A copy of the commitment or chain tip your browser saved before the round. This page checks against that copy — not against whatever a server claims now. If you never pinned one, that check is skipped as unproven (amber), not reported as cheating.
Lane
An independent card stream. Each box’s cards come only from its own seed; the dealer lane comes from all revealed seeds. Nobody else’s choices can shift your lane.
Nonce
The round number, mixed into the formula so the same seeds can never produce the same cards twice.
dealtCards
The log of which card came from which lane, in order. With it, the verifier reproduces every card you actually saw — the strongest check on this page.
How cards are dealtlanes · dealer · Fisher–Yates

There is no shared shoe. Each box and the dealer own an independent 6-deck stream (a lane). Your hits cannot move anyone else’s cards; a house bot cannot steer the dealer by playing another seat.

Step A — Lane keys (HMAC-SHA512)

A lane key is a 128-hex (512-bit) value. It is the input to the shuffle — not a card yet. The key argument of HMAC is the server seed; message is the UTF-8 string below.

// Box b (player seat) — depends only on THAT box’s client seed seatKey_b = HMAC-SHA512( key = serverSeed, msg = "seat:" + b + ":" + clientSeed_b + ":" + nonce ) // Dealer — every revealed client seed, sorted lexicographically, comma-joined // (one player → just that seed; multi → sort then join with ",") dealerKey = HMAC-SHA512( key = serverSeed, msg = "dealer:" + sort(clientSeeds).join(",") + ":" + nonce ) // Example (nonce=10, single client seed): // msg = "dealer:0d44fcc8…d79695:10" // dealerKey = 1ecc79bc…48fe1 ← this IS the dealer lane key (aka combinedSeed) // Cards are NOT read from the hex digits of dealerKey. // dealerKey only seeds the Fisher–Yates below.

Step B — Build a fresh 6-deck shoe

Order is fixed and identical on every machine: 6 × (suits ♠♥♦♣) × (ranks A,2…10,J,Q,K) → 312 cards. Inner loops: for each deck, for each suit, for each rank.

deck = [] for d in 0..5: for suit in [♠, ♥, ♦, ♣]: for rank in [A,2,3,4,5,6,7,8,9,10,J,Q,K]: deck.push({ rank, suit }) // length = 312 (NUM_DECKS * 4 * 13)

Step C — Partial Fisher–Yates + rejection sampling

Only the first count positions are shuffled (prefix-stable: drawing 24 cards yields the same first 5 as drawing 5). Randomness comes from HMAC-SHA256(laneKey, "fy:" + counter), not from the lane key hex itself. The counter starts at 1 and increments on every sample attempt, including rejections.

function drawCards(laneKeyHex, count): deck = buildDeck() // 312 cards n = 312 drawCounter = 0 next = () => (++drawCounter) for i in 0 .. count-1: range = n - i // remaining unshuffled tail length // unbiased j in [i, n): offset = unbiasedIndex(laneKeyHex, range, next) // ∈ [0, range) j = i + offset swap(deck[i], deck[j]) return deck[0 .. count-1] function unbiasedIndex(laneKey, range, nextCounter): MOD = 2^32 // 0x100000000 limit = floor(MOD / range) * range // largest multiple of range < 2^32 loop forever: c = nextCounter() // 1, 2, 3, … buf = HMAC-SHA256(laneKey, "fy:" + c) // 32 bytes j = uint32_be(buf[0..3]) // first 4 bytes, big-endian if j < limit: return j % range // uniform in [0, range) // else reject and try next counter (avoids modulo bias)

Step D — Who gets which card?

The initial deal and every hit pull the next card from the relevant lane’s precomputed stream (cursor 0, 1, 2, …). The dealer always uses lane 0; box b uses lane b. The audit log dealtCards[] records { lane, cursor, to, box, rank, suit } so you can replay every card.

// Per round, after all reveals: lanes[0] = drawCards(dealerKey, 24) // dealer stream lanes[b] = drawCards(seatKey_b, 24) // each seated box // Deal order example (1 player at box 4): // player card 1 ← lanes[4][0] // dealer up ← lanes[0][0] // player card 2 ← lanes[4][1] // dealer hole ← lanes[0][1] // player hit ← lanes[4][2] // dealer hit ← lanes[0][2] // …

Worked example — dealer lane, nonce 10

serverSeed = 437b92c4cd85f808f60173f692bf5c5d2e71657c9ff5c4815ce2ee0a52f70be8 // SHA-256(serverSeed) must equal the published commitment: // 34285dc8d9e9f16ac44139a33d97829eb4ba122b2c5adc83c977c3d075fdb897 ✓ clientSeed = 0d44fcc82359745e2c4afc94631526c38098858995f907665a00a92193d79695 // SHA-256(clientSeed) = clientCommit (the commitment is the hash, not the lane key): // 8bdd0348519f2e000400fca5bf1e23a85faedfea3504e4d486228074a70927ca msg = "dealer:" + clientSeed + ":10" dealerKey = HMAC-SHA512(serverSeed, msg) = 1ecc79bcd4be1a62a4aec29a6da5c556b91245381fbdcc76ab2f8fc855971243 428d55e4e53a0598b5adc64390da5245480e945c724279051e8e7ef0fb848fe1 // Then Fisher–Yates with that dealerKey yields the dealer lane, e.g.: // cursor 0 → A♦ (up card) // cursor 1 → 3♣ (hole) // cursor 2 → 7♠ (first hit, if needed) // …

Common pitfall: the HMAC-SHA512 output (dealerKey) is not “the cards in hex”. You have to run Step C on it. And don’t confuse clientCommit = SHA-256(clientSeed) with the lane key — the commit only proves the seed was locked in before the reveal.

Verification formulathe five checks, condensed
seatKey_b = HMAC-SHA512(serverSeed, "seat:" + b + ":" + clientSeed_b + ":" + nonce) dealerKey = HMAC-SHA512(serverSeed, "dealer:" + sort(clientSeeds).join(",") + ":" + nonce) cards = partial Fisher–Yates(6 decks), HMAC-SHA256(laneKey, "fy:N") + rejection sampling Verify: 1) SHA-256(serverSeed) === serverCommitment 2) SHA-256(serverSeed) === previous seed in chain (or published tip if none) 3) commitment matches the copy pinned BEFORE the round 4) SHA-256(clientSeed_b) === clientCommitment_b (FAIL if no commitment) 5) every dealt card matches its lane’s sequential reproduction
Audit JSON schemav2 is current — v1 still accepted

Current games export version 2: it adds pinned, dealtCards and per-box seats[]. The in-game Copy proof button exports kind: "seat-lane-proof" (a single seat); that shape is accepted too, as is version 1’s clients[].

{ "version": 1, "game": "pf-blackjack", "nonce": 7, "serverSeed": "<64 hex>", "serverCommitment": "<64 hex SHA-256(serverSeed)>", "clients": [ { "box": 1, "seed": "<64 hex plaintext>", "commitment": "<64 hex SHA-256(seed)>" } ] } // Old format also accepted: "clients":[…] or "clientSeeds":[…] // dealtCards not required / ignored — the shoe is generated here

The shoe formula takes the plaintext client seed, not its hash — the hash is only for commitment verification. Client seed order does not matter: the verifier applies sort(), exactly as the server does.

Advanced tool

Fisher–Yates — derive cards from a lane keyseeds → cards, no audit JSON needed

Paste a lane key (a 128-hex HMAC-SHA512 output) and run the same partial Fisher–Yates the server uses. No commitments, no audit JSON — just seeds → cards. Or compute the lane key from seeds first, then draw. After a full verify, the → FY tool button on any lane fills the key in automatically.

This is dealerKey / seatKey / the audit field combinedSeed — not the raw client seed, and not SHA-256(clientSeed).

HMAC-SHA512(serverSeed, "dealer:" + sort(seeds).join(",") + ":" + nonce)

HMAC-SHA512(serverSeed, "seat:" + box + ":" + clientSeed + ":" + nonce)

Lane key used

Derived stream