
Blackjack
Independent verification, backed by open-source code.
audit and dataset.
- Live Bets Verified
- 6,000
- Parity Rate
- 100%
- Simulated Rounds
- 10M
- Proven RTP
- 99.4296%
Verification pipeline
Real bets. Independently reproduced.
Placed anonymously at Duel.com. We rebuilt the game logic from scratch and matched all 6,000 recorded outcomes.
hit → stand
Recorded hand · Cards reproduced.
- Casino outcome
- Win · 19
- Our reproduction
- 6 / 6 cards match
- Recorded payout
- $0.0200
- Seed Hash IntegrityPass
- Commitment LinkagePass
- Hash ConsistencyPass
- Nonce AuditPass
- Outcome RecomputationPass
- Client Seed InfluencePass
- Payout MathPass
- Multiplier ProvenancePass
- Bet-Size InvariancePass
- House Edge AuditPass
- Config CompletenessPass
- Epoch SizePass
- Phase LabelsPass
- Dataset HashPass
- Phase D — Client SeedPass
- Anti-CircularityPass
- Simulation Pass 1 (Fisher’s)Pass
- Cherry-Pick DetectionPass
- Dealer Rule CompliancePass
- Blackjack 3:2 PayoutPass
- Double PayoutPass
- Split Cursor OrderingPass
- Split RulesPass
- Split Payout IndependencePass
- Insurance PromptPass
- PP + 21+3 PayoutsPass
- Side Bet IndependencePass
- Available Actions SetPass
- Infinite-Deck ConfirmationPass
- Outcome DistributionPass
- Side Bet Stake EqualityPass
- Initial Deal StructurePass
- Stake Bracket BoundsPass
The repo is the credential. You don't have to trust us — every finding ships as code. Run npm test to re-run the full audit: 33 verification steps, the 10M-round simulation (10 streams Fisher-combined + Pass 2 cherry-pick detection), the independent infinite-deck EV solver, and 6,000 live bets re-verified card-by-card.
This audit independently validates the Blackjack game operated by Duel.com across five domains: deterministic outcome generation, entropy integrity, live-to-verifier parity, RTP mathematical accuracy, and fairness integrity testing. We placed 6,000 real bets across 120 seed pairs and independently verified every visible card — initial deal, hits, doubles, splits, and dealer play-out — using our own implementation of the algorithm.
Audit Verdict
| Check | Result | Evidence |
|---|---|---|
| Overall Status | Pass | |
| RTP Verified | Pass | Main bet: 99.4296% optimal-play RTP from independent recursive infinite-deck EV solver · cross-validated against Wizard of Odds for Duel's exact rules (S17, DAS, no surrender, no re-split) · house edge 0.5704% · 99.4670% simulated (10M rounds) · Side bets: Perfect Pairs and 21+3 both 100% RTP |
| Live ↔ Verifier Parity | Pass | 100% — 6,000 / 6,000 bets · 33,194 card cursors verified across the deal, hits, doubles, and splits |
| Commit-Reveal System | Pass | SHA-256 verified across 120 seed pairs — commitment intact across all rotations |
| Client Seed | Pass | Player-controlled + customizable — server commits before client seed is known |
| RNG Analysis | Pass | HMAC-SHA256 single-card draw — bias-free rejection sampling, infinite-deck independent draws at 1/52 per card, no hidden inputs |
| Payout Logic | Pass | All 6,000 main payouts reconciled (win 2×, blackjack 2.5×, push 1×, doubled 4×) plus 5,800 Perfect Pairs + 5,800 21+3 side-bet payouts · 0 mismatches |
| Anti-Circularity | Pass | Optimal-play RTP (99.4296%) computed from rules + infinite-deck card probabilities alone — no casino input · Perfect Pairs EV = 0 by algebraic identity, 21+3 EV ≈ 0 by table balance |
| Fairness Integrity | Pass | 17 fairness integrity tests — 16 pass · 1 N/A (includes 2 Blackjack-specific multi-step state tests) |
| Determinism | Pass | Full reproducibility confirmed — every visible card across 6,000 hands recomputable from the recorded inputs |
Overall StatusPass
RTP VerifiedPass
Live ↔ Verifier ParityPass
Commit-Reveal SystemPass
Client SeedPass
RNG AnalysisPass
Payout LogicPass
Anti-CircularityPass
Fairness IntegrityPass
DeterminismPass
What Was Audited
✓ Audited
- The RNG algorithm is deterministic and verifiable
- Each round's server seed is cryptographically committed via SHA-256 before the round opens for betting
- Client seed is browser-generated and players can customize it
- Nonces increment correctly within each seed pair and never repeat
- Each visible card is computed via HMAC-SHA256 single-card draw with bias-free rejection sampling
- The card stream — initial deal, hits, doubles, splits, dealer play-out — is reproducible from (serverSeed, clientSeed, nonce, cursor)
- Dealer rule compliance: stand on soft 17 (S17), peek on Ace/10 upcards, no surrender, no re-split
- Optimal-play RTP is 99.4296% (independent recursive infinite-deck EV solver, cross-validated against Wizard of Odds)
- Bet amount does not influence the card stream — verified across $0.01 and $10 stakes
- Players can independently verify every bet — every visible card and every payout
What Audit Excludes
- Infrastructure or server security
- Wallet, payments, or operational systems outside game logic
- Rakeback layer — 99.4296% optimal-play RTP is the certified figure; rakeback is operator-side
- Cross-account sampling
- Max win cap enforcement — not embedded in game logic
What Audit Covers
| Area | Description |
|---|---|
| Commit-Reveal System | SHA-256 server seed hashing, pre-bet commitment, reveal on rotation |
| Client Seed Origin | Player-controlled seed, supplied before each epoch — server commits before the seed is known |
| Seed Handling | Client seed control, nonce lifecycle, seed pair rotation |
| RNG Analysis | HMAC-SHA256 single-card draw, infinite-deck independent draws, rejection sampling, bias analysis |
| Game Flow | Initial deal (cursors 0–3), player actions (cursor 4+), dealer play-out — every visible card verified |
| Payout Logic | Main payouts (win, blackjack 3:2, push, doubled-hand 4×), Perfect Pairs + 21+3 side bets, bet-size invariance (Phase E $10) |
| Live Parity | Independent card-by-card recomputation vs live game results — 6,000 / 6,000 hands, 33,194 cursors |
| RTP Validation | Anti-circularity (independent EV solver), simulated RTP (10M rounds, 10 streams Fisher-combined), Wizard of Odds cross-check, cherry-pick detection (Pass 2) |
| Hand Resolution | Dealer S17 compliance, blackjack 3:2, double payout, split rules (DAS, no re-split, ace one-card), insurance prompt condition |
| Side Bet Coverage | Perfect Pairs + 21+3 across 5,800 active bets each — multiplier provenance, deal-time invariance, stake equality |
| Fairness Integrity | Standard integrity matrix — tests across commit-reveal, determinism, payout, isolation, and parameter enforcement |
What Audit Guarantees
- Outcomes are deterministic and reproducible from the recorded inputs
- Live game results match independent recomputation for the verified sample (6,000 / 6,000 hands; 33,194 card cursors)
- Every visible card is verified — initial deal, every hit, every double card, every split sub-hand card, every dealer draw
- Optimal-play RTP is proven analytically: independent recursive infinite-deck EV solver returns 99.4296%; cross-validated against Wizard of Odds for Duel's exact rule set (S17, DAS, no surrender, no re-split), Δ ≈ 0
- Client seed is a genuine, independent input that materially influences results (99% of sampled bets diverge under a wrong client seed; analytical baseline 51/52 ≈ 98.08% at cursor 0)
- The house edge is 0.5704% — derived from the EV solver, not from any casino-supplied figure
- Stake does not enter the card-generation algorithm
References
Blackjack — Game Rules6 sections▶
Blackjack is a single-hand game against the dealer — you try to get closer to 21 without going over. Choose from hit, stand, double, or split. The dealer stands on every 17 and blackjack pays 3:2. The deck is treated as infinite, so cards are drawn independently and identical-rank cards can appear in the same hand. Two optional side bets — Perfect Pairs and 21+3 — resolve on the deal.
How to Play
1. Place your bet — Choose your stake on the main hand. You may also place independent stakes on Perfect Pairs and 21+3 side bets.
2. Receive the deal — You receive two face-up cards (cursors 0, 2). The dealer receives one face-up upcard (cursor 1) and one face-down hole card (cursor 3). Side bets resolve immediately.
3. Decide your action — Choose hit (take another card), stand (keep your hand), double (take exactly one more card and double your stake), or split (only if your two cards are the same rank — separate them into two hands).
4. Play out the dealer — Once you stand or bust, the dealer reveals their hole card and draws additional cards until they reach 17 or higher (or bust above 21).
5. Outcome — Win pays 1:1 on the main bet (return = 2× stake). A natural blackjack (Ace + 10-value on the deal) pays 3:2 (return = 2.5× stake) unless the dealer also has blackjack (push, return = 1×). A doubled win returns 4× stake; a doubled push returns 2× stake; a loss or bust returns 0.
Win Conditions
The win condition compares the final hand totals after the dealer plays out.
| Outcome | Condition | Return on $1 main stake |
|---|---|---|
| Natural blackjack | Player has Ace + 10-value on the deal; dealer does not | $2.50 (3:2 payout) |
| Win (regular) | Player total ≤ 21 and beats dealer total | $2.00 (1:1) |
| Doubled win | Player doubled, hand ≤ 21, beats dealer | $4.00 |
| Push | Player and dealer have the same final total | $1.00 (stake returned) |
| Doubled push | Player doubled, ties dealer | $2.00 (doubled stake returned) |
| Loss / bust | Player total > 21 OR dealer beats player | $0.00 |
Risk vs Reward
Blackjack's RTP is fixed by the rule set, not by a tunable house-edge knob.
- Optimal-play RTP is 99.4296% — verified by independent recursive infinite-deck EV solver enumerating all 1,000 (P1, P2, dealer-up) initial triples × dealer S17 distribution × max-EV player action
- House edge is 0.5704% — the operative number for long-run play under perfect basic strategy
- Decisions matter — sub-optimal play (e.g., hitting hard 17, splitting 10s) lowers the achievable RTP; the 99.4296% figure is the ceiling under perfect basic strategy
- Stake-invariant —
getCard(serverSeed, clientSeed, nonce, cursor)has no wager parameter; the same seed pair produces the same cards at $0.01 or $10
Parameters
| Parameter | Value | Notes |
|---|---|---|
| Deck Model | Infinite (with replacement) | Each card drawn independently at 1/52; identical-rank cards can appear within the same hand |
| Dealer Rule | Stand on all 17 (S17) | Including soft 17 — verified across 75 in-range played-out hands (Step 19) |
| Blackjack Payout | 3:2 (return = 2.5× stake) | 260 / 260 naturals verified (Step 20) |
| Doubling | Available on any two-card hand; DAS allowed | 1,368 doubled hands across 1,291 bets reconciled (Step 21) |
| Splitting | Same-rank pairs only; one card on split aces; no re-splitting | 269 split bets across 33,194 cursors verified (Steps 22-24) |
| Surrender | Not available | Confirmed via available-actions audit (Step 28) |
| Insurance | Offered only on dealer Ace upcard; pays 2:1 | Step 25: insurance prompt condition verified |
| Side Bets | Perfect Pairs + 21+3 (independent, deal-time only) | 5,800 active PP bets + 5,800 active 21+3 bets verified (Steps 26-27) |
| House Edge | 0.5704% | Derived from optimal-play EV solver — not an API-reported value |
| RNG Algorithm | HMAC-SHA256 single-card draw | Per-cursor independent draws with bias-free rejection sampling at MAX_FAIR = 4,294,967,248 |
Seed Formats
Every Blackjack bet uses three cryptographic inputs to generate the card stream.
| Seed Type | Format | Example | Purpose |
|---|---|---|---|
| Server Seed | 64-char hex (32 bytes) | b6f2cbcd411eedbd… | Casino-provided randomness |
| Client Seed | Alphanumeric string | pf_Naa0pBEOuWmz6 | Player-contributed entropy |
| Nonce | Integer ≥ 0 | 1 | Per-bet counter within the active seed pair |
serverSeedHashed (SHA-256 of the raw hex bytes) before play and revealed only on seed rotation. The HMAC key is the hex-decoded bytes of the server seed, not its UTF-8 text. Cursors 0..49 within a single bet use the same (serverSeed, clientSeed, nonce) triple, with the cursor index appended to the HMAC message: clientSeed:nonce:cursor.Cursor Map & Payout Math
Every visible card is drawn from a specific cursor position. The cursor is appended to the HMAC message, so card N at cursor C is fully determined by (serverSeed, clientSeed, nonce, C). The first four cursors form the initial deal; cursors 4 onward are consumed in order as actions are taken.
| Cursor | Role | Notes |
|---|---|---|
| 0 | Player card 1 | Used for Perfect Pairs and 21+3 classification |
| 1 | Dealer upcard | Determines insurance prompt eligibility (Ace upcard) |
| 2 | Player card 2 | Used for Perfect Pairs (with cursor 0); not a 21+3 input |
| 3 | Dealer hole card | Revealed at play-out; the dealer hole card is not a 21+3 input (21+3 uses the upcard, cursor 1) (with cursors 0, 2) |
| 4 onward | Action cards | Hit cards, double cards, split sub-hand cards, dealer additional draws — consumed in order |
amount_won is the total return (stake-inclusive: e.g., a $0.01 win returns $0.02). For a doubled hand: win returns 4× original stake, push returns 2×, loss returns 0. Side bets are independent of the main outcome — a player can win the main bet and lose both side bets, or vice versa.Why Provably Fair Matters▶
Traditional online casinos require players to trust that games are fair. Provably fair systems eliminate this trust requirement by allowing players to mathematically verify that outcomes were not manipulated. In a Provably Fair system:
- The casino commits to the inputs that determine a result before the player bets
- A source of randomness exists that the casino cannot predict or control
- Anyone can verify the outcome after the fact
High-Level Overview8 sections▶
This section walks through how a Blackjack bet moves through the system end-to-end — from the server seed commitment, through the deal and player actions, to the post-rotation verification step that closes the loop.
Checklist Reference
Commit-Reveal
| Check | What it proves |
|---|---|
| Server seed committed before play | Casino cannot change outcome post-bet |
| SHA-256 of revealed seed matches committed hash | Revealed seed is the one that was actually committed |
| Hash identical across all bets in an epoch | All bets within a seed pair share the same commitment |
Seed System
| Check | What it proves |
|---|---|
| Nonces are sequential within an epoch | No nonce skipping or reuse |
| Single client seed per epoch | Client seed cannot be silently rotated mid-epoch |
| Next-seed pre-committed before rotation | Future commitment chain is locked in before reveal |
RNG & Parity
| Check | What it proves |
|---|---|
| Every visible card recomputes from seeds + nonce + cursor | Card stream is deterministic and verifiable |
| Independent verifier matches live API for every cursor | No hidden inputs to the RNG |
| Card sequence invariant across player actions | Cards fixed at deal time, not rewritten per action |
Payout & RTP
| Check | What it proves |
|---|---|
| Main payouts match rule set: win 2×, BJ 2.5×, doubled-win 4×, push 1× | Payout arithmetic matches published rules |
| Side-bet payouts match published multiplier table | Perfect Pairs + 21+3 returns are exact to API precision |
| Optimal-play RTP = 99.4296% from independent solver | RTP is mathematical, not empirical — cannot be silently inflated |
Integrity
| Check | What it proves |
|---|---|
| Wrong client seed produces different cards | Client seed genuinely influences outcome |
| Bet size does not influence card stream | Phase E $10 bets match Phase A-D $0.01 bets |
| No cherry-picking in seed selection (Pass 2) | Casino is not curating favourable seeds |
High-Level Flow
A Blackjack round runs through five stages — from the casino committing to a server seed before you play, through the deal and player actions, to the post-rotation verification that anyone can run.
1. Commit — Server generates server seed, publishes SHA-256 hash as `serverSeedHashed`. Player sees only the hash.
2. Seed pair activated — Client seed is browser-generated (or player-set). Nonce starts at 0. Next server seed is pre-committed.
3. Bet — Player places main bet (and optional Perfect Pairs / 21+3 side bets). Platform draws cards 0..3 (initial deal) via HMAC-SHA256 single-card draw using the active seed pair + nonce.
4. Actions — Player hits, stands, doubles, or splits. Each action consumes the next cursor from the same seed triple. Dealer reveals hole and plays out per S17 rule.
5. Rotate — On client-seed change or manual rotation, the active server seed is revealed. The next-committed seed becomes active.
6. Verify — Anyone with serverSeed, clientSeed, nonce, and the action sequence can recompute every visible card.
Provably Fair Model
Blackjack is a commit-reveal provably fair game. The casino commits to a server seed by publishing its SHA-256 hash before any bet is placed. Once play on that seed is complete and the seed is rotated, the casino reveals the raw server seed. Anyone can then verify two things: (1) that the revealed seed hashes to the previously-published commitment, and (2) that every card for every bet in that epoch recomputes correctly from the seed triple. The model does not require trust in the casino — only that SHA-256 and HMAC-SHA256 are cryptographically sound, which is a well-established assumption.
Commit-Reveal Model
Blackjack uses a four-phase commit-reveal cycle. Each phase has a single, well-defined purpose, and every phase produces artefacts that can be verified after the fact.
Commit phase
Server generates a 32-byte random server seed, computes `SHA-256(hex_bytes(serverSeed))`, and publishes the hash as `serverSeedHashed`. The raw seed is kept secret. A next-seed hash is also pre-committed.
Bet phase
Player places bets against the active seed pair. For each bet, every visible card is computed via `HMAC-SHA256(key=hex_bytes(serverSeed), msg=clientSeed:nonce:cursor)` followed by 4-byte chunk rejection sampling and modular reduction over 52. Nonce increments per bet.
Reveal phase
Player (or system) rotates the seed pair — typically by changing the client seed. The previously-active server seed is now revealed in the API. The pre-committed next-seed hash becomes the active `serverSeedHashed`.
Verify phase
Anyone with the seed triple can run HMAC-SHA256 + rejection sampling independently and confirm that every visible card matches the values returned by the live API. The commitment chain is preserved: revealed hash = prior commitment.
Player-Controlled Client Seed
The client seed is the player's lever into the RNG. It is generated in the browser (not the server) and can be replaced at any time, which forces a seed rotation.
- Player-supplied — an alphanumeric string the player sets, submitted via the client-seed rotation endpoint
- Player-editable — the player can overwrite it with any value they choose; changing the client seed triggers a seed rotation and reveals the active server seed
- Materially influences outcome — we verified that changing
clientSeedto a random alternative produces different cards in 99 / 100 sampled bets at cursor 0 (matching the analytical baseline of 51/52 ≈ 98.08% — the 1/52 collision rate is expected when both seed-derived cards happen to map to the same card by coincidence) - Not known to the server at commit time — the server publishes
serverSeedHashedbefore your client seed is known, so it cannot cherry-pick a commitment that favours the casino against your specific seed
Nonce Lifecycle
The nonce is a per-bet counter within the active seed pair. It starts at 0, increments by 1 per bet, and resets only on seed rotation. The nonce is shared across all cursors in a single bet — every card in a hand uses the same nonce, with the cursor index distinguishing card positions.
- Starts at 0 on every new seed pair
- Increments sequentially per bet — no skipping, no reuse within an epoch
- Resets on rotation — every rotation produces a fresh nonce=0 sequence
- Shared across cursors — all 50 cursor positions in a single bet share the same nonce; the cursor index is appended to the HMAC message
// Per seed pair, nonce sequence:// bet 1 → nonce=0 (cursors 0..49 share nonce=0)// bet 2 → nonce=1 (cursors 0..49 share nonce=1)// bet 3 → nonce=2// ...// On client seed change:// serverSeed revealed, new pair activated, nonce resets to 0Determinism Guarantee
Given serverSeed, clientSeed, nonce, and cursor, the card at that cursor is fully determined — there is no randomness, no hidden input, and no dependence on bet amount, timestamp, or any other variable. The pipeline is:
// Inputs: serverSeed (hex), clientSeed (string), nonce (int), cursor (int 0..49) key = Buffer.from(serverSeed, 'hex') // hex-decoded bytes, NOT utf-8message = `${clientSeed}:${nonce}:${cursor}`hash = HMAC-SHA256(key, message) // Read 32-byte hash as 8 big-endian uint32 chunks; first chunk < MAX_FAIR acceptedMAX_FAIR = 52 * floor(2^32 / 52) // 4,294,967,248 for chunk in chunks_of_4_bytes(hash): value = uint32_be(chunk) if value < MAX_FAIR: cardIndex = value % 52 return CARDS[cardIndex]throw 'all 8 chunks rejected' // probability ≈ 1.12 × 10⁻⁸Phase Map
The audit captured 6,000 bets across six phases, each designed to stress a specific property of the system.
| Phase | Bets | Stake | Purpose |
|---|---|---|---|
| A | 3,300 | $0.01 main + $0.01 PP + $0.01 21+3 | Bulk capture: dealer-rule, payout, side-bet coverage |
| B | 1,000 | $0.01 main + $0.01 PP + $0.01 21+3 | Continued bulk capture for distribution coverage |
| C | 500 | $0.01 main only (no side bets) | Side-bet independence — confirms main-bet outcomes are unaffected by side-bet stake presence |
| D | 500 | $0.01 main + $0.01 side bets (10 auditor-controlled client seeds, pfaudit_bj_seed00..09) | Client-seed influence test — alternate seed produces different cards (Step 6, Step 15) |
| E | 200 | $10.00 main only (no side bets) | Bet-size invariance — verifies $10 stakes produce identical RNG behaviour to $0.01 stakes (Step 9) |
| F | 500 | $0.01 main + $0.01 PP + $0.01 21+3 | Trailing capture for split-bet coverage and convergence |
Technical Glossary7 categories▶
Terms and definitions used throughout this audit report, grouped by category.
Core Concepts
| Term | Definition |
|---|---|
| Provably Fair | A system where every game outcome can be independently verified from public cryptographic inputs, without trusting the operator. |
| Commit-Reveal | A two-phase protocol where a party publishes a hash of a secret before play (commit), then reveals the secret afterward (reveal). The hash proves the secret was fixed in advance. |
| Determinism | A property where the same inputs always produce the same output. Blackjack is deterministic: (serverSeed, clientSeed, nonce, cursor) uniquely determines the card. |
| Epoch | The set of bets placed under a single active seed pair, from activation to rotation. The auditor chose to rotate every 50 bets to bound per-seed-pair exposure for analysis (50 is auditor methodology, not platform-enforced). |
Seed System
| Term | Definition |
|---|---|
| Server Seed | A 32-byte random value generated by the casino. Represented as a 64-character hex string. Used as the HMAC key (hex-decoded, NOT utf-8). |
| Server Seed Hashed | SHA-256(hex_bytes(serverSeed)). The commitment published before play. |
| Client Seed | A player-controlled alphanumeric string, browser-generated by default. Part of the HMAC message. |
| Nonce | A per-bet counter within the active seed pair. Starts at 0, increments per bet, shared across all cursors within a single bet, resets on seed rotation. |
| Cursor | The card-position index appended to the HMAC message. Cursors 0-3 form the initial deal (P1, dealer up, P2, dealer hole); cursors 4 onward are consumed in order as actions are taken. |
| Seed Pair | The combination of one server seed and one client seed. A seed pair defines an epoch. |
| Next Server Seed Hash | A pre-commitment to the server seed that will become active after the current one is rotated. Locks the future chain in advance. |
Cryptographic Functions
| Term | Definition |
|---|---|
| SHA-256 | A 256-bit cryptographic hash function. Used here for server seed commitment: SHA-256(hex_bytes(serverSeed)) = serverSeedHashed. |
| HMAC-SHA256 | Hash-based message authentication code built on SHA-256. Takes a key and a message, returns a 256-bit (32-byte) digest. Used here for per-cursor card draw. |
| Single-Card Draw | The Blackjack RNG primitive: getCard(serverSeed, clientSeed, nonce, cursor) runs HMAC-SHA256 once per cursor and returns one card. Each cursor is independent (infinite-deck model). |
| Rejection Sampling | A technique for bias-free uniform sampling. A 4-byte chunk is accepted only if it falls below MAX_FAIR = 52 × ⌊2³² / 52⌋ = 4,294,967,248. Guarantees every card has probability exactly 1/52. |
| MAX_FAIR | The bias-free threshold: 4,294,967,248. Per-chunk rejection probability is 48 / 2³² ≈ 1.12 × 10⁻⁸. With 8 chunks per HMAC, the probability of all chunks rejecting is ≈ 5 × 10⁻⁶² — never observed in practice. |
Verification Terms
| Term | Definition |
|---|---|
| Parity | Agreement between the live game's reported outcome and an independent recomputation from the same inputs. 100% parity means zero card mismatches across all cursors. |
| Recomputation | Running the audit's standalone RNG implementation on captured seed triples and comparing every visible card to the live API response. |
| Anti-Circularity Proof | A proof that RTP is not an empirical observation but the output of an independent recursive infinite-deck EV solver. The solver consumes only the rule set + 13-rank distribution; no casino-supplied figure feeds in. |
| Cherry-Pick Detection (Pass 2) | A statistical test over revealed casino seeds that checks whether per-seed return distributions show systematic skew — the signature of seed pre-selection. Pass 2 result: 11 / 120 seeds flagged at α=0.05 (binomial p=0.038450), above the α=0.01 dataset reject threshold. |
| Optimal-Play EV Solver | The recursive infinite-deck expected-value enumerator in src/optimal-play.ts. Computes, for every initial (P1, P2, dealer-up) triple, the maximum-EV action sequence (hit/stand/double/split) under Duel's exact rule set. Returns the analytical RTP of 99.4296%. |
Game Mechanics
| Term | Definition |
|---|---|
| S17 | The dealer rule "stand on all 17" — including soft 17 (Ace counted as 11). Verified across 75 in-range played-out hands (Step 19). |
| DAS | "Double after split" — the player may double on a split sub-hand. Allowed in Duel's rules and confirmed in the EV solver. |
| Soft Hand | A hand containing an Ace counted as 11 (e.g., A-6 = 17 soft). If hitting would bust, the Ace counts as 1 instead (becomes a hard hand). |
| Natural Blackjack | A two-card 21 — Ace + 10-value (10, J, Q, K) on the deal. Pays 3:2 (return = 2.5× stake) unless the dealer also has blackjack (push). |
| Insurance | An optional side wager offered when the dealer's upcard is an Ace. The player wagers up to half the main bet; pays 2:1 if the dealer has blackjack. The audit confirms insurance is offered if and only if the upcard is an Ace (Step 25). |
| Perfect Pairs | A side bet on the player's two dealt cards (cursors 0, 2). Pays 27× for matching rank + suit, 11× for matching rank + colour, 7× for matching rank + different colour. Expected value is exactly 0 by algebraic identity ((27+11+14)/52 = 52/52). |
| 21+3 | A side bet on the player's two cards plus the dealer upcard (cursors 0, 2, 1). Pays 121.2307692× (suited trips), 53× (straight flush), 32× (three of a kind), 12× (straight), 5× (flush). Expected value is 0 to within 7-decimal multiplier rounding. |
Audit Terms
| Term | Definition |
|---|---|
| Fairness Integrity (FI) Matrix | The standard test matrix applied to every audit, covering nonce integrity, seed commitment, outcome determinism, player isolation, payout integrity, and game-state integrity. For Blackjack: 17 tests (16 standard + 1 BJ-specific bonus race-condition probe). Documented in S5. |
| FLAG | A severity level for anomalies disclosed transparently with no observed operational effect on gameplay. FLAG findings do not block certification. |
| Hard Fail | A severity level for findings that invalidate the fairness guarantee. Any single hard fail blocks certification until remediated. Zero hard fails in this audit. |
| Bonferroni Correction | A multiple-comparison adjustment: when testing N hypotheses at significance α, the per-test threshold is α/N. Applied to per-stream serial-independence tests in Pass 1 (10 streams, α=0.01 → per-stream α=0.001, z-critical ≈ 3.291). |
| Fisher's Combined p-value | A meta-analytic combination of independent p-values: T = -2 × Σ ln(p_i) follows χ²(2K). Used in Pass 1 to combine 10 per-stream p-values into a single dataset-level test (combined p = 0.686432, no concentration in either tail). |
| Point-in-Time Audit | An audit verdict that applies to the code and configuration in force at the audit date. Subsequent changes are outside scope unless re-certified. |
Data Formats
| Term | Definition |
|---|---|
| blackjack-dataset-6000hands.json | Primary capture dataset. 6,000 bets across six phases (A: 3,300, B: 1,000, C: 500, D: 500, E: 200, F: 500), 120 epochs, 33,194 card cursors. SHA-256 pinned in S1 and S7. |
| verification-results.json | Output of the 33-step verification pipeline. Contains per-step pass/fail status and per-step detail strings. |
| simulation-results.json | Output of the 11.2M-round simulation: optimal-play RTP solver, Pass 1 (10M rounds, 10 streams Fisher-combined), Pass 2 (120 captured seeds × 10K nonces, cherry-pick detection). |
| rtp-convergence.html | Self-contained interactive RTP convergence chart for Pass 1 — viewable in any browser, no external dependencies. |
Seed, Nonce & Determinism
Can the casino change your outcome after you bet?
Every Blackjack hand on Duel.com is generated from three inputs: server seed, client seed, and nonce. The casino commits to its server seed by publishing a SHA-256 hash before you place any bets. After you rotate your seed, the server reveals the actual seed — and anyone can verify that the hash matches. This cryptographic commitment makes it impossible for the casino to secretly change your cards after you bet.
What we verified
- Casino commits to the server seed hash before any bet is placed
- Client seed is browser-generated — server cannot know it at commitment time
- Players can set or change their client seed at any time via the rotation UI
- Nonce increments by 1 per bet across all 120 seed pairs
- Every visible card — initial deal, hits, doubles, splits, dealer play-out — is determined by
(serverSeed, clientSeed, nonce, cursor)before any animation plays - Identical inputs always produce the same card stream — confirmed across all 6,000 hands (33,194 card cursors)
- Your client seed is a genuine input — changing it changes the cards
What this means for you
- The casino cannot change your hand after you bet
- You contribute randomness the server cannot predict or pre-select against
- Every bet is unique — the nonce ensures no two bets share a card stream within a seed pair
- Any hand can be independently verified using the public tools and repo
- Outcomes are tamper-proof and verifiable even months later
- Cherry-picking favourable seeds is structurally impossible
- Casino commits to the server seed hash before any bet is placed
- Client seed is browser-generated — server cannot know it at commitment time
- Players can set or change their client seed at any time via the rotation UI
- Nonce increments by 1 per bet across all 120 seed pairs
- Every visible card — initial deal, hits, doubles, splits, dealer play-out — is determined by
(serverSeed, clientSeed, nonce, cursor)before any animation plays - Identical inputs always produce the same card stream — confirmed across all 6,000 hands (33,194 card cursors)
- Your client seed is a genuine input — changing it changes the cards
- The casino cannot change your hand after you bet
- You contribute randomness the server cannot predict or pre-select against
- Every bet is unique — the nonce ensures no two bets share a card stream within a seed pair
- Any hand can be independently verified using the public tools and repo
- Outcomes are tamper-proof and verifiable even months later
- Cherry-picking favourable seeds is structurally impossible
Server seed committed before betPass
Client seed originPass
Client seed controlPass
Nonce sequencingPass
Hash consistency within seed pairPass
Seed hash integrityPass
Deterministic outputPass
Client seed participationPass
All 120 revealed seeds hash-verified. Every seed rotation was verified — the next seed the casino pre-committed always matched what was actually used. Outcomes are fully deterministic — the same server seed, client seed, nonce, and cursor always produce the same card. The casino cannot change your hand after you bet.
| Check | Result | Evidence |
|---|---|---|
| Server seed committed before bet | Pass | SHA-256 hash of server seed published before play — casino cannot change cards after betting |
| Client seed origin | Pass | Player-controlled — server commits before client seed is known |
| Client seed control | Pass | Player can set/change client seed via rotation UI at any time |
| Nonce sequencing | Pass | Sequential within each seed pair, 0 gaps, 0 duplicates across 120 seed pairs |
| Hash consistency within seed pair | Pass | server_seed_hashed constant across all bets within each of 120 seed pairs |
| Seed hash integrity | Pass | 120 / 120 revealed seeds hash-verified — commitment chain intact |
| Deterministic output | Pass | Same (serverSeed, clientSeed, nonce, cursor) always produces same card — 6,000 / 6,000 hands recompute (33,194 card cursors verified, 0 mismatches) |
| Client seed participation | Pass | Client seed is a genuine input — changing it changes the deck |
Server seed committed before betPass
Client seed originPass
Client seed controlPass
Nonce sequencingPass
Hash consistency within seed pairPass
Seed hash integrityPass
Deterministic outputPass
Client seed participationPass
All 120 revealed seeds hash-verified. Every seed rotation was verified — the next seed the casino pre-committed always matched what was actually used. Outcomes are fully deterministic — the same server seed, client seed, nonce, and cursor always produce the same card. The casino cannot change your hand after you bet.
How It Works — Seed, Nonce & Determinism8 sections
1.1Server Seed Commitment
Before any bet is placed, the casino generates a secret server seed and publicly commits to it by displaying its SHA-256 hash. The server seed is a 64-character hex string (32 bytes), and the commitment hash is computed as SHA-256(hexDecode(serverSeed)). This cryptographic commitment locks the server's randomness before the player acts. After the epoch ends, the actual server seed is revealed — and anyone can verify that the hash matches.
/** SHA-256 hash of server seed (for commitment verification) *//** Duel Blackjack uses SHA-256 of hex-decoded bytes, not UTF-8 string */export function hashServerSeed(serverSeed: string): string { return crypto.createHash('sha256').update(Buffer.from(serverSeed, 'hex')).digest('hex');}Real epoch verified:
// Source: data/blackjack-dataset-6000hands.json// First epoch (Phase A, rotation context: rotate-phase-A)// ✅ VERIFIED — SHA-256(hex_decode(serverSeed)) matches serverSeedHashed { "clientSeed": "pf_Naa0pBEOuWmz6", "serverSeedHashed": "94c218c91df8997d4e7b1280687e90a3573c98739bd9220cd2fdd595699ef34f", "nextServerSeedHash": "27674345267704331cc6f6f2ee91e94ca47d2d1076774a75b2191a7ec9b2fddd", "serverSeed": "b6f2cbcd411eedbd53587902410f17f43e962f2e374e97ccbec24088debd0556"} // Verify:// crypto.createHash('sha256')// .update(Buffer.from('b6f2cbcd411eedbd53587902410f17f43e962f2e374e97ccbec24088debd0556', 'hex'))// .digest('hex')// = 94c218c91df8997d4e7b1280687e90a3573c98739bd9220cd2fdd595699ef34f ✅1.2Commitment Linkage (Next-Seed Promotion)
At every seed rotation, the nextServerSeedHash pre-committed before rotation must match the serverSeedHashed that becomes active after rotation. This chain ensures the server cannot generate seeds at rotation time after seeing the player's client seed — the next seed was already locked before the current epoch began.
// Step 2: Commitment Linkagelet linked = 0, broken = 0;for (let i = 0; i < ctx.seeds.length - 1; i++) { const curr = ctx.seeds[i].seed; const next = ctx.seeds[i + 1].seed; if (curr.nextServerSeedHash === next.serverSeedHashed) linked++; else broken++;}// Result: 120 / 120 consecutive seed links verified, 0 broken1.3Hash Consistency Within Epoch
For each of the 120 bet-bearing epochs, the server_seed_hashed field was extracted from every bet response and checked for uniqueness. More than one distinct hash within a single epoch would indicate a mid-epoch seed substitution — a critical commit-reveal violation. The combined dataset contains 121 seed entries across 120 bet-bearing epochs. The 1 non-bet-bearing entry is the most recently pre-committed next-seed that had not yet rotated to active by capture-end. Every bet-bearing epoch had its commit hash verified against the revealed server seed; the unrotated pre-commitment carries a valid hash but no bets reference it. This catches mid-epoch tampering even before the seed is revealed.
// Step 3: Hash Consistency Within Epochlet consistent = 0, inconsistent = 0;for (const [hash, bets] of ctx.byHash) { const allMatch = bets.every(b => b.server_seed_hashed === hash); if (allMatch) consistent++; else inconsistent++;}// Result: All 120 epochs internally consistent, 0 inconsistentserver_seed_hashed. 0 inconsistent epochs.1.4Client Seed Origin & Control
Players have full control over their client seed through the Duel.com fairness UI — by default the browser generates one, and players can view, modify, or randomize it at any time before placing bets. The client seed is an input to every HMAC-SHA256 computation, so changing it produces a completely different 52-card deck. For this audit, the dataset's client seeds were generated by the capture script and submitted through Duel's standard client-seed rotation endpoint. The server commits to its seed before the client seed for that epoch is known, so cherry-picking is structurally impossible.
- Example client seeds from the dataset:
pf_vIBZ6rgPyYvq7— 50 bets (Phase A)pf_Naa0pBEOuWmz6— 50 bets (Phase A)pf_JUnsd3HAFJxzz— 50 bets (Phase A)pf_hc9GvK8F3fFn7— 50 bets (Phase A)pfaudit_bj_seed00— 50 bets (Phase D; deterministic auditor seed)
pf_-prefixed), and Phase D uses ten fixed, human-readable seeds (pfaudit_bj_seed00..09). Both kinds are submitted through Duel's client-seed rotation endpoint and accepted by the server identically. The server commits to each server seed before the matching client seed is received, which is what guarantees cherry-pick immunity. Step 15 confirms 500 / 500 Phase D hands recompute under the auditor seeds.1.5Nonce Incrementation
The nonce begins at 0 and increments by 1 for each bet under the same server seed. In Blackjack, the nonce advances once per bet — every visible card in the hand (initial deal, hits, doubles, splits, dealer play-out) shares the same nonce, with cursors 0..49 distinguishing them. The nonce resets to 0 when the player rotates their seed, starting a new epoch. In this audit we rotated every 50 bets as our chosen sampling cadence (nonces 0–49 per epoch); epoch length is not enforced by the casino — players can rotate at any time. 7 auditor-side capture gaps were observed; the missing nonces are not in the captured dataset. Revealed seeds permit deterministic derivation of the cursor-0 card for each gap (and no captured bet uses those nonces), but the missing games themselves are not verified.
// Step 4: Nonce Audit — for every gap, verify all four conditions hold:// (a) revealed seed exists, (b) getCard returns a valid card,// (c) no captured bet uses the missing nonce in this epoch,// (d) no captured bet ANYWHERE uses (server_seed_hashed, missing). for (const [hash, bets] of ctx.byHash) { const nonces = bets.map(b => b.nonce).sort((a, b) => a - b); const seen = new Set<number>(nonces); const seed = ctx.seedMap.get(hash); const clientSeed = bets[0].client_seed; for (let i = 1; i < nonces.length; i++) { if (nonces[i] !== nonces[i - 1] + 1) { for (let missing = nonces[i - 1] + 1; missing < nonces[i]; missing++) { // (a) seed must be revealed if (!seed) { unverifiable++; continue; } // (c) missing nonce must NOT appear in this epoch if (seen.has(missing)) { unverifiable++; continue; } // (d) no captured bet anywhere uses (hash, missing) if (globalNonceIndex.has(`${hash}:${missing}`)) { unverifiable++; continue; } // (b) seed deterministically produces a valid card const card = getCard(seed, clientSeed, missing, 0); if (VALID_CARDS.has(card)) retroactivelyVerified++; else unverifiable++; } } }}// Result: 7 gaps; cursor-0 card derivable from revealed seed; missing games not themselves verified; 0 unverifiable1.6Deterministic Mapping
The RNG is fully deterministic: given the same (serverSeed, clientSeed, nonce, cursor), getCard always returns the same card. The algorithm hex-decodes the server seed to 32 raw bytes for use as the HMAC key, then computes HMAC-SHA256(key, "clientSeed:nonce:cursor"). The 32-byte HMAC output is read as eight uint32 chunks (big-endian); each chunk is tested against MAX_FAIR = 4,294,967,248 (the largest multiple of 52 that fits in uint32). The first chunk that passes is taken modulo 52 to index into the canonical 52-card array. This is bias-free rejection sampling — every card has exactly probability 1/52, and the 8-chunk fallback ensures no hash ever exhausts (the rejection rate per chunk is (2³² − MAX_FAIR) / 2³² = 48 / 2³² ≈ 1.12×10⁻⁸).
// Bias-free maximum: largest multiple of 52 that fits in uint32const MAX_FAIR = 52 * Math.floor(0x100000000 / 52); // 4,294,967,248 /** HMAC-SHA256 with hex-decoded key */export function hmacSHA256(serverSeedHex: string, message: string): Buffer { const key = Buffer.from(serverSeedHex, 'hex'); return crypto.createHmac('sha256', key).update(message).digest();} /** Generate a single card from an HMAC hash via rejection sampling */export function generateCardFromHash(hash: Buffer): string { for (let i = 0; i <= hash.length - 4; i += 4) { const value = hash.readUInt32BE(i); if (value < MAX_FAIR) { return CARDS[value % 52]; } } throw new Error('Failed to generate unbiased card from hash — all 8 chunks rejected');} /** Get a single card at a specific cursor position */export function getCard(serverSeed: string, clientSeed: string, nonce: number, cursor: number): string { const message = `${clientSeed}:${nonce}:${cursor}`; const hash = hmacSHA256(serverSeed, message); return generateCardFromHash(hash);}(serverSeed, clientSeed, nonce, cursor). 33,194 card cursors verified across the dataset (strict ordering for non-split bets, multiset for split bets). Zero mismatches. No external entropy — outcomes derive solely from the four named inputs.Worked Example — Real Bet Verified:
// Source: data/blackjack-dataset-6000hands.json// bet 13420172 (Phase A, nonce 0 — first bet of the epoch)// Player: JC + AH = 21 (natural blackjack)// Dealer: 2D + 10S = 12 (loses to player BJ)// Outcome: $0.01 stake → $0.025 returned (3:2 blackjack pays 1.5× profit)// ✅ VERIFIED — every visible card recomputed from the revealed server seed { "serverSeed": "5d75992d78294438db4971c53c70d5c38959797ea2768ea0c57e11edb14ce700", "serverSeedHashed": "5a171081ba5a98936cbc1d48655d69ded386ab1bab0f6c5f2069e24c1753d19b", "clientSeed": "pf_vIBZ6rgPyYvq7", "nonce": 0, "deal": { "player": ["JC", "AH"], // cursor 0, cursor 2 "dealer": ["2D", "10S"] // cursor 1, cursor 3 (hole revealed at deal-time for BJ) }, "outcome": "blackjack", "amount_currency": "0.01", "amount_won": "0.025"}Verification — Full HMAC trace, all 4 cursors:
// getCard(// serverSeed = '5d75992d78294438db4971c53c70d5c38959797ea2768ea0c57e11edb14ce700',// clientSeed = 'pf_vIBZ6rgPyYvq7',// nonce = 0,// cursor = 0..3// )//// Each cursor: HMAC-SHA256(hexDecode(serverSeed), `${clientSeed}:${nonce}:${cursor}`)// Read 32 bytes as 8 big-endian uint32 chunks. First chunk < MAX_FAIR (4,294,967,248) accepted.// Card index = chunk % 52 → CARDS[index].//// ─── CURSOR 0 (Player card 1) ───────────────────────────────────────// message = 'pf_vIBZ6rgPyYvq7:0:0'// HMAC = 5cd3ac7796357342b03cca3e5df5e5fa3279edb5bab80a40d386c1fc0b7fb9f2// chunk[0] = 0x5cd3ac77 = 1557376119 → j = 1557376119 % 52 = 39 → JC ✅ ACCEPTED// chunk[1] = 0x96357342 = 2520085314 (would map j=6 → 3S if chunk[0] had been ≥ MAX_FAIR)// chunk[2] = 0xb03cca3e = 2956773950 (would map j=26 → 8S)// chunk[3] = 0x5df5e5fa = 1576396282 (would map j=6 → 3S)// chunk[4] = 0x3279edb5 = 846851509 (would map j=49 → AH)// chunk[5] = 0xbab80a40 = 3132623424 (would map j=8 → 4D)// chunk[6] = 0xd386c1fc = 3548824060 (would map j=28 → 9D)// chunk[7] = 0x0b7fb9f2 = 192920050 (would map j=50 → AS)//// ─── CURSOR 1 (Dealer upcard) ───────────────────────────────────────// message = 'pf_vIBZ6rgPyYvq7:0:1'// HMAC = 0ce314184168d199266e0cc6794c75f3c30a473bf8ba500316dbf3582b3f1c2b// chunk[0] = 0x0ce31418 = 216208408 → j = 216208408 % 52 = 0 → 2D ✅ ACCEPTED// chunk[1] = 0x4168d199 = 1097388441 (would map j=45 → KH)// chunk[2] = 0x266e0cc6 = 644746438 (would map j=50 → AS)// chunk[3] = 0x794c75f3 = 2035054067 (would map j=7 → 3C)// chunk[4] = 0xc30a473b = 3272230715 (would map j=39 → JC)// chunk[5] = 0xf8ba5003 = 4172959747 (would map j=47 → KC)// chunk[6] = 0x16dbf358 = 383513432 (would map j=16 → 6D)// chunk[7] = 0x2b3f1c2b = 725556267 (would map j=7 → 3C)//// ─── CURSOR 2 (Player card 2) ───────────────────────────────────────// message = 'pf_vIBZ6rgPyYvq7:0:2'// HMAC = 6af06ed1beb6b0a1251ddd35d14620bf7986cadeaf7fc50b0c2559e4981202a6// chunk[0] = 0x6af06ed1 = 1794141905 → j = 1794141905 % 52 = 49 → AH ✅ ACCEPTED// chunk[1] = 0xbeb6b0a1 = 3199643809 (would map j=37 → JH)// chunk[2] = 0x251ddd35 = 622714165 (would map j=21 → 7H)// chunk[3] = 0xd14620bf = 3511034047 (would map j=27 → 8C)// chunk[4] = 0x7986cade = 2038876894 (would map j=2 → 2S)// chunk[5] = 0xaf7fc50b = 2944386315 (would map j=39 → JC)// chunk[6] = 0x0c2559e4 = 203774436 (would map j=8 → 4D)// chunk[7] = 0x981202a6 = 2551317158 (would map j=26 → 8S)//// ─── CURSOR 3 (Dealer hole, revealed at deal-time on natural BJ) ────// message = 'pf_vIBZ6rgPyYvq7:0:3'// HMAC = 91da572605a294d0507d8a12328faed6a385b5057e179d8f30c4fff7ba51b7e7// chunk[0] = 0x91da5726 = 2447005478 → j = 2447005478 % 52 = 34 → 10S ✅ ACCEPTED// chunk[1] = 0x05a294d0 = 94541008 (would map j=16 → 6D)// chunk[2] = 0x507d8a12 = 1350404626 (would map j=38 → JS)// chunk[3] = 0x328faed6 = 848277206 (would map j=10 → 4S)// chunk[4] = 0xa385b505 = 2743448837 (would map j=25 → 8H)// chunk[5] = 0x7e179d8f = 2115476879 (would map j=35 → 10C)// chunk[6] = 0x30c4fff7 = 818216951 (would map j=19 → 6C)// chunk[7] = 0xba51b7e7 = 3125917671 (would map j=19 → 6C)//// ─── Final card stream ──────────────────────────────────────────────// Cursors [0, 1, 2, 3] → ['JC', '2D', 'AH', '10S']// Player = [JC, AH] → 21 (natural blackjack) ✅// Dealer = [2D, 10S] → 12 (loses to player BJ on peek) ✅// Note: every cursor in this bet accepts on chunk[0] — chunks 1–7 are shown// to illustrate the fallback path that triggers if chunk[0] ≥ MAX_FAIR// (rejection rate per chunk: 48 / 2³² ≈ 1.12×10⁻⁸ — vanishingly rare in practice).1.7Card-Stream Architecture & Determinism at Deal Time
Blackjack at Duel uses a single linear cursor stream per bet — no shuffle, no deck array. The first four cursors fix the deal: cursor 0 → Player card 1, cursor 1 → Dealer upcard, cursor 2 → Player card 2, cursor 3 → Dealer hole. Cursor 4 onward fixes the action sequence: hits, doubles, split sub-hand draws, and dealer play-out cards consume cursors in the order actions are taken. Because every card is a pure function of (serverSeed, clientSeed, nonce, cursor) — and cursors are integers starting at 0 — the entire infinite-deck stream is fixed at the moment the bet is placed. The player's hit/stand/double/split decisions only determine how many cursors are consumed; they cannot change the card any cursor produces.
/** * Deal order mapping (confirmed from dataset — alternating P/D): * cursor 0 → Player card 1 * cursor 1 → Dealer upcard * cursor 2 → Player card 2 * cursor 3 → Dealer hole card * cursor 4+ → hits, splits, doubles, dealer draws in sequence */export const DEAL_ORDER = { PLAYER_1: 0, DEALER_UP: 1, PLAYER_2: 2, DEALER_HOLE: 3, FIRST_ACTION: 4,} as const;Cursor consumption — non-split vs split:
// Non-split bet: strict cursor-ordered check.// out[0] = playerDeal[0]; // cursor 0 — P1// out[1] = dealerUp; // cursor 1 — D up// out[2] = playerDeal[1]; // cursor 2 — P2// out[3] = dealerFinal[1]; // cursor 3 — D hole// // Cursor 4+: player additional cards (hits / double card), in order// for (let i = 2; i < playerFinal.length; i++) out.push(playerFinal[i]);// // Then dealer's additional draws// for (let i = 2; i < dealerFinal.length; i++) out.push(dealerFinal[i]); // Split bet: multiset check.// The multiset of visible cards (across both sub-hands and the dealer hand)// must equal the multiset of cards produced by cursors 0..N-1.// Proves no cursor reuse and full coverage — without committing to an// exact post-split cursor ordering. // Result (Step 5):// 6000 / 6000 hands recomputed// 33,194 card cursors verified// 0 mismatches1.8Client Seed Influence
The client seed is consumed inside the HMAC message — clientSeed:nonce:cursor — so changing it changes every cursor's hash output, and therefore every card. We test this directly: for 100 sampled bets, recompute cursor 0's card with the real client seed and again with a deliberately wrong one ('wrong_seed_value_12345'). Under a fair RNG the analytical baseline is 51/52 ≈ 98.08% — only the 1/52 case where the wrong seed happens to produce the same card index lands on the same card. A casino that secretly ignored the client seed for most bets would fail this gate.
// Step 6: Client Seed Influencelet changed = 0, tested = 0;const sampleSize = Math.min(100, ctx.bets.length); for (let i = 0; i < sampleSize; i++) { const bet = ctx.bets[i]; const serverSeed = ctx.seedMap.get(bet.server_seed_hashed); if (!serverSeed) continue; tested++; const correct = getCard(serverSeed, bet.client_seed, bet.nonce, 0); const wrong = getCard(serverSeed, 'wrong_seed_value_12345', bet.nonce, 0); if (correct !== wrong) changed++;} // PASS criterion: changed >= 95% of sampled (>= 95/100)// Result: 99 / 100 sampled bets diverge under wrong client seed (99.0%)Technical Evidence & Verification5 sections
1.9Evidence Coverage Summary6 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Seed hash integrity (Step 1) | 120 / 120 revealed seeds | Pass |
| Commitment linkage (Step 2) | 120 / 120 consecutive seed links | Pass |
| Hash consistency within epoch (Step 3) | 120 / 120 epochs internally consistent | Pass |
| Nonce audit (Step 4) | 120 epochs, 0 gaps, 0 duplicates, 7 capture-retry verified | Pass |
| Outcome recomputation (Step 5) | 6,000 / 6,000 hands · 33,194 card cursors | Pass |
| Client seed influence (Step 6) | 99 / 100 sampled bets diverge under wrong client seed | Pass |
1.10Code References6 source files
| File | Purpose |
|---|---|
| tests/verify.ts | 33-step verification pipeline (Steps 1–6 cover S1) |
| tests/steps/commitment.ts | Steps 1–4: Commit-reveal integrity checks |
| tests/steps/determinism.ts | Steps 5–6: Outcome recomputation and client seed influence |
| src/rng.ts | HMAC-SHA256 single-card draw (hashServerSeed, hmacSHA256, generateCardFromHash, getCard, MAX_FAIR constant) |
| src/loader.ts | Dataset loading + pre-flight SHA-256 check (EXPECTED_HASH, checkDatasetHash, buildSeedMap) |
| capture/capture.reference.js | Browser-based data collection script |
1.11Datasets UsedSource artifacts
| Property | Value |
|---|---|
| Source | Live capture from duel.com — 6,000 real bets across 6 phases (A=3,300 · B=1,000 · C=500 · D=500 · E=200 · F=500) |
| Total Records | 6,000 bets · 121 seed entries (120 revealed + 1 active commitment) |
| SHA-256 | ad9cdffcea2f6cf535361e2265ae0bf95bb1290b3bbf9fee70dda053d917fe12 |
| Pre-flight check | tests/verify.ts aborts before any step runs if the loaded file's SHA-256 does not match the pinned EXPECTED_HASH in src/loader.ts |
Fields used in S1 verification: server_seed_hashed, server_seed, client_seed, nonce, phase, plus the seed-rotation log (seeds[].seed.serverSeedHashed, seeds[].seed.nextServerSeedHash, seeds[].seed.serverSeed, seeds[].seed.clientSeed).
1.12Verified Invariants11 invariants
| Invariant | Result |
|---|---|
| SHA-256(hexDecode(serverSeed)) = serverSeedHashed for all 120 revealed seeds | Pass |
| Next-seed promotion chain intact for all 120 transitions | Pass |
| serverSeedHashed constant within epoch for all 120 epochs | Pass |
| Zero nonce duplicates within any epoch | Pass |
| 7 capture-side nonce gaps observed — cursor-0 card derivable from revealed seed, missing games not themselves verified, 0 unverifiable | Pass |
| getCard(serverSeed, clientSeed, nonce, cursor) returns valid card for all 33,194 (epoch, nonce, cursor) tuples | Pass |
| Strict cursor ordering verified for all 5,731 non-split bets (P1=0, D_up=1, P2=2, D_hole=3, action cards 4+) | Pass |
| Split bet visible-card multiset matches cursors 0..N−1 for all 269 split bets | Pass |
| Cards differ under wrong client seed (99/100 sampled, analytical baseline ≈ 98.08%) | Pass |
| No (serverSeedHashed, nonce) tuple appears more than once across 6,000 bets | Pass |
| Client seed is browser-generated by the player's browser before each bet | Pass |
1.13Reproduction InstructionsRun locally
Clone the repository, install dependencies, and run the verification suite:
git clone https://github.com/ProvablyFair-org/duel-blackjack.gitcd duel-blackjack && npm installnpm run verify# Expected output: Steps 1, 2, 3, 4, 6 all PASS (Step 5 covered in S2/S3)Steps 1–6 cover all Seed, Nonce & Determinism checks. Expected output:
[PASS] Step 1 — Seed Hash Integrity
[PASS] Step 2 — Commitment Linkage
[PASS] Step 3 — Hash Consistency Within Epoch
[PASS] Step 4 — Nonce Audit
[PASS] Step 6 — Client Seed InfluenceRNG & Entropy Model
Is the randomness genuinely random, or could it be rigged?
This section verifies that Duel.com's Blackjack random number generation produces cryptographically sound, unbiased outputs using only the disclosed inputs. The RNG uses HMAC-SHA256 single-card draw — for each cursor in the 50-card stream, the HMAC produces 32 bytes read as 8 big-endian uint32 chunks; the first chunk below the MAX_FAIR ceiling (4,294,967,248) is taken modulo 52 to index into the canonical 52-card array. We independently implemented this algorithm, verified it produces the same cards as the live game, and confirmed no hidden inputs can influence outcomes.
What we verified
- HMAC-SHA256 produces cryptographically sound, unpredictable output for each card draw
- Only disclosed inputs affect outcomes — no timestamps, no server-side state, no hidden entropy
- Rejection sampling via the
MAX_FAIRceiling eliminates modulo bias for the 52-card range - 10 million simulated rounds match the analytical optimal-play RTP under the disclosed rules
- Empirical infinite-deck confirmation: 1,354 of 6,000 hands contain ≥2 visible cards with identical rank+suit (impossible under any finite-deck model)
- Consecutive outcomes are statistically independent — no patterns, no streaks
- 99% of cards diverge with a different client seed (99/100 tested bets)
What this means for you
- Each card is generated fairly and cannot be skewed
- All 52 cards are equally likely on every draw — no positional bias
- No hidden randomness or server-side tricks influence which cards appear
- Consecutive bets are not correlated — past results don't affect future outcomes
- The algorithm depends only on seeds you can verify
- HMAC-SHA256 produces cryptographically sound, unpredictable output for each card draw
- Only disclosed inputs affect outcomes — no timestamps, no server-side state, no hidden entropy
- Rejection sampling via the
MAX_FAIRceiling eliminates modulo bias for the 52-card range - 10 million simulated rounds match the analytical optimal-play RTP under the disclosed rules
- Empirical infinite-deck confirmation: 1,354 of 6,000 hands contain ≥2 visible cards with identical rank+suit (impossible under any finite-deck model)
- Consecutive outcomes are statistically independent — no patterns, no streaks
- 99% of cards diverge with a different client seed (99/100 tested bets)
- Each card is generated fairly and cannot be skewed
- All 52 cards are equally likely on every draw — no positional bias
- No hidden randomness or server-side tricks influence which cards appear
- Consecutive bets are not correlated — past results don't affect future outcomes
- The algorithm depends only on seeds you can verify
RNG derived only from disclosed inputsPass
Entropy purityPass
Algorithm independently implementedPass
Modulo biasPass
Key encoding verifiedPass
Analytical RTP convergencePass
Infinite-deck modelPass
Serial independencePass
Client seed influencePass
The Blackjack RNG uses only the disclosed inputs, produces cards with bias-free 1/52 probability, converges to the analytical optimal-play RTP across 10M simulated rounds with no serial dependence, and confirms infinite-deck behaviour. The client seed is a genuine input — changing it changes the cards.
| Check | Result | Evidence |
|---|---|---|
| RNG derived only from disclosed inputs | Pass | HMAC-SHA256(hexDecode(serverSeed), clientSeed:nonce:cursor) — no hidden entropy |
| Entropy purity | Pass | No timestamps, external APIs, Math.random, or server-side state |
| Algorithm independently implemented | Pass | Independent implementation produces identical results for all 6,000 bets (33,194 card cursors) |
| Modulo bias | Pass | Rejection sampling at MAX_FAIR = 4,294,967,248 — every card has probability exactly 1/52 |
| Key encoding verified | Pass | Server seed hex-decoded to bytes (not UTF-8) — confirmed via 6,000-bet recomputation |
| Analytical RTP convergence | Pass | 10M-round simulation converges to the optimal-play RTP across 10 streams × 1M rounds |
| Infinite-deck model | Pass | 1,354 / 6,000 hands contain ≥2 cards with identical rank+suit — only possible under infinite-deck draw with replacement |
| Serial independence | Pass | Lag-1 autocorrelation near zero and runs tests pass across all 10 streams at 1M rounds each |
| Client seed influence | Pass | 99% of cards diverge under a wrong client seed — confirmed across 100 sampled bets |
RNG derived only from disclosed inputsPass
Entropy purityPass
Algorithm independently implementedPass
Modulo biasPass
Key encoding verifiedPass
Analytical RTP convergencePass
Infinite-deck modelPass
Serial independencePass
Client seed influencePass
The Blackjack RNG uses only the disclosed inputs, produces cards with bias-free 1/52 probability, converges to the analytical optimal-play RTP across 10M simulated rounds with no serial dependence, and confirms infinite-deck behaviour. The client seed is a genuine input — changing it changes the cards.
How It Works — RNG & Entropy Model8 sections
2.1RNG Function Implementation
Each Blackjack card draw is a single HMAC-SHA256 call. The server seed is hex-decoded to 32 raw bytes for use as the HMAC key; the message is clientSeed:nonce:cursor where cursor is the per-card index (0 = Player card 1, 1 = Dealer upcard, 2 = Player card 2, 3 = Dealer hole, 4+ = action cards). The 32-byte HMAC output is read as eight uint32 chunks (big-endian). The first chunk below the MAX_FAIR ceiling is taken modulo 52 to index into the canonical 52-card array. No deck state, no shuffle — each card is independently derived from its (serverSeed, clientSeed, nonce, cursor) tuple.
| Component | Detail |
|---|---|
| Hash function | HMAC-SHA256 |
| Key | Buffer.from(serverSeed, 'hex') — 32 bytes |
| Message | clientSeed:nonce:cursor |
| Extraction | 8 × 4-byte chunks → readUInt32BE → uint32 |
| Rejection | value < MAX_FAIR — bias-free ceiling for range 52 |
| Reduction | uint32 % 52 → card index ∈ [0, 51] |
| Output | single card at the requested cursor (no deck state) |
| Cursor map | 0=P1 · 1=D up · 2=P2 · 3=D hole · 4+ = actions |
// Bias-free maximum: largest multiple of 52 that fits in uint32const MAX_FAIR = 52 * Math.floor(0x100000000 / 52); // 4,294,967,248 /** Generate a single card from an HMAC hash via rejection sampling */export function generateCardFromHash(hash: Buffer): string { for (let i = 0; i <= hash.length - 4; i += 4) { const value = hash.readUInt32BE(i); if (value < MAX_FAIR) { return CARDS[value % 52]; } } throw new Error('Failed to generate unbiased card from hash — all 8 chunks rejected');} /** Get a single card at a specific cursor position */export function getCard(serverSeed: string, clientSeed: string, nonce: number, cursor: number): string { const message = `${clientSeed}:${nonce}:${cursor}`; const hash = hmacSHA256(serverSeed, message); return generateCardFromHash(hash);}src/rng.ts matches all 6,000 live bets across 33,194 card cursors with zero mismatches. Algorithm coded from the cryptographic specification, not copied from any casino source code.2.2Entropy Sources
All randomness derives exclusively from the deterministic HMAC-SHA256 function combining four cleanly separated inputs:
| Source | Controlled By | Purpose |
|---|---|---|
| Server Seed | Casino | Base randomness (committed via SHA-256 hash before betting) |
| Client Seed | Player | Player-contributed entropy |
| Nonce | System | Uniqueness per bet (increments automatically within each epoch) |
| Cursor | System | Per-card isolation (0=P1, 1=D up, 2=P2, 3=D hole, 4+ = action cards) |
Math.random(), no external APIs, no server-side mutable state. Only: HMAC-SHA256(hexDecode(serverSeed), clientSeed:nonce:cursor).No mixed entropy sources detected. Run the same inputs multiple times — results are always identical. Pure HMAC-SHA256 must always produce identical outputs. 6,000 / 6,000 bets confirmed.
2.3Modulo Bias Analysis
A naïve chunk % 52 is biased: 2³² = 4,294,967,296 is not divisible by 52 (4,294,967,296 = 52 × 82,595,524 + 48), so 48 chunk values out of every 2³² map to residues 0–47 with one more chance than residues 48–51. Across millions of draws this would skew the card distribution. Duel's RNG eliminates the bias entirely via rejection sampling: a ceiling MAX_FAIR = 52 × ⌊2³² / 52⌋ = 4,294,967,248 discards the 48 unsafe values. Any chunk ≥ MAX_FAIR is skipped; the next 4-byte chunk in the same HMAC output is tried instead. One implementation detail worth noting: this RNG does not retry by incrementing cursor on rejection — instead, it scans up to 8 chunks within the same HMAC and throws if all 8 fail. The probability of all 8 chunks being rejected is approximately (48 / 2³²)⁸ ≈ 2.4×10⁻⁶⁴; this branch has never been observed in the dataset.
For range = 52 (every card draw):
MAX_FAIR = 52 × ⌊2³² / 52⌋
= 52 × 82,595,524
= 4,294,967,248 (= 0xFFFFFFD0)
Rejected values: [4,294,967,248 .. 4,294,967,295] = 48 values
Rejection rate: 48 / 2³² ≈ 1.12×10⁻⁸
Accepted values: exactly divisible by 52
→ each card index 0..51 equally likely (zero bias)MAX_FAIR ceiling ensures uniform card indices. Per-chunk rejection rate 48 / 2³² ≈ 1.12×10⁻⁸; full-HMAC failure probability ≈ 2.4×10⁻⁶⁴.The 8-chunk fallback within a single HMAC handles the rare case of an early chunk being rejected; an entire HMAC failing all 8 chunks has probability (48/2³²)⁸ ≈ 2.4×10⁻⁶⁴. To put that in scale, that's roughly the probability of correctly guessing a 213-bit secret on the first try — astronomically smaller than any threat model.
2.4RNG Isolation
Each card draw within a single bet uses a unique cursor value in the HMAC message (clientSeed:nonce:cursor), ensuring per-card outputs are cryptographically independent. Each bet uses a unique nonce, ensuring per-bet outputs are independent. HMAC-SHA256 is a pseudorandom function — knowing one card's output gives zero information about other cards' outputs. There is no shared state between cards, between bets, or between epochs.
Evidence: The getCard(serverSeed, clientSeed, nonce, cursor) implementation in 2.1 confirms this — it is a pure function with no module-scoped state, no file I/O, no network calls, no clock access. Same inputs always produce the same card. If getCard consumed any input outside the four recorded values, the cards would be unverifiable after the fact; because it doesn't, every captured bet is deterministic in perpetuity.
What's not in the algorithm:
- Date.now() or any time-derived value
- Math.random() or any non-deterministic PRNG
- process.env, process.hrtime, os.*
- Any database, file, or network read
- Any mutable global or module-scoped variable
- Any other bet's outcome (cross-bet contamination)
2.5Monte Carlo Simulation (10M Rounds, Fisher's Method)
A 10,000,000-round Monte Carlo simulation (10 streams × 1,000,000 rounds, each stream using a pinned independent seed pair) verified that the Blackjack RNG converges to the analytical optimal-play RTP under the disclosed rules. Blackjack at Duel is a single-configuration game (one rule set: S17, DAS, no surrender, no re-split), so the methodology uses Fisher's combined test to aggregate per-stream RTP-z-test p-values rather than per-config Bonferroni correction.
| Metric | Value |
|---|---|
| Total rounds | 10,000,000 (10 streams × 1,000,000) |
| Fisher's combined p-value | 0.686432 (T = 16.48, df = 20) |
| Streams failing per-stream z-test at Bonferroni-corrected α = 0.001 | 0 / 10 |
| Analytical optimal-play RTP | 99.4296% |
| Simulated RTP (Pass 1, 10M rounds) | 99.4670% |
| simRTP − analytical Δ | +0.0374 pp (within Monte Carlo SE ≈ 0.115 pp per 1M-round stream) |
| Serial independence rejects | 0 / 10 streams |
| Rounds | Cumulative simulated RTP |
|---|---|
| 1,000 | 98.2350% |
| 5,000 | 98.9120% |
| 10,000 | 99.3665% |
| 50,000 | 99.7229% |
| 100,000 | 99.4323% |
| 500,000 | 99.4257% |
| 1,000,000 | 99.4670% |
// Pass 1 simulation seeds — one unique pair per stream.// Generated once via crypto.randomBytes(32/16), pinned for reproducibility:// every reviewer's `npm run simulate` produces bit-identical numbers. No// casino data is used as input — these are independent random bytes.const SIM_SEEDS: Array<{ server: string; client: string }> = [ { server: '2420f708b5b67ad9c5df5fc62e5b5fed4601a1e33d4d145c12b5996090d8dd58', client: 'c772e3090033e1a282fece2e3fe4873a' }, { server: '30983ea650a9338e858743f92189ed8fae282eae955a2f6ea406aa5ba1d6ecf5', client: 'fa8301973e46928cf81ad78205250a0c' }, { server: '951327dd6d9d0ca8731ee47c19875fa1d41b81d4c7a7ca4b547dc4bc2d1936dd', client: '34ff531b7d0f75e39507d82cbdbe2903' }, // ... (10 total — full list in src/simulate.ts)];Methodology rule (per src/simulate.ts): single-config → Fisher's. Each stream runs independently against pinned seeds; per-stream z-test of streamRTP against the analytical optimal-play RTP from src/optimal-play.ts (independent reference); combined via Fisher's method T = −2 Σ ln(p_i) ~ χ²(2K). Serial independence is tested on the full 10M combined stream via lag-1 autocorrelation and Wald-Wolfowitz runs test, with each stream individually screened at Bonferroni-corrected α = 0.001.
The simulation seeds are hardcoded in src/simulate.ts (10 server/client pairs, generated once via crypto.randomBytes and pinned). Running npm run simulate on any reviewer's machine produces bit-identical numbers — same simRTP, same Fisher's p, same convergence chart. No casino data is used as input.
2.6Serial Independence
Serial independence ensures consecutive bet outcomes are not correlated — winning on one bet does not affect the next. Two complementary tests were applied to the full 10M-round Pass 1 sequence, plus a per-stream Bonferroni-corrected z-screen.
Lag-1 autocorrelation: Measures correlation between consecutive payout returns. Expected r ≈ 0 for independent sequences. Per-stream threshold: |z| > 3.291 (Bonferroni-corrected, family-wise α = 0.01).
Wald-Wolfowitz runs test: Tests whether the sequence of above-/below-median returns has the expected number of runs. p < 0.01 indicates non-random structure.
Per-stream Bonferroni screen: Each of 10 streams independently tested at α = 0.001 (family-wise α = 0.01); two-sided z-critical = 3.291. 0 / 10 streams reject.
// Pass 1 — Multi-stream Fisher's method (10 streams × 1M hands = 10M total).// Per-stream test: z-test of streamRTP against the analytical optimal-play// RTP from src/optimal-play.ts (independent reference). Fisher-combined:// T = -2 Σ ln(p_i) ~ χ²(2K).// Serial independence: per-stream lag-1 + runs test, Bonferroni-corrected// per-stream alpha (α = 0.01 / streams).2.7Infinite-Deck Confirmation
Duel's blackjack uses an infinite deck — each card draw is independent at 1/52, with replacement. Step 29 confirms this empirically with a categorical existence test: any single hand containing two visible cards with the exact same rank and suit (e.g., two 2Cs) is impossible under any finite-deck model, so even one observation refutes that alternative. We don't need a chi-squared on the live distribution — one duplicate is enough. The dataset has 1,354 such hands out of 6,000 (22.6% of bets).
| Bet ID | Duplicate observation |
|---|---|
| 13418615 | 2C appeared ≥2× |
| 13418640 | 7C appeared ≥2× |
| 13418645 | 4D appeared ≥2× |
// Step 29: Infinite-Deck Confirmation// Counts hands where ≥2 visible cards share the exact same rank+suit. Such// a hand is impossible under any finite-deck model, so even one observation// refutes the alternative. Reports the full count as the empirical evidence// for the infinite-deck claim. let dupCard: string | null = null;for (const [k, n] of seen) if (n >= 2) { dupCard = k; break; }if (dupCard) { handsWithDuplicate++; if (examples.length < 3) examples.push(`bet ${b.id}: ${dupCard} appeared ≥2×`);}Why this matters: Many casino-side card games shuffle a 52-card deck per bet and deal positions, which prevents repeated cards within a single hand. Duel's blackjack uses the simpler infinite-deck model — each card is drawn independently at 1/52. The two models produce the same long-run hand-rank distribution (the optimal-play RTP solver in src/optimal-play.ts uses the infinite-deck model), but only the infinite-deck model can produce a hand with two identical cards. 1,354 such hands across 6,000 captured bets is conclusive.
2.8Worked Example — Full RNG Trace
Real bet from the dataset — bet 13418619 (Phase A, nonce 1, second bet of the epoch). Action sequence: [hit, stand] — player hit on 15 to make 19, then stood; dealer revealed 7S to make 17 (S17 stand) and lost to player 19. Stake $0.01 → returned $0.02. Verified from data/blackjack-dataset-6000hands.json:
serverSeed = b6f2cbcd411eedbd53587902410f17f43e962f2e374e97ccbec24088debd0556
clientSeed = pf_Naa0pBEOuWmz6
nonce = 1| cursor | role | HMAC[:8] | accepted uint32 | % 52 | card |
|---|---|---|---|---|---|
| 0 | Player card 1 | 52a82fa4 | 1,386,753,956 | 16 | 6D |
| 1 | Dealer upcard | b0ab4391 | 2,964,013,969 | 33 | 10H |
| 2 | Player card 2 | 8c31d570 | 2,352,076,144 | 28 | 9D |
| 3 | Dealer hole (revealed during play-out) | 9d838a66 | 2,642,643,558 | 22 | 7S |
| 4 | Player hit card | cfdc2f60 | 3,487,313,760 | 8 | 4D |
getCard(serverSeed, clientSeed, nonce, cursor) for cursors 0..4. Live amount_won = 0.02 matches verifier expectation 2 × stake = 2 × 0.01 = 0.02 (regular win, no double, no split, no side bets active in this bet's schema). Action sequence [hit, stand] reflects the player's two decisions; the dealer's automatic play-out from cursor 3 onward is rule-driven (S17), not action-driven.Verification — Full HMAC trace, all 5 cursors:
// getCard(// serverSeed = 'b6f2cbcd411eedbd53587902410f17f43e962f2e374e97ccbec24088debd0556',// clientSeed = 'pf_Naa0pBEOuWmz6',// nonce = 1,// cursor = 0..4// )//// Each cursor: HMAC-SHA256(hexDecode(serverSeed), `${clientSeed}:${nonce}:${cursor}`)// Read 32 bytes as 8 big-endian uint32 chunks. First chunk < MAX_FAIR (4,294,967,248) accepted.// Card index = chunk % 52 → CARDS[index].//// ─── CURSOR 0 (Player card 1) ───────────────────────────────────────// message = 'pf_Naa0pBEOuWmz6:1:0'// HMAC = 52a82fa46b1f4bb9204051b4c621babab013c1bd54c78aef5d512fdd691fd3c4// chunk[0] = 0x52a82fa4 = 1386753956 → j = 1386753956 % 52 = 16 → 6D ✅ ACCEPTED// chunk[1] = 0x6b1f4bb9 = 1797213113 (would map j=33 → 10H if chunk[0] had been ≥ MAX_FAIR)// chunk[2] = 0x204051b4 = 541086132 (would map j=28 → 9D)// chunk[3] = 0xc621baba = 3324099258 (would map j=38 → JS)// chunk[4] = 0xb013c1bd = 2954084797 (would map j=1 → 2H)// chunk[5] = 0x54c78aef = 1422363375 (would map j=43 → QC)// chunk[6] = 0x5d512fdd = 1565601757 (would map j=5 → 3H)// chunk[7] = 0x691fd3c4 = 1763693508 (would map j=44 → KD)//// ─── CURSOR 1 (Dealer upcard) ───────────────────────────────────────// message = 'pf_Naa0pBEOuWmz6:1:1'// HMAC = b0ab4391f2297b348d559973e979c9db830455f7c8f6e878217dd1a89449ac91// chunk[0] = 0xb0ab4391 = 2964013969 → j = 2964013969 % 52 = 33 → 10H ✅ ACCEPTED// chunk[1] = 0xf2297b34 = 4062804788 (would map j=16 → 6D)// chunk[2] = 0x8d559973 = 2371197299 (would map j=3 → 2C)// chunk[3] = 0xe979c9db = 3917072859 (would map j=11 → 4C)// chunk[4] = 0x830455f7 = 2198099447 (would map j=11 → 4C)// chunk[5] = 0xc8f6e878 = 3371624568 (would map j=0 → 2D)// chunk[6] = 0x217dd1a8 = 561893800 (would map j=0 → 2D)// chunk[7] = 0x9449ac91 = 2487856273 (would map j=45 → KH)//// ─── CURSOR 2 (Player card 2) ───────────────────────────────────────// message = 'pf_Naa0pBEOuWmz6:1:2'// HMAC = 8c31d57065494f345efeb1f25616c7508b77a5675d688e1998c95797ef3239d7// chunk[0] = 0x8c31d570 = 2352076144 → j = 2352076144 % 52 = 28 → 9D ✅ ACCEPTED// chunk[1] = 0x65494f34 = 1699303220 (would map j=4 → 3D)// chunk[2] = 0x5efeb1f2 = 1593750002 (would map j=26 → 8S)// chunk[3] = 0x5616c750 = 1444333392 (would map j=8 → 4D)// chunk[4] = 0x8b77a567 = 2339874151 (would map j=43 → QC)// chunk[5] = 0x5d688e19 = 1567133209 (would map j=5 → 3H)// chunk[6] = 0x98c95797 = 2563331991 (would map j=51 → AC)// chunk[7] = 0xef3239d7 = 4013046231 (would map j=51 → AC)//// ─── CURSOR 3 (Dealer hole, revealed during play-out) ───────────────// message = 'pf_Naa0pBEOuWmz6:1:3'// HMAC = 9d838a667d74683c1c2656c9beefb99b532d14ab50ff66f6e4ed0491acfffbae// chunk[0] = 0x9d838a66 = 2642643558 → j = 2642643558 % 52 = 22 → 7S ✅ ACCEPTED// chunk[1] = 0x7d74683c = 2104780860 (would map j=0 → 2D)// chunk[2] = 0x1c2656c9 = 472274633 (would map j=25 → 8H)// chunk[3] = 0xbeefb99b = 3203381659 (would map j=23 → 7C)// chunk[4] = 0x532d14ab = 1395463339 (would map j=23 → 7C)// chunk[5] = 0x50ff66f6 = 1358915318 (would map j=46 → KS)// chunk[6] = 0xe4ed0491 = 3840738449 (would map j=41 → QH)// chunk[7] = 0xacfffbae = 2902457262 (would map j=42 → QS)//// ─── CURSOR 4 (Player hit card) ─────────────────────────────────────// message = 'pf_Naa0pBEOuWmz6:1:4'// HMAC = cfdc2f60b0329350d4b0a7eeb6ea91987b0326ff369b54d4f86805599ebf1a25// chunk[0] = 0xcfdc2f60 = 3487313760 → j = 3487313760 % 52 = 8 → 4D ✅ ACCEPTED// chunk[1] = 0xb0329350 = 2956104528 (would map j=0 → 2D)// chunk[2] = 0xd4b0a7ee = 3568347118 (would map j=50 → AS)// chunk[3] = 0xb6ea9198 = 3068826008 (would map j=40 → QD)// chunk[4] = 0x7b0326ff = 2063804159 (would map j=27 → 8C)// chunk[5] = 0x369b54d4 = 916149460 (would map j=44 → KD)// chunk[6] = 0xf8680559 = 4167566681 (would map j=5 → 3H)// chunk[7] = 0x9ebf1a25 = 2663324197 (would map j=1 → 2H)//// ─── Final card stream ──────────────────────────────────────────────// Cursors [0, 1, 2, 3, 4] → ['6D', '10H', '9D', '7S', '4D']// Player initial = [6D, 9D] → 15 (hard)// Dealer up = 10H → peek for BJ (hole 7S, no BJ) → player acts// Player hits → 4D → 19 (hard); player stands// Dealer hole = 7S → reveals to make 17; S17 → stands// Player 19 vs Dealer 17 → PLAYER WINS ✅//// Note: every cursor in this bet accepts on chunk[0] — chunks 1–7 are shown// to illustrate the fallback path that triggers if chunk[0] ≥ MAX_FAIR// (rejection rate per chunk: 48 / 2³² ≈ 1.12×10⁻⁸).Every cursor's chunk[0] is below MAX_FAIR and accepts directly — no fallback to chunks 1–7 is needed. Full 8-chunk-per-cursor trace below.
Technical Evidence & Verification5 sections
2.9Evidence Coverage Summary9 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Outcome recomputation (Step 5) | 6,000 / 6,000 hands · 33,194 card cursors | Pass |
| Key encoding (hex vs UTF-8) | Confirmed via recomputation | Pass |
| Modulo bias analysis | Rejection sampling: MAX_FAIR ceiling = 4,294,967,248 for 52-card range | Pass |
| External entropy non-participation (Step 5) | 6,000 / 6,000 reproduced from (serverSeed, clientSeed, nonce, cursor) only | Pass |
| Anti-circularity gate (Step 16) | Optimal-play RTP solver returns 99.4296% (full exposition in S4) | Pass |
| Simulation Pass 1 (Step 17) | 10M rounds · Fisher's combined p = 0.686432 across 10 streams | Pass |
| Simulation Pass 2 cherry-pick (Step 18) | 11 / 120 seeds flagged at α = 0.05 · binomial p = 0.038450 (full exposition in S4) | Pass |
| Serial independence (Step 17) | lag-1 r₁ = 0.001842 · Wald-Wolfowitz runs test p = 0.196518 | Pass |
| Infinite-deck confirmation (Step 29) | 1,354 / 6,000 hands contain ≥2 visible cards with identical rank+suit | Pass |
2.10Code References7 source files
| File | Purpose |
|---|---|
| src/rng.ts | RNG primitives (hmacSHA256, generateCardFromHash, getCard, MAX_FAIR constant) |
| src/simulate.ts | Monte Carlo simulation (10M Pass 1 + 1.2M Pass 2, Fisher's method) |
| src/optimal-play.ts | Independent recursive infinite-deck EV solver (anti-circularity baseline; full exposition in S4) |
| src/strategy.ts | Basic-strategy decision oracle used by Pass 1 simulator |
| src/stats.ts | Per-stream z-test, Fisher's combined, lag-1 autocorrelation, Wald-Wolfowitz runs test |
| tests/steps/determinism.ts | Steps 5–6: Card recomputation and client seed influence |
| tests/steps/simulation.ts | Steps 16–18: Anti-circularity gate, Pass 1 integrity, Pass 2 cherry-pick detection |
2.11Verified Invariants12 invariants
| Invariant | Result |
|---|---|
| HMAC-SHA256 single-card draw produces correct card for all 33,194 cursor draws across 6,000 bets | Pass |
| Key is hex-decoded (not UTF-8) — wrong encoding produces wrong cards | Pass |
| Rejection sampling at MAX_FAIR = 4,294,967,248 eliminates modulo bias for the 52-card range (per-chunk rejection rate 48 / 2³² ≈ 1.12×10⁻⁸) | Pass |
| getCard consumes only (serverSeed, clientSeed, nonce, cursor) — no other inputs reach the HMAC | Pass |
| Independent reimplementation reproduces every visible card across 6,000 hands (33,194 cursors) | Pass |
| Pass 1 Fisher's combined p ≥ α = 0.01 across 10 streams (p = 0.686432) | Pass |
| 10M simulated rounds converge to analytical optimal-play RTP within Monte Carlo SE (simRTP 99.4670% vs analytical 99.4296%, Δ = +0.0374 pp) | Pass |
| 0/10 streams reject serial independence at Bonferroni-corrected α = 0.001 | Pass |
| Lag-1 autocorrelation negligible on combined 10M-round stream (r₁ = 0.001842) | Pass |
| Wald-Wolfowitz runs test p > 0.01 on combined 10M-round stream (p = 0.1965) | Pass |
| Infinite-deck confirmation — at least one captured hand contains ≥2 visible cards with identical rank+suit (refutes any finite-deck model) for 1,354 / 6,000 bets | Pass |
| Cards differ under wrong client seed (99/100 sampled, analytical baseline ≈ 98.08%) | Pass |
2.12Datasets UsedSource artifacts
2.13Reproduction InstructionsRun locally
Clone the repository, install dependencies, and run the simulation and verification:
git clone https://github.com/ProvablyFair-org/duel-blackjack.gitcd duel-blackjack && npm installnpm run simulate # 10M Pass 1 + 1.2M Pass 2 (~5–10 min)npm run verify # Steps 5, 17, 18, 29 cover S2S2-related steps:
[PASS] Step 5 — Outcome Recomputation
[PASS] Step 17 — Simulation Validation — Pass 1 (Fisher's method, 10M rounds)
[PASS] Step 18 — Pass 2 Cherry-Pick Detection (120 seeds × 10K nonces)
[PASS] Step 29 — Infinite-Deck ConfirmationVerifier Parity
Does the live game actually follow its own rules?
This section validates that the independent verifier produces the exact same cards as the live game for every cursor in every bet. Any mismatch would invalidate the fairness guarantee. We verified all 33,194 card cursors across 6,000 hands — strict ordering for non-split hands, multiset ordering for splits. The section also confirms that each hand outcome is correctly resolved against Duel's published rule set and every payout — including the 3:2 natural and both side bets — matches the posted pay table.
What we verified
- Every bet independently recomputed from seeds — every visible card verified, not just the payout
- All 33,194 card cursors (across 6,000 hands · 269 splits · 1,368 doubled hands) match strict cursor ordering or the multiset check
- Hand classification: every win / lose / push / bust / blackjack outcome recomputes from the cards
- Payout correctness:
amount_won = main + side bet wins, exact for all 6,000 bets (±1×10⁻⁶ tolerance) - Side-bet multiplier provenance: 5,800 PP + 5,800 21+3 classifications match independent re-evaluation from cursors 0, 2, 1
- Bet amount is not an input to the RNG — cards depend only on seeds, nonce, and cursor
- The player's actions (hit/stand/double/split) cannot change the cards — they only determine how many cursors are consumed
What this means for you
- The verifier isn't a simulation — it produces the exact same cards, hand resolutions, and payouts as the live game
- Every bet you play can be independently recomputed by anyone after seed rotation
- No hidden logic alters outcomes based on how much you bet, what your client seed is, or which actions you take
- The game engine in production matches the published algorithm exactly
- Every bet independently recomputed from seeds — every visible card verified, not just the payout
- All 33,194 card cursors (across 6,000 hands · 269 splits · 1,368 doubled hands) match strict cursor ordering or the multiset check
- Hand classification: every win / lose / push / bust / blackjack outcome recomputes from the cards
- Payout correctness:
amount_won = main + side bet wins, exact for all 6,000 bets (±1×10⁻⁶ tolerance) - Side-bet multiplier provenance: 5,800 PP + 5,800 21+3 classifications match independent re-evaluation from cursors 0, 2, 1
- Bet amount is not an input to the RNG — cards depend only on seeds, nonce, and cursor
- The player's actions (hit/stand/double/split) cannot change the cards — they only determine how many cursors are consumed
- The verifier isn't a simulation — it produces the exact same cards, hand resolutions, and payouts as the live game
- Every bet you play can be independently recomputed by anyone after seed rotation
- No hidden logic alters outcomes based on how much you bet, what your client seed is, or which actions you take
- The game engine in production matches the published algorithm exactly
Card stream recomputationPass
Hand classificationPass
Payout correctnessPass
Pay table integrityPass
Bet-size independencePass
Auditor seed coveragePass
Action coveragePass
All 6,000 bets matched the independent verifier exactly — every visible card, every hit / double / split decision, every dealer play-out, every win / lose / push outcome, and every payout (main + side bets) reproduces from (serverSeed, clientSeed, nonce, cursor) across all six capture phases. Every cursor consumed by the bet is recoverable from the recorded inputs.
| Check | Result | Evidence |
|---|---|---|
| Card stream recomputation | Pass | 6,000 / 6,000 bets · 33,194 cursors recompute byte-equal from (serverSeed, clientSeed, nonce, cursor) |
| Hand classification | Pass | All 6,000 bets: win / lose / push / bust / blackjack outcome reconciles from independent hand-value evaluator |
| Payout correctness | Pass | All 6,000 bets: amount_won = main return + side-bet wins, tolerance ±1×10⁻⁶ |
| Pay table integrity | Pass | Main payout tiers (win 2× · BJ 2.5× · push 1× · double 4×) + 5,800 PP + 5,800 21+3 multipliers match config for every bet |
| Bet-size independence | Pass | Phase E (200 bets at $10/hand) — same algorithm produces cards regardless of bet amount; getCard() has no wager parameter |
| Auditor seed coverage | Pass | Phase D (500 bets across 10 auditor seeds) — auditor-controlled seeds recompute byte-equal |
| Action coverage | Pass | All 14,313 available_actions lists are subsets of {hit, stand, double, split, insurance, no_insurance}; 0 surrender occurrences |
Card stream recomputationPass
Hand classificationPass
Payout correctnessPass
Pay table integrityPass
Bet-size independencePass
Auditor seed coveragePass
Action coveragePass
All 6,000 bets matched the independent verifier exactly — every visible card, every hit / double / split decision, every dealer play-out, every win / lose / push outcome, and every payout (main + side bets) reproduces from (serverSeed, clientSeed, nonce, cursor) across all six capture phases. Every cursor consumed by the bet is recoverable from the recorded inputs.
How It Works — Verifier Parity11 sections
3.1Why Parity Matters
Provably-fair algorithms are only useful if the production casino actually runs them. A casino could publish a perfect HMAC-SHA256 specification and then run something else server-side — paying out wins from a different deck, applying hidden multipliers, or silently adjusting card draws based on bet size. Parity testing is the empirical refutation of that scenario: we replay every bet against an independent implementation of the published algorithm, and any deviation — even a single byte — would be visible. For Blackjack, the stakes are higher than a single-step game because every bet involves multiple decisions (deal, hit, double, split, dealer play-out) that each consume cursors. A casino that wanted to bias outcomes could in principle slip in an extra cursor consumption, swap a card mid-stream, or apply a hidden adjustment to the dealer's play-out. Parity catches all of those — every visible card across all 33,194 cursors must match the deterministic recomputation.
3.2Six-Phase Collection Design
Data was collected across six structured phases, each designed to test a specific fairness property. The phases are complementary — together they cover broad RNG sampling, dealer-action coverage, side-bet stress, auditor-controlled client seeds, bet-size invariance, and split-heavy dealing.
| Phase | Bets | Bet Amount | Client Seed | Purpose |
|---|---|---|---|---|
| A — Broad sampling | 3,300 | $0.01 main + $0.01 PP + $0.01 21+3 | 66 distinct (player-rotated) | Primary RNG sampling across many seed pairs and natural action choices |
| B — Targeted hands | 1,000 | $0.01 main + $0.01 PP + $0.01 21+3 | 20 distinct | Targeted sampling for distribution coverage of paired and consecutive deals |
| C — Targeted splits | 500 | $0.01 main + $0.01 PP + $0.01 21+3 | 10 distinct | Stress the split / DAS / one-card-on-aces path |
| D — Auditor client seed | 500 | $0.01 main + $0.01 PP + $0.01 21+3 | 10 (pfaudit_bj_seed00..09) | Verify server honours arbitrary player-supplied client seeds |
| E — Bet-size invariance | 200 | $10 (no side bets) | 4 distinct | Confirm bet amount is not an RNG input — 1,000× the standard $0.01 stake |
| F — Pair-rich seeding | 500 | $0.01 main + $0.01 PP + $0.01 21+3 | 10 distinct | Additional split / side-bet coverage with pair-rich initial deals |
3.3Card Recomputation (Step 5)
The core parity test. For each captured bet, we read (serverSeed, clientSeed, nonce) from the dataset, then recompute every visible card by calling getCard(serverSeed, clientSeed, nonce, cursor) for cursors 0..N-1. Two checking modes are applied: non-split bets use strict cursor ordering — cursor 0 = Player card 1, cursor 1 = Dealer upcard, cursor 2 = Player card 2, cursor 3 = Dealer hole, cursor 4+ = action cards in the order they were drawn (player hits / double cards first, then dealer additional draws); any byte-level mismatch fails the bet. Split bets use a multiset check — the multiset of all visible cards (across both sub-hands and the dealer hand) must equal the multiset of cards produced by cursors 0..N-1, proving no cursor is reused, no card is fabricated, and every dealt card came from the seed.
if (!isSplit) { const seq = buildNonSplitSequence(bet); for (let cursor = 0; cursor < seq.length; cursor++) { const got = getCard(seed, bet.client_seed, bet.nonce, cursor); if (got !== seq[cursor]) { ok = false; break; } cardsChecked++; }} else { const ms = buildSplitMultiset(bet); const recomputed = new Map<string, number>(); for (let cursor = 0; cursor < ms.count; cursor++) { const c = getCard(seed, bet.client_seed, bet.nonce, cursor); recomputed.set(c, (recomputed.get(c) || 0) + 1); } if (recomputed.size !== ms.multiset.size) ok = false; if (ok) { for (const [k, v] of ms.multiset) { if (recomputed.get(k) !== v) { ok = false; break; } } } cardsChecked = ms.count;}(serverSeed, clientSeed, nonce) inputs.3.4Payout Math (Step 7)
Every bet's payout reconciles independently. For each captured bet, we compute the expected gross return from the per-hand outcomes plus side-bet wins, then compare against the API-reported amount_won. Per-hand return (× original stake): win = 2×, blackjack = 2.5×, push = 1×, lose / bust = 0. Doubled hand doubles the wager: doubled win = 4×, doubled push = 2×, doubled lose / bust = 0. Side bets (PP and 21+3) add the deal-time amount_won from the side-bet results.
let expected = 0; for (const hand of final.player.hands) { const wager = hand.has_doubled ? 2 : 1; switch (hand.result) { case 'win': expected += stake * 2 * wager; break; case 'blackjack': expected += stake * 2.5; break; // only on unsplit, undoubled case 'push': expected += stake * wager; break; case 'lose': case 'bust': break; // 0 default: return null; // unknown result → cannot reconcile } } const sb = deal.side_bet_results; if (sb) { if (sb.side_perfect_pairs) expected += parseFloat(sb.side_perfect_pairs.amount_won || '0'); if (sb.side_21_plus_3) expected += parseFloat(sb.side_21_plus_3.amount_won || '0'); } const actual = parseFloat(bet.amount_won); return { expected, actual, ok: Math.abs(expected - actual) < TOL };3.5Side-Bet Multiplier Provenance (Step 8)
Both side bets (Perfect Pairs and 21+3) use deal-time inputs only — Player card 1 (cursor 0), Player card 2 (cursor 2), Dealer upcard (cursor 3) — and lock in their multiplier the moment the deal is placed. Step 8 independently re-classifies every side bet by feeding those three cards into our own implementation in src/sidebets.ts, then compares the resulting multiplier against the API's reported multiplier for the same hand. 0 mismatches in 11,600 side bets (5,800 PP + 5,800 21+3). Every multiplier tier was hit and verified — even the rarest (suited trips, 2 occurrences in 5,800 active bets).
| Side Bet | Multiplier | Hits / 5,800 |
|---|---|---|
| PP — Perfect Pair (same rank + same suit) | 27× | 113 |
| PP — Colored Pair (same rank + same colour) | 11× | 122 |
| PP — Mixed Pair (same rank + different colour) | 7× | 224 |
| 21+3 — Suited Trips (3 cards same rank + same suit) | 121.2307692× | 2 |
| 21+3 — Straight Flush (3 consecutive same suit) | 53× | 11 |
| 21+3 — Three of a Kind (3 cards same rank, different suits) | 32× | 33 |
| 21+3 — Straight (3 consecutive any suits) | 12× | 181 |
| 21+3 — Flush (3 same suit, not consecutive, not same rank) | 5× | 342 |
export function classifyPerfectPairs(card1: string, card2: string): PerfectPairsResult { const r1 = cardRank(card1); const r2 = cardRank(card2); if (r1 !== r2) return null; const s1 = cardSuit(card1); const s2 = cardSuit(card2); if (s1 === s2) return 'perfect_pair'; const c1 = cardColor(card1); const c2 = cardColor(card2); if (c1 === c2) return 'colored_pair'; return 'mixed_pair';}3.6Phase E Bet-Size Invariance (Step 9)
Phase E placed 200 bets at $10 per hand — 1,000× the standard $0.01 stake — to confirm the bet amount is not an input to the RNG. All 200 hands recompute byte-equal from (serverSeed, clientSeed, nonce, cursor) regardless of stake; the structural reason is that getCard() has no wager parameter.
export function getCard( serverSeed: string, clientSeed: string, nonce: number, cursor: number,): string;// ↑ no wager parameter, no stake input — the function is mathematically identical at any bet size3.7Phase D Auditor Client Seed (Step 15)
Phase D used 10 auditor-controlled client seeds (pfaudit_bj_seed00 through pfaudit_bj_seed09) across 500 bets to verify the server honours arbitrary player-supplied seeds. If the server ignored the player's seed and used a different one internally, the verifier's recomputed cards under the advertised seed would not match the live cards. They do match, for all 500 hands.
pfaudit_bj_seed00, pfaudit_bj_seed01, pfaudit_bj_seed02, …, pfaudit_bj_seed09). Recompute: 500 ok, 0 failed, 0 skipped. The server uses exactly the client seed the player set — not a substitute, not a derivative, not a reweighted version.3.8Dealer-Side Rules (Steps 19, 25, 28, 30, 32)
Five checks verify the dealer side of every bet — confirming that S17 dealer compliance, insurance prompt logic, available-action enforcement, deal-time structure, and outcome bucketing all behave as specified.
| Check | Step | Coverage | Result |
|---|---|---|---|
| Dealer Rule Compliance (S17) | 19 | 4,226 hands where dealer played out (others skipped — player BJ, dealer BJ, or all player hands resolved). 4,226 compliant, 0 violations. Soft-17 outcomes: 0 hits, 75 stands → confirms stand on soft 17 | Pass |
| Insurance Prompt Condition | 25 | 462 insurance prompts, all on dealer Ace upcard, 0 on non-Ace upcard (must be 0). All declined by the capture script — payout side not exercised by this dataset | Pass |
| Available Actions Set | 28 | 14,313 / 14,313 available_actions lists are subsets of {hit, stand, double, split, insurance, no_insurance}. surrender appearances: 0 (rules state 0) | Pass |
| Outcome Distribution | 30 | 6,269 / 6,269 player hands carry a result field. Buckets: blackjack=260, bust=1318, lose=1916, push=514, win=2261. surrender=0 (rules state 0) | Pass |
| Initial Deal Structure | 32 | 6,000 / 6,000 deals show valid initial structure (2 player cards + 2 dealer cards; dealer hole face-down except for 443 auto-resolved natural-BJ hands where it is legitimately revealed). 0 failures across all three structural sub-checks | Pass |
available_actions quietly include or omit options inconsistent with the game state (e.g., split offered on non-paired cards, or surrender appearing despite being disabled)? Step 30 — does the outcome bucket (win / lose / push / bust / blackjack) reconcile to the player and dealer hand values? Step 32 — does the initial deal always record exactly 4 cards in the documented order? All five close cleanly — 0 violations across 6,000 bets.3.9Player-Side Mechanics (Steps 22, 23, 24)
Three checks verify the split and double mechanics — the most complex player-side branches in blackjack.
| Check | Step | Coverage | Result |
|---|---|---|---|
| Split Cursor Ordering | 22 | 269 / 269 split bets: every visible card across both sub-hands and the dealer hand matches a unique cursor 0..N-1 from the seed (multiset check — proves no cursor is reused and every dealt card came from the seed) | Pass |
| Split Rules Verification | 23 | 269 splits. Matching-rank-only: 269 / 269 (0 violations of the matching-rank requirement). DAS observed: 109. Re-splits: 0. Split-aces one-card-only: 34 / 34 (0 violations) | Pass |
| Split Payout Independence | 24 | 269 / 269 split bets reconcile to the sum of per-hand settlements (each sub-hand vs dealer independently — expected = Σ per-hand expected). 0 mismatched | Pass |
amount_won. All three pass.3.10Side-Bet Mechanics (Steps 26, 27, 31)
Three checks verify Perfect Pairs and 21+3 side bets are settled correctly and consistently across every bet.
| Check | Step | Coverage | Result |
|---|---|---|---|
| Side-Bet Payout (Multiplier × Stake) | 26 | PP: 5,800 reconciled, 0 mismatched. 21+3: 5,800 reconciled, 0 mismatched. amount_won = multiplier × amount_placed at API precision | Pass |
| Side-Bet Independence (deal vs final) | 27 | PP: 5,800 unchanged across deal → final, 0 changed. 21+3: 5,800 unchanged. Side-bet result is deal-time invariant — does not mutate as the main hand plays out | Pass |
| Side-Bet Stake Equality | 31 | PP stake = main stake: 5,800 / 5,800. 21+3 stake = main stake: 5,800 / 5,800 — every active side bet was wagered at the same stake as the main hand | Pass |
3.11Worked Example — Full Parity Verification
Real bet from Phase F — bet 13481476, Phase F, nonce 47, stake $0.01. Dealer's upcard QH sat against player pocket Jacks JC + JD. The player split the Jacks; sub-hand 0 hit 6S then doubled to 3C for [JC, 6S, 3C] = 19 (doubled-lose vs dealer 20); sub-hand 1 took the next cursor AS for [JD, AS] = 21 (regular win vs dealer 20). The Perfect Pairs side bet hit Mixed Pair (JC club black + JD diamond red) for 7×. Final return: $0.09 from a $0.03 total stake (main + PP + 21+3). Verified end-to-end from data/blackjack-dataset-6000hands.json:
serverSeed = 4f1f3f8a46b0078d4120daf13c4f27677f1b6d7cac34111231c1fe8222559335
clientSeed = pf_Kf3fXJ0vOBr3a
nonce = 47
phase = F
main stake = $0.01
PP stake = $0.01 (Mixed Pair, 7× → won $0.07)
21+3 stake = $0.01 (no qualifying combo, won $0.00)
actions = ['split', 'double']| Step | Process | Output |
|---|---|---|
| 1 | Independent HMAC-SHA256 recomputation: 7 calls with cursor 0..6 | Card stream [JC, QH, JD, QC, 6S, AS, 3C] |
| 2 | Initial deal: cursors 0/1/2/3 | P=[JC, JD]=20 · D up=QH · D hole=QC |
| 3 | PP classification (cursors 0, 2): JC + JD — same rank, different colour | mixed_pair → 7× |
| 4 | 21+3 classification (cursors 0, 2, 1): JC + JD + QH — none of trip / straight / flush | null → 0× |
| 5 | Player splits Jacks (matching rank ✓, allowed by Step 23). Sub-hand 0 hits 6S, then doubles 3C → [JC, 6S, 3C] = 19 | sub-0: 19 (doubled) |
| 6 | Sub-hand 1 receives one card AS (split-aces would receive only one and stand; J split allows further play, but player stands here on 21) | sub-1: [JD, AS] = 21 |
| 7 | Dealer reveals hole QC → QH + QC = 20. S17, stands on hard 20 | Dealer 20 |
| 8 | Resolution: sub-0 (19, doubled) loses to dealer 20 → 0; sub-1 (21) wins → 2 × $0.01 = $0.02 | sub-0 = 0; sub-1 = $0.02 |
| 9 | Side-bet payouts: PP = 7 × $0.01 = $0.07 · 21+3 = 0 × $0.01 = $0.00 | PP $0.07; 21+3 $0.00 |
| 10 | Total: $0.02 (sub-1) + $0.07 (PP) + $0.00 (21+3) | **$0.09 ✅** |
// Cursor stream from getCard(serverSeed, clientSeed=pf_Kf3fXJ0vOBr3a, nonce=47, cursor):// cursor 0 → JC (Player card 1)// cursor 1 → QH (Dealer upcard)// cursor 2 → JD (Player card 2)// cursor 3 → QC (Dealer hole)// cursor 4 → 6S (sub-hand 0 hit card)// cursor 5 → AS (sub-hand 1 dealt card after split)// cursor 6 → 3C (sub-hand 0 double card)//// Multiset of visible cards (Step 22 check):// Player sub-hand 0: [JC, 6S, 3C]// Player sub-hand 1: [JD, AS]// Dealer: [QH, QC]// ────────────────────────────────// Multiset: {JC, QH, JD, QC, 6S, AS, 3C} = 7 unique cards//// Recomputed cursors 0..6:// {JC: 1, QH: 1, JD: 1, QC: 1, 6S: 1, AS: 1, 3C: 1}//// Multiset match: ✅ identical0 (sub-0 doubled lose) + $0.02 (sub-1 win) + $0.07 (PP) + $0.00 (21+3) = $0.09. This single bet exercises split, double-after-split, side-bet payout, and per-hand independent settlement — and every piece matches.Technical Evidence & Verification5 sections
3.12Evidence Coverage Summary17 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Outcome recomputation (Step 5) | 6,000 / 6,000 hands · 33,194 card cursors (strict for non-split, multiset for split) | Pass |
| Payout reconciliation (Step 7) | 6,000 / 6,000 bets reconcile (expected = main + side bet wins, ±1×10⁻⁶) | Pass |
| Side-bet multiplier provenance (Step 8) | PP: 5,800 / 5,800 · 21+3: 5,800 / 5,800 — 0 mismatches | Pass |
| Bet-size invariance (Step 9) | 200 / 200 Phase E ($10) hands recompute byte-equal | Pass |
| Auditor seed coverage (Step 15) | 500 / 500 Phase D hands across 10 auditor seeds (pfaudit_bj_seed00..09) | Pass |
| Dealer S17 compliance (Step 19) | 4,226 / 4,226 hands where dealer played out — 0 violations · 75 soft-17 stands, 0 soft-17 hits | Pass |
| Split cursor ordering (Step 22) | 269 / 269 split bets pass multiset check | Pass |
| Split rules verification (Step 23) | 269 splits — matching-rank-only, no re-splits, ace-one-card all enforced | Pass |
| Split payout independence (Step 24) | 269 / 269 reconcile per-hand | Pass |
| Insurance prompt condition (Step 25) | 462 / 462 prompts on dealer Ace upcards (must be 0 on non-Ace) | Pass |
| Side-bet payouts (Step 26) | PP: 5,800 / 5,800 · 21+3: 5,800 / 5,800 reconcile at API precision | Pass |
| Side-bet deal-time invariance (Step 27) | PP: 5,800 / 5,800 unchanged · 21+3: 5,800 / 5,800 unchanged | Pass |
| Available actions set (Step 28) | 14,313 / 14,313 lists are subsets of allowed actions; surrender appearances: 0 | Pass |
| Outcome distribution (Step 30) | 6,269 / 6,269 player hands have a result field | Pass |
| Side-bet stake equality (Step 31) | PP: 5,800 / 5,800 · 21+3: 5,800 / 5,800 | Pass |
| Initial deal structure (Step 32) | 6,000 / 6,000 deals show valid initial structure | Pass |
| Stake bracket bounds (Step 33) | 6,000 / 6,000 bets at the expected stake for their phase | Pass |
3.13Code References8 source files
| File | Purpose |
|---|---|
| src/rng.ts | Card generation + utilities (getCard, hashServerSeed, cardRank, cardSuit, cardColor, handValue, isBlackjack, isBust, DEAL_ORDER) |
| src/sidebets.ts | Independent side-bet classifiers (classifyPerfectPairs, classifyTwentyOnePlus3, getPPMultiplier, get21Plus3Multiplier, evaluateSideBets) |
| src/loader.ts | Dataset loading + epoch grouping (buildSeedMap, groupByHash) |
| tests/steps/determinism.ts | Step 5 (outcome recomputation, strict / multiset modes) and Step 6 (client seed influence) |
| tests/steps/payouts.ts | Steps 7–9: Payout reconciliation, multiplier provenance, Phase E bet-size invariance |
| tests/steps/dataset.ts | Steps 11–15: Config completeness, epoch size, phase labels, dataset hash, Phase D variation |
| tests/steps/game-specific.ts | Steps 19, 22–27: Dealer compliance, split mechanics, insurance, side bets |
| tests/steps/rules.ts | Steps 28, 30, 32, 33: Available actions, outcome distribution, deal structure, stake brackets |
3.14Datasets UsedSource artifacts
3.15Verified Invariants19 invariants
| Invariant | Result |
|---|---|
| Strict cursor ordering verified for all 5,731 non-split bets (cursor 0=P1, 1=D up, 2=P2, 3=D hole, 4+ = action cards in order) | Pass |
| Split bet visible-card multiset matches cursors 0..N−1 for all 269 split bets | Pass |
| amount_won reconciles to Σ per-hand return + side-bet wins (±1×10⁻⁶) for all 6,000 bets | Pass |
| Per-hand return rule (win=2×, blackjack=2.5×, push=1×, lose/bust=0; doubled-hand wager doubles) reconciles for all 6,000 bets | Pass |
| Perfect Pairs and 21+3 multipliers match independent classification from cursors 0, 2, 1 for all 11,600 active side bets | Pass |
| Phase E ($10/hand) cards recompute byte-equal to $0.01 procedure (200 / 200 bets) | Pass |
| Phase D auditor seeds (pfaudit_bj_seed00..09) recompute correctly for all 500 bets | Pass |
| Dealer obeys S17 — hits all hard < 17, stands on every hard 17+ and every soft 17 (4,226 / 4,226 played-out hands; 75 soft-17 stands, 0 soft-17 hits) | Pass |
| Splits offered only on matching ranks; no rank-mixed splits across 269 splits | Pass |
| Re-splits not offered (player can split once, never more); 0 / 269 re-splits observed | Pass |
| Split aces receive exactly one additional card and then stand (34 / 34 ace-split sub-hands) | Pass |
| Split payouts reconcile per-hand for all 269 splits | Pass |
| Insurance offered if and only if dealer upcard is Ace (462 / 462 prompts on Ace · 0 / 462 on non-Ace) | Pass |
| available_actions ⊆ {hit, stand, double, split, insurance, no_insurance} for all 14,313 action lists | Pass |
| surrender never appears in available_actions or as a result value (0 / 14,313 actions · 0 / 6,269 results) | Pass |
| Perfect Pairs and 21+3 deal-time results immutable across deal → final for all 11,600 active side bets | Pass |
| 6,000 main payouts + 11,600 side-bet payouts reconcile against API multiplier precision (0 mismatches) | Pass |
| Initial deal records exactly 4 cards (P1, D up, P2, D hole) for all 6,000 bets — no out-of-order or missing visible cards | Pass |
| Stake brackets ($0.01 main + $0.01 PP + $0.01 21+3 in Phases A/B/C/D/F · $10 main no side bets in Phase E) consistent across all 6,000 bets | Pass |
3.16Reproduction InstructionsRun locally
Clone the repository, install dependencies, and run the verification suite:
git clone https://github.com/ProvablyFair-org/duel-blackjack.gitcd duel-blackjack && npm installnpm run verify# Expected output: Steps 5, 7, 8, 9, 15, 19, 22–28, 30–33 all PASSS3-related steps:
[PASS] Step 5 — Outcome Recomputation
[PASS] Step 7 — Payout Math
[PASS] Step 8 — Multiplier Provenance
[PASS] Step 9 — Bet-Size Invariance
[PASS] Step 15 — Phase D Client Seed Variation
[PASS] Step 19 — Dealer Rule Compliance
[PASS] Step 22 — Split Cursor Ordering
[PASS] Step 23 — Split Rules Verification
[PASS] Step 24 — Split Payout Independence
[PASS] Step 25 — Insurance Prompt Condition
[PASS] Step 26 — Perfect Pairs + 21+3 Payout
[PASS] Step 27 — Side Bet Independence
[PASS] Step 28 — Available Actions Set
[PASS] Step 30 — Outcome Distribution
[PASS] Step 31 — Side Bet Stake Equality
[PASS] Step 32 — Initial Deal Structure
[PASS] Step 33 — Stake Bracket BoundsRTP & Payout Logic
Is the house edge what the casino claims?
This section verifies the game's RTP and house edge exactly, and confirms every payout follows the published rules. The headline optimal-play RTP of 99.4296% (a 0.5704% house edge) is proven from first principles by an independent strategy solver — the optimal decision is enumerated for every player hand against every dealer up-card, with no operator-supplied figure entering the chain. It cross-validates against Wizard of Odds for Duel's exact rule set and is corroborated by 10 million simulated rounds. Every captured hand's payout reconciles against the published rules — including 3:2 naturals, doubled-hand settlement, and side-bet EV — and cherry-pick detection replays all 120 committed server seeds to rule out seed pre-selection.
What we verified
- House edge is 0.5704% under Duel's rule set (S17, DAS, no surrender, no re-split) — flat across all bet sizes
- Optimal-play RTP 99.4296% proven from first principles: independent recursive infinite-deck EV solver consuming only the disclosed rules
- Cross-validated against Wizard of Odds for Duel's exact rule set — match to <1×10⁻⁶
- 10M simulated rounds converge to the analytical 99.4296% target — no drift across 10 independent streams
- Cherry-pick detection across all 120 captured server seeds — no evidence of seed pre-selection
- Doubled-hand payouts reconcile across 1,291 doubled bets (1,368 doubled hands · win 4× · push 2× · lose/bust 0)
- Natural blackjack pays 3:2 — 260 / 260 player naturals paid correctly
- Side bets: Perfect Pairs and 21+3 are both zero-EV — 11,600 active side-bet payouts reconcile
What this means for you
- The 0.5704% figure is an expected-value average over many hands, not a per-hand deduction
- The 99.4296% claim is the optimal-play RTP — players who deviate from basic strategy earn less
- The RTP proof is derived independently — it doesn't rely on trusting the casino
- The casino's seeds show no evidence of being chosen to produce favourable early outcomes
- The side bets carry no built-in edge either way — they don't improve or worsen your expected return
- Your bet amount doesn't affect which cards are dealt
- House edge is 0.5704% under Duel's rule set (S17, DAS, no surrender, no re-split) — flat across all bet sizes
- Optimal-play RTP 99.4296% proven from first principles: independent recursive infinite-deck EV solver consuming only the disclosed rules
- Cross-validated against Wizard of Odds for Duel's exact rule set — match to <1×10⁻⁶
- 10M simulated rounds converge to the analytical 99.4296% target — no drift across 10 independent streams
- Cherry-pick detection across all 120 captured server seeds — no evidence of seed pre-selection
- Doubled-hand payouts reconcile across 1,291 doubled bets (1,368 doubled hands · win 4× · push 2× · lose/bust 0)
- Natural blackjack pays 3:2 — 260 / 260 player naturals paid correctly
- Side bets: Perfect Pairs and 21+3 are both zero-EV — 11,600 active side-bet payouts reconcile
- The 0.5704% figure is an expected-value average over many hands, not a per-hand deduction
- The 99.4296% claim is the optimal-play RTP — players who deviate from basic strategy earn less
- The RTP proof is derived independently — it doesn't rely on trusting the casino
- The casino's seeds show no evidence of being chosen to produce favourable early outcomes
- The side bets carry no built-in edge either way — they don't improve or worsen your expected return
- Your bet amount doesn't affect which cards are dealt
Anti-circularityPass
Optimal-play RTP solverPass
Solver cross-validationPass
House edge auditPass
Simulated RTP (Pass 1)Pass
Cherry-pick detection (Pass 2)Pass
Pay table integrityPass
Side-bet EVPass
Both the optimal-play RTP and house edge are verified. Independent first-principles enumeration produces 99.4296%, cross-validated against Wizard of Odds for Duel's exact rule set. 10 million simulated rounds converge to that target without surprise. Cherry-pick detection across all 120 captured server seeds finds no evidence of seed pre-selection. The house edge is 0.5704% — flat across all bet sizes, fixed by the rules and not tunable via per-bet metadata.
| Check | Result | Evidence |
|---|---|---|
| Anti-circularity | Pass | Solver reads only rule constants and 13-rank infinite-deck probabilities — no casino-supplied RTP or edge values feed in |
| Optimal-play RTP solver | Pass | 99.4296% from independent recursive infinite-deck enumeration |
| Solver cross-validation | Pass | Cross-validated against Wizard of Odds for Duel's exact rule set — match to <1×10⁻⁶ |
| House edge audit | Pass | 0.5704% — flat across all bet sizes, no scaling structure |
| Simulated RTP (Pass 1) | Pass | 10M rounds converge to 99.4670% (Fisher's p = 0.686432; 0/10 streams reject independence) |
| Cherry-pick detection (Pass 2) | Pass | 11/120 flagged at α=0.05 (binomial p = 0.038450 — marginal under a single-test reading; not significant after multiple-comparisons correction) |
| Pay table integrity | Pass | All payout tiers (main + 5,800 PP + 5,800 21+3) match the deployed multiplier table for every bet |
| Side-bet EV | Pass | Perfect Pairs EV = 0 by algebraic identity; 21+3 EV ≈ 0 by table balance |
Anti-circularityPass
Optimal-play RTP solverPass
Solver cross-validationPass
House edge auditPass
Simulated RTP (Pass 1)Pass
Cherry-pick detection (Pass 2)Pass
Pay table integrityPass
Side-bet EVPass
Both the optimal-play RTP and house edge are verified. Independent first-principles enumeration produces 99.4296%, cross-validated against Wizard of Odds for Duel's exact rule set. 10 million simulated rounds converge to that target without surprise. Cherry-pick detection across all 120 captured server seeds finds no evidence of seed pre-selection. The house edge is 0.5704% — flat across all bet sizes, fixed by the rules and not tunable via per-bet metadata.
How It Works — RTP & Payout Logic10 sections
4.1RTP & House Edge Targets
The blackjack RTP comes from the rules of the game, not from a pay-table the casino can tune. Once Duel's rule set is fixed (S17, DAS, 3:2 naturals, no surrender, no re-split, infinite deck), the math is fixed too. With perfect basic strategy, the long-run return is 99.4296% per dollar wagered on the main bet — the ceiling a player can achieve at this rule set.
| Metric | Value |
|---|---|
| Optimal-play RTP | 99.4296% |
| House edge | 0.5704% |
4.2Optimal-Play RTP Solver (Step 16)
The solver enumerates all 1,000 = 10 × 10 × 10 initial (P1 rank, P2 rank, dealer-up rank) triples weighted by infinite-deck rank probabilities (1/13 for each of 2..9, A; 4/13 for any 10-valued card T = {10, J, Q, K}). For each triple it computes the player's optimal expected return — taking into account whether the dealer peeks for blackjack, whether the player has a natural, and the optimal stand / hit / double / split decision at every state.
| Output | Value |
|---|---|
| Optimal-play RTP | 99.4296% |
| House edge | 0.5704% |
| Player BJ frequency | 4.7337% (= 2 × P[T] × P[A] = 2 × 4/13 × 1/13) |
| Dealer BJ frequency | 4.7337% (same, infinite-deck) |
| Total triples enumerated | 1,000 |
export function computeOptimalRTP(s17: S17 = 'stand', das: boolean = true): OptimalPlayResult { let rtpSum = 0; let playerBJProb = 0; for (const p1 of RANKS) { for (const p2 of RANKS) { const playerBJ = (p1 === 'T' && p2 === 'A') || (p1 === 'A' && p2 === 'T'); if (playerBJ) playerBJProb += P[p1] * P[p2]; for (const up of RANKS) { const pCombo = P[p1] * P[p2] * P[up]; const upPeeks = up === 'A' || up === 'T'; const pDealerBJ = up === 'A' ? P['T'] : up === 'T' ? P['A'] : 0; let returnRatio: number; // total returned / wagered if (playerBJ && upPeeks) { // Player BJ vs dealer that may have BJ: push if dealer BJ, else 2.5× returnRatio = pDealerBJ * 1 + (1 - pDealerBJ) * 2.5; } else if (playerBJ) { returnRatio = 2.5; } else if (upPeeks) { // Dealer might have BJ — if so player loses, else play optimally const optEV = playerOptimalEV(p1, p2, up, s17, das); returnRatio = pDealerBJ * 0 + (1 - pDealerBJ) * (1 + optEV); } else { const optEV = playerOptimalEV(p1, p2, up, s17, das); returnRatio = 1 + optEV; } rtpSum += pCombo * returnRatio; } } } // Sanity normalization: P(any combo) sums to 1 const dealerBJProb = 2 * P['T'] * P['A']; // P(dealer 2-card BJ) return { rtp: rtpSum, edge: 1 - rtpSum, playerBJFreq: playerBJProb, dealerBJFreq: dealerBJProb, s17, das, };}99.4296% from independent recursive infinite-deck EV enumeration. The solver reads only rule constants (S17, DAS, blackjack-pays-3:2, infinite deck) and the 13-rank probability vector. No casino-supplied RTP, edge, or per-bet field appears in the calculation chain — this is the anti-circularity guarantee for the headline number. ¹The recursion underneath playerOptimalEV covers stand / hit / double / split exhaustively. dealerDist produces the dealer's terminal-total distribution from any starting state under S17. splitEV correctly handles the rules: matching-rank pairs only, DAS allowed, one card on split aces, no re-splitting.
4.3Wizard of Odds Cross-Validation
An independent solver can be cross-validated in two ways: replicate its inputs and check the output, or feed it a problem with a known expected output and check that the output matches. We do the second — we compare the solver's emitted 99.4296% against Wizard of Odds' published reference RTP for Duel's exact rule set (S17 + DAS, no surrender, no re-split, infinite deck). WoO is a widely-cited independent reference for blackjack mathematics, maintained separately from any casino, by a third party who has no commercial relationship with Duel.com.
The cross-check is enforced as a hard PASS gate in tests/steps/simulation.ts — if the solver's result drifts outside 0.02 percentage points of the WoO reference, Step 16 fails. The actual delta is essentially zero (≈1.2×10⁻⁷), well inside tolerance.
| Source | Value |
|---|---|
BJ solver (src/optimal-play.ts) | 99.4296% |
| Wizard of Odds reference (Duel's exact rules) | 99.4296% |
| Δ (solver − WoO reference) | ≈1.2×10⁻⁷ |
| Tolerance gate | ±0.02 pp (= 2×10⁻⁴) |
| Within tolerance? | Pass |
// Wizard of Odds reference for Duel's exact rule set (infinite deck S17 + DAS,// no surrender, no re-split), used as a cross-check. The audit's// authoritative figure is computed by computeOptimalRTP.const WOO_REFERENCE = 0.994296;const WOO_TOLERANCE = 0.0002; // 0.02 percentage points const main = computeOptimalRTP('stand', true);const wooDelta = main.rtp - WOO_REFERENCE;const wooWithinTol = Math.abs(wooDelta) <= WOO_TOLERANCE; // PASS criterion requires the WoO cross-check to land within tolerance —// a buggy engine returning, say, 0.97 would fail this gate even though// the value is finite.const ok = ppOk && t3Ok && engineSane && wooWithinTol;4.4Anti-Circularity Proof — Optimal-Play RTP (Step 16)
The anti-circularity proof establishes the optimal-play RTP of 99.4296% from first principles without using any casino-supplied figure. This is what separates a mathematical proof from a statistical estimate. The proof consumes only two kinds of input — each independent of the casino:
| Component | Source | Independent of casino? |
|---|---|---|
| Game rules (S17, DAS, 3:2 BJ, no surrender, no re-split) | Disclosed at /api/v2/blackjack/config + verified live in S3 (Steps 19–28) | Yes — verified against live play, not against casino's RTP claim |
| 13-rank card probabilities | Combinatorics — 2–9, A at 1/13; T at 4/13 (4 ten-valued ranks) | Yes — derivable from a 52-card deck |
| Recursive EV solver | src/optimal-play.ts — independent reimplementation | Yes — coded from game-theoretic specification, not copied from any reference |
| Live RNG simulation | src/simulate.ts — runs RNG primitives + basic-strategy oracle across 10M rounds | Yes — bit-identical on every reviewer's machine; pinned seeds, no live data |
| Casino-supplied RTP / edge values | (none consumed) | — |
// Card probabilities — pure combinatorics, no casino inputconst RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', 'T', 'A'] as const; const P: Record<Rank, number> = { '2': 1/13, '3': 1/13, '4': 1/13, '5': 1/13, '6': 1/13, '7': 1/13, '8': 1/13, '9': 1/13, 'T': 4/13, 'A': 1/13,};This is the entire universe of probability input the solver consumes. Anyone can recompute 99.4296% from this constant declaration plus the disclosed rule set — no casino-side data needed.
99.4296% from inputs that are either combinatorial (1/13, 4/13) or live-verified (the rule set in S3). At no point does the chain consume the casino's claimed RTP, the effective_edge field, or any other casino-provided number that would let the casino retroactively define what "correct" means.src/optimal-play.ts.4.5House Edge Audit (Step 10)
The casino's API emits an effective_edge field on every bet — 0.1 on the main bet (6,000 / 6,000 constant) and 0 on both side bets (5,800 / 5,800 each, constant). effective_edge is the operator's Zero Edge rakeback signal: the main bet is tagged 0.1 at settlement and updates to 0 asynchronously once an operator-side rewards queue processes the rakeback. The side bets read 0 for a different reason — they are zero-edge bets by construction, not by rakeback: this audit proves Perfect Pairs EV = 0 and 21+3 EV ≈ 0 directly (sub 4.8). The field is not a game-logic input. Three lines of evidence confirm it does not affect outcomes: (i) source review across src/ finds no operational reader — it appears only in type definitions, (ii) Step 7's payout reconciliation works from a formula that doesn't reference the field, and (iii) the optimal-play RTP solver in 4.2 derives 99.4296% from inputs that don't include it. The rakeback layer itself is operator-side and outside the scope of this audit — see the exclusions list.
| Evidence | Source |
|---|---|
| No operational reader | Source review across src/*.ts |
| Payouts reconcile without it | Step 7 — uses amount_won = main + side-bet wins formula only |
| Optimal-play RTP derived without it | Step 16 / sub 4.2 — solver inputs are rule constants + 13-rank probabilities |
const EDGE_TOL = 1e-9; for (const b of ctx.bets) { // Main bet effective_edge — at the bet root if (Math.abs(b.effective_edge - 0.1) < EDGE_TOL) mainOk++; else { mainBad++; if (badExamples.length < 3) badExamples.push(`Bet ${b.id}: main edge=${b.effective_edge}`); } // Side bet effective_edges const sb = b.deal?.blackjack?.side_bet_results; if (sb) { if (sb.side_perfect_pairs && parseFloat(sb.side_perfect_pairs.amount_placed || '0') > 0) { if (Math.abs((sb.side_perfect_pairs.effective_edge ?? -1) - 0) < EDGE_TOL) ppOk++; else { ppBad++; if (badExamples.length < 3) badExamples.push(`Bet ${b.id}: PP edge=${sb.side_perfect_pairs.effective_edge}`); } } if (sb.side_21_plus_3 && parseFloat(sb.side_21_plus_3.amount_placed || '0') > 0) { if (Math.abs((sb.side_21_plus_3.effective_edge ?? -1) - 0) < EDGE_TOL) t3Ok++; else { t3Bad++; if (badExamples.length < 3) badExamples.push(`Bet ${b.id}: 21+3 edge=${sb.side_21_plus_3.effective_edge}`); } } }}effective_edge = 0.1: 6,000 / 6,000. PP effective_edge = 0: 5,800 / 5,800. 21+3 effective_edge = 0: 5,800 / 5,800. Constancy confirmed. The main-bet 0.1 is the pre-rakeback Zero Edge tag; the side-bet 0 reflects their zero-edge construction (proven in 4.8), not a rakeback adjustment.4.6Simulation Pass 1 — 10M Rounds (Step 17)
Sub 4.2 derives the optimal-play RTP of 99.4296% from first principles; sub 4.3 cross-validates it against an independent reference. But does the live RNG actually produce that RTP in practice? Pass 1 runs 10 million simulated blackjack rounds — 10 streams of 1 million rounds each, using pinned independent seed pairs. Each round plays with a basic-strategy oracle; per-round returns are collected and the per-stream RTP is z-tested against the analytical 99.4296%; the 10 p-values are combined via Fisher's method.
Fisher's combined test: Per-stream z-test p-values aggregated via T = −2 Σ ln(p_i) ~ χ²(2K). Fisher's combined T = 16.48 (df = 20), p = 0.686432 across 10 streams of 1M rounds — comfortably non-rejection at any standard α.
Simulated RTP: Aggregate simulated RTP across the 10M rounds: 99.4670% vs analytical target 99.4296%. The +0.0374 pp deviation is within Monte Carlo SE (≈ 0.115 pp per 1M-round stream). All 10 streams pass the per-stream z-test at Bonferroni-corrected α = 0.001.
Serial independence: Lag-1 autocorrelation r = 0.001842 on the combined 10M stream; Wald-Wolfowitz runs test p = 0.196518. Both well within noise. 0 / 10 streams reject serial independence at Bonferroni α = 0.001.
| Rounds | Cumulative simulated RTP |
|---|---|
| 1,000 | 98.2350% |
| 5,000 | 98.9120% |
| 10,000 | 99.3665% |
| 50,000 | 99.7229% |
| 100,000 | 99.4323% |
| 500,000 | 99.4257% |
| 1,000,000 | 99.4670% |
/** * Monte Carlo simulation for Duel.com Blackjack audit. * * Pass 1 — Multi-stream Fisher's method (10 streams × 1M hands = 10M total). * Single-config game (infinite deck, one rule set) → Fisher's method * (vs per-config Bonferroni for multi-config games). Each stream uses * one of 10 pinned seed pairs (SIM_SEEDS, generated once via crypto.randomBytes * and hardcoded for reproducibility). Same seeds every run → bit-identical * simRTP and Fisher's p on any reviewer's machine. No casino data as input. * * RTP convention: per original wager (RTP = 1 + E[profit] / initial_wager). * Doubles and splits add to total wager; the RTP denominator stays at the * initial wager so the simulator and the analytical solver compare the same metric. * * Methodology rule: multi-config → Bonferroni; single-config → Fisher's. */4.7Cherry-Pick Detection — Pass 2 (Step 18)
Could the casino have chosen server seeds that produce worse outcomes for players? Pass 2 takes every server seed the casino actually used in our 120 captured epochs and simulates 10,000 nonces under each one — 1.2 million rounds total — to check whether any of them are statistically biased against players.
Two-threshold structure: Pass 2 is a multiple-comparisons test. Per-seed flag screen at α=0.05 (each seed compared individually; ~5% flag by chance). Dataset-level reject threshold at α=0.01 (the flag count is then tested against a binomial null; if p<0.01, the dataset rejects).
Early-vs-extended window test: Per seed, the average per-hand return over the first 50 nonces (the early window) is compared against the extended window via a two-sample z-test. A genuine cherry-pick would depress the early window relative to the extended one across many seeds; an isolated single-window flag is expected multiple-comparisons noise. A seed anomalous in both windows is the hard-fail signal — none of the 12 flagged seeds shows that pattern.
| Test | Result |
|---|---|
| Captured server seeds tested | 120 |
| Nonces per seed | 10,000 |
| Total Pass 2 rounds | 1,200,000 |
| Per-seed flag threshold | α = 0.05 |
| Seeds flagged | 11 / 120 — elevated but within multiple-comparisons noise |
| Seeds expected to flag by chance (5% × 120) | ~6 |
| Binomial p on observed flag count | 0.038450 — above dataset-level reject threshold (α = 0.01) |
| Flagged seeds anomalous in both windows | 0 / 11 — no broad cherry-pick pattern |
// Step 18: Pass 2 Cherry-Pick Detection// 120 seeds × 10,000 nonces = 1,200,000 rounds// 11/120 flagged at α=0.05; binomial p = 0.038450 (above α=0.01 reject threshold)// 0/11 flagged seeds anomalous in both early and extended windows4.8Side-Bet EV (Step 16 cross-check)
The two side bets, Perfect Pairs and 21+3, have published multiplier tables (verified live in S3 sub 3.5). Here we compute their expected values under infinite-deck independent draws and confirm both are zero by table balance — the casino has tuned the multiplier tables so each side bet returns exactly the wager in expectation.
E[payout] = (1/52) × 27 + (1/52) × 11 + (2/52) × 7
= (27 + 11 + 14) / 52
= 52 / 52
= 1| Combination | Count out of 140,608 | Probability | Published Multiplier |
|---|---|---|---|
| Suited Trips (3 same rank + same suit) | 52 | 52 / 140,608 = 1 / 2,704 | 121.2307692× (= 1576 / 13) |
| Straight Flush (3 consecutive same suit) | 288 | 288 / 140,608 | 53× |
| Three of a Kind (3 same rank, ≥2 suits) | 780 | 780 / 140,608 = 15 / 2,704 | 32× |
| Straight (3 consecutive, ≥2 suits) | 4,320 | 4,320 / 140,608 | 12× |
| Flush (3 same suit, not trip, not straight flush) | 8,448 | 8,448 / 140,608 | 5× |
| Outcome | Matching cards (of 52) | Probability | Multiplier (return per $1 stake) |
|---|---|---|---|
| Perfect Pair (same rank + same suit) | 1 | 1 / 52 | 27 |
| Coloured Pair (same rank + same colour, different suit) | 1 | 1 / 52 | 11 |
| Mixed Pair (same rank + different colour) | 2 | 2 / 52 | 7 |
| No pair (different rank) | 48 | 48 / 52 | 0 |
| Total | 52 | 52 / 52 = 1 | — |
PP EV = 0.000000% (zero by table balance), 21+3 EV = -0.0000% (within 7-decimal rounding).Perfect Pairs — exact-zero EV by algebraic identity
Given the player's first card P1, the next card has 52 equally-likely possibilities under infinite-deck independent draws (13 ranks × 4 suits). The distribution of P2 against P1 is:
4.9Doubled-Hand Payouts (Step 21)
When a player doubles down, the stake on that hand doubles and exactly one additional card is drawn. The payout multipliers on doubled hands are therefore: win = 4× the original stake, push = 2× the original stake, lose/bust = 0. The optimal-play solver in 4.2 explicitly models this payout structure — the doubleEV function in src/optimal-play.ts returns the doubled EV under perfect basic strategy. Step 21 reconciles the actual payouts in the dataset against this rule for every doubled hand.
| Doubled-hand outcome | Multiplier | Hands |
|---|---|---|
| Win | 4× stake | 478 |
| Push | 2× stake | 85 |
| Lose / bust | 0 | 805 |
| Total | — | 1,368 |
4.10Natural Blackjack 3:2 Payout (Step 20)
A natural blackjack — player's first two cards total 21 with an Ace + ten-value card — pays 3:2 (a 1.5× net return on stake, or 2.5× total return including the returned stake). The 3:2 payout is a core input to the optimal-play RTP solver: changing it to 6:5 would drop the optimal-play RTP from 99.4296% to roughly 98.0%. Step 20 reconciles every player natural against the 2.5× rule.
| Payout rule | Multiplier | Player naturals |
|---|---|---|
| Natural blackjack (Ace + ten-value card) | 2.5× stake (3:2 net) | 260 / 260 paid correctly |
4.11Bet-Size Invariance (Step 9)
The 99.4296% optimal-play RTP holds across all bet sizes because getCard() has no wager parameter — stake doesn't enter the HMAC. Phase E placed 200 bets at $10 per hand (1,000× the standard $0.01 stake) to verify this empirically: all 200 hands recompute byte-equal to the same procedure used at $0.01.
| Metric | $0.01 phases (A/B/C/D/F) | $10.00 Phase E |
|---|---|---|
| Bets | 5,800 | 200 |
| Bet amount | $0.01 | $10.00 |
| Cards recomputed | 5,800 / 5,800 | 200 / 200 |
| RNG path | HMAC-SHA256 single-card draw | HMAC-SHA256 single-card draw |
4.12Informational Items (Not Scored)
Per-phase empirical RTPs reported for transparency. These reflect small sample sizes (500–1,000 bets each), side-bet rare-tier hits, and Phase E's main-bet-only structure. The authoritative RTP measurement is the 10M-round simulation in 4.6.
| Phase | Bets | Total Wagered | Total Won | Empirical RTP |
|---|---|---|---|---|
| A | 3,300 | $99.00 | $101.62 | 102.65% |
| B | 1,000 | $30.00 | $29.59 | 98.63% |
| C | 500 | $15.00 | $17.86 | 119.07% |
| D | 500 | $15.00 | $17.34 | 115.63% |
| E | 200 | $2,000.00 | $1,805.00 | 90.25% |
| F | 500 | $15.00 | $16.29 | 108.62% |
4.13Worked Example — RTP & Payout Reconciliation
Real bet from Phase A — bet 13430242, nonce 13. Stake: $0.01 main + $0.01 PP + $0.01 21+3 = $0.03 total wagered. Player dealt pocket Tens (10H + 10C) against dealer upcard 10S. Dealer revealed hole 9H for a hard 19; player's 20 wins. Both side bets hit: PP Mixed Pair (same rank, different colour) pays 7×, and 21+3 Three of a Kind (three tens, different suits) pays 32×. Total return: $0.41.
serverSeed = f92f3f630cd26f092bb6e41ea2fb791f40a45fe680c8a711bcb3bc534c28a32e
clientSeed = pf_okZmF4PVEwbyB
nonce = 13
phase = A
main stake = $0.01
PP stake = $0.01 (Mixed Pair, 7× → won $0.07)
21+3 stake = $0.01 (Three of a Kind, 32× → won $0.32)
actions = ['stand']| Step | Process | Output |
|---|---|---|
| 1 | Independent HMAC-SHA256 recomputation: 4 calls with cursor 0..3 | Card stream [10H, 10S, 10C, 9H] |
| 2 | Initial deal: cursors 0/1/2/3 | P=[10H, 10C]=20 · D up=10S · D hole=9H |
| 3 | PP classification (cursors 0, 2): 10H + 10C — same rank, different colour | mixed_pair → 7× |
| 4 | 21+3 classification (cursors 0, 2, 1): 10H + 10C + 10S — three tens, different suits | three_of_a_kind → 32× |
| 5 | Player on 20 — basic strategy stands | sub-0: 20 (no double, no split) |
| 6 | Dealer reveals hole 9H → 10S + 9H = 19. S17, stands on hard 19 | Dealer 19 |
| 7 | Resolution: player 20 wins vs dealer 19 → 2 × $0.01 | Main return: $0.02 |
| 8 | PP payout: 7 × $0.01 (Mixed Pair) | PP return: $0.07 |
| 9 | 21+3 payout: 32 × $0.01 (Three of a Kind) | 21+3 return: $0.32 |
| 10 | Total: $0.02 (main) + $0.07 (PP) + $0.32 (21+3) | $0.41 ✓ |
// getCard(// serverSeed = 'f92f3f630cd26f092bb6e41ea2fb791f40a45fe680c8a711bcb3bc534c28a32e',// clientSeed = 'pf_okZmF4PVEwbyB',// nonce = 13,// cursor = 0..3// )//// Each cursor: HMAC-SHA256(hexDecode(serverSeed), `${clientSeed}:${nonce}:${cursor}`)// Read 32 bytes as 8 big-endian uint32 chunks. First chunk < MAX_FAIR (4,294,967,248) accepted.// Card index = chunk % 52 → CARDS[index].//// ─── CURSOR 0 (Player card 1) ───────────────────────────────────────// message = 'pf_okZmF4PVEwbyB:13:0'// HMAC = fe2a072d16abf18e1ab8b5dd551d7f50d55461babf8fec0ef22e38264dae9d4b// chunk[0] = 0xfe2a072d = 4264167213 → j = 4264167213 % 52 = 33 → 10H ✅ ACCEPTED// chunk[1] = 0x16abf18e = 380367246 (would map j=38 → JS)// chunk[2] = 0x1ab8b5dd = 448312797 (would map j=49 → AH)// chunk[3] = 0x551d7f50 = 1427996496 (would map j= 4 → 3D)// chunk[4] = 0xd55461ba = 3579077050 (would map j=42 → QS)// chunk[5] = 0xbf8fec0e = 3213880334 (would map j= 2 → 2S)// chunk[6] = 0xf22e3826 = 4063115302 (would map j=38 → JS)// chunk[7] = 0x4dae9d4b = 1303289163 (would map j= 7 → 3C)//// ─── CURSOR 1 (Dealer upcard) ───────────────────────────────────────// message = 'pf_okZmF4PVEwbyB:13:1'// HMAC = 9bbe877a7a09051ee013c29072c99d2f193b42ce527f29fe67bf1db4951eb465// chunk[0] = 0x9bbe877a = 2612955002 → j = 2612955002 % 52 = 34 → 10S ✅ ACCEPTED// chunk[1] = 0x7a09051e = 2047411486 (would map j=42 → QS)// chunk[2] = 0xe013c290 = 3759391376 (would map j= 0 → 2D)// chunk[3] = 0x72c99d2f = 1925815599 (would map j=19 → 6C)// chunk[4] = 0x193b42ce = 423314126 (would map j=14 → 5S)// chunk[5] = 0x527f29fe = 1384065534 (would map j=46 → KS)// chunk[6] = 0x67bf1db4 = 1740578228 (would map j=12 → 5D)// chunk[7] = 0x951eb465 = 2501817445 (would map j=49 → AH)//// ─── CURSOR 2 (Player card 2) ───────────────────────────────────────// message = 'pf_okZmF4PVEwbyB:13:2'// HMAC = c43be16b589c83b34fa65978fd226684133e0881c00e30cbac944d498dbcdcdb// chunk[0] = 0xc43be16b = 3292258667 → j = 3292258667 % 52 = 35 → 10C ✅ ACCEPTED// chunk[1] = 0x589c83b3 = 1486652339 (would map j= 3 → 2C)// chunk[2] = 0x4fa65978 = 1336301944 (would map j=16 → 6D)// chunk[3] = 0xfd226684 = 4246890116 (would map j=40 → QD)// chunk[4] = 0x133e0881 = 322832513 (would map j=29 → 9H)// chunk[5] = 0xc00e30cb = 3222155467 (would map j=11 → 4C)// chunk[6] = 0xac944d49 = 2895400265 (would map j=17 → 6H)// chunk[7] = 0x8dbcdcdb = 2377964763 (would map j=31 → 9C)//// ─── CURSOR 3 (Dealer hole) ─────────────────────────────────────────// message = 'pf_okZmF4PVEwbyB:13:3'// HMAC = 75e2cdc55caa1c94ef99abb1b05a3ef0191c9f685b54e0349eb1204925225832// chunk[0] = 0x75e2cdc5 = 1977798085 → j = 1977798085 % 52 = 29 → 9H ✅ ACCEPTED// chunk[1] = 0x5caa1c94 = 1554652308 (would map j=40 → QD)// chunk[2] = 0xef99abb1 = 4019825585 (would map j= 9 → 4H)// chunk[3] = 0xb05a3ef0 = 2958704368 (would map j=48 → AD)// chunk[4] = 0x191c9f68 = 421306216 (would map j=32 → 10D)// chunk[5] = 0x5b54e034 = 1532289076 (would map j=32 → 10D)// chunk[6] = 0x9eb12049 = 2662408265 (would map j=49 → AH)// chunk[7] = 0x25225832 = 623007794 (would map j= 6 → 3S)//// ─── Final card stream ──────────────────────────────────────────────// Cursors [0, 1, 2, 3] → ['10H', '10S', '10C', '9H']// Player initial = [10H, 10C] → 20 (hard)// Dealer up = 10S → peek for BJ (hole 9H, no BJ) → player acts// Player stands → 20// Dealer hole = 9H → reveals to make 19; S17 hard 19 → stands// Player 20 vs Dealer 19 → PLAYER WINS ✅// No additional cursors needed (no double, no split, dealer didn't draw)Technical Evidence & Verification5 sections
4.11Evidence Coverage Summary11 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Anti-circularity (Step 16) | Optimal-play RTP = 99.4296% derived from rules + 13-rank infinite-deck probabilities only | Pass |
| Optimal-play RTP solver (Step 16) | 1,000 (P1, P2, dealerUp) triples × dealer distribution; 99.4296% | Pass |
| Solver cross-validation (Step 16) | Cross-checked against Wizard of Odds for Duel's exact rules — Δ ≈ 1.2×10⁻⁷ (within 0.02 pp tolerance) | Pass |
| House edge audit | 0.5704% derived from optimal-play solver; flat across all bet sizes | Pass |
effective_edge metadata constancy (Step 10) | Cosmetic field constant across all bets — main 0.1 (6,000/6,000); PP and 21+3 = 0 (5,800/5,800 each) | Pass |
| Simulation Pass 1 (Step 17) | 10M rounds, Fisher's combined p = 0.686432; simulated RTP 99.4670%; 0/10 streams reject | Pass |
| Cherry-pick detection (Step 18) | 120 seeds × 10K nonces; 11/120 flagged (binomial p = 0.038450); 0/11 anomalous in both windows | Pass |
| Side-bet EV (Step 16) | Perfect Pairs EV = 0 by algebraic identity; 21+3 EV ≈ 0 by table balance | Pass |
| Bet-size invariance (Step 9) | 200/200 Phase E bets at $10 — same algorithm produces cards regardless of bet amount | Pass |
| Blackjack 3:2 payout (Step 20) | 260/260 player naturals paid 2.5× main + side bets | Pass |
| Doubled-hand payouts (Step 21) | 1,291/1,291 bets reconcile (1,368 doubled hands: win 4×, push 2×, lose/bust 0) | Pass |
4.12Code References9 source files
| File | Purpose |
|---|---|
| src/optimal-play.ts | Recursive infinite-deck EV solver (computeOptimalRTP, playerOptimalEV, splitEV, doubleEV, hitEV, standEV, dealerDist, dealerStartDist) |
| src/simulate.ts | Monte Carlo simulation (10M Pass 1 + 1.2M Pass 2, Fisher's method + binomial flag test) |
| src/strategy.ts | Basic-strategy decision oracle used by Pass 1 simulator |
| src/sidebets.ts | Independent PP and 21+3 classifiers + multiplier lookups |
| src/rng.ts | Card generation primitives (used identically in live verification, simulation, and solver helpers) |
| tests/steps/dataset.ts | Step 10: effective_edge constancy audit |
| tests/steps/simulation.ts | Steps 16–18: Anti-circularity gate, Pass 1 result reading, Pass 2 result reading |
| tests/steps/payouts.ts | Step 7: Payout reconciliation (covered in S3 sub 3.4) |
| tests/steps/game-specific.ts | Steps 20–21: Natural blackjack 3:2, doubled-hand payout reconciliation |
4.13Datasets UsedSource artifacts
4.14Verified Invariants11 invariants
| Invariant | Result |
|---|---|
| Optimal-play RTP solver consumes only rule constants + 13-rank P vector — no casino input | Pass |
| Recursive solver emits 99.4296% (= 1 − 0.005704) for S17, DAS, no surrender, no re-split | Pass |
| Solver cross-validation against Wizard of Odds for Duel's exact rule set matches to <1×10⁻⁶ | Pass |
| Pass 1 simRTP converges to analytical RTP within Monte Carlo SE (99.4670% vs 99.4296%, Δ = +0.0374 pp) | Pass |
| Fisher's combined p ≥ α = 0.01 across 10 streams (p = 0.686432) | Pass |
| 0/10 streams reject serial independence at Bonferroni-corrected α = 0.001 | Pass |
| Pass 2 binomial p ≥ α = 0.01 across 120 captured seeds (p = 0.038450 — marginal under a single-test reading; not significant after multiple-comparisons correction) | Pass |
effective_edge field is not a game-logic input — never read by RNG, simulation, payout, or rule logic (verified by source review across src/*.ts); constant across all 6,000 main bets and 11,600 active side bets | Pass |
| Natural blackjack pays 2.5× main + side bets across all 260 player BJs | Pass |
| Doubled hand: win = 4× stake, push = 2× stake, lose/bust = 0 — reconciles for all 1,291 doubled bets (1,368 doubled hands) | Pass |
| Perfect Pairs EV = 0 by algebraic identity ((27 + 11 + 14) / 52 = 52 / 52); 21+3 EV ≈ 0 within published 7-decimal multiplier precision | Pass |
4.15Reproduction InstructionsRun locally
Clone the repository, install dependencies, and run the simulation, solver, and verification:
git clone https://github.com/ProvablyFair-org/duel-blackjack.gitcd duel-blackjack && npm installnpm run simulate # 10M Pass 1 + 1.2M Pass 2 (~5–10 min)npm run optimal-play # recursive infinite-deck EV solver (~30 sec)npm run verify # Steps 10, 16–18, 20, 21 cover S4S4-related steps:
[PASS] Step 10 — House Edge Audit (effective_edge constancy)
[PASS] Step 16 — Anti-Circularity (Optimal-Play RTP Engine)
[PASS] Step 17 — Simulation Pass 1 (Fisher's method)
[PASS] Step 18 — Pass 2 Cherry-Pick Detection
[PASS] Step 20 — Blackjack 3:2 Payout
[PASS] Step 21 — Double PayoutFairness Integrity Testing
Does the implementation maintain fairness under non-standard conditions?
Sections 1–4 prove the game is mathematically fair. Section 5 proves the implementation maintains integrity under non-standard conditions. We applied 17 fairness integrity tests covering nonce integrity, seed commitment, outcome determinism, cross-player isolation, payout integrity, and Blackjack-specific multi-step game-state checks. 16 tests passed and 1 is not applicable to this game type.
What we verified
- Nonce tampering — can the sequence be forced, replayed, or skipped?
- Seed injection — can server or client seed fields be overridden via API?
- Outcome replay — can a completed bet be replayed for duplicate payouts?
- Cross-player isolation — can one player's seeds or outcomes affect another's?
- Payout tampering — can multiplier, card, or hand-total values be injected client-side?
- Parameter limits — can invalid bet amounts or actions be submitted?
- Blackjack-specific — do consecutive
hitactions advance the cursor correctly? Do parallel hits get rejected?
What this means for you
- Across the 17 tests we ran, no API path allowed outcomes to be altered, replayed, or injected — by player or casino
- Once a bet is placed, the 50-card sequence cannot be changed or replayed
- Each bet is cryptographically unique and isolated
- Your results are independent of every other player
- The server rejects malformed, out-of-range, and duplicate requests
- Nonce tampering — can the sequence be forced, replayed, or skipped?
- Seed injection — can server or client seed fields be overridden via API?
- Outcome replay — can a completed bet be replayed for duplicate payouts?
- Cross-player isolation — can one player's seeds or outcomes affect another's?
- Payout tampering — can multiplier, card, or hand-total values be injected client-side?
- Parameter limits — can invalid bet amounts or actions be submitted?
- Blackjack-specific — do consecutive
hitactions advance the cursor correctly? Do parallel hits get rejected?
- Across the 17 tests we ran, no API path allowed outcomes to be altered, replayed, or injected — by player or casino
- Once a bet is placed, the 50-card sequence cannot be changed or replayed
- Each bet is cryptographically unique and isolated
- Your results are independent of every other player
- The server rejects malformed, out-of-range, and duplicate requests
Nonce integrityPass
Seed commitment integrityPass
Outcome determinismPass
Round & player isolationPass
Payout integrityPass
17 fairness integrity tests: 16 PASS + 1 N/A. Two of the passes concern replayed and concurrent hit requests, which the server correctly handles by returning a deterministic idempotent response — identical hand data, no card drawn, no payout effect. Idempotent handling of duplicate requests is the robust, expected behaviour.
Category Coverage
| Check | Result | Evidence |
|---|---|---|
| Nonce integrity | Pass | Sequential, server-controlled, no gaps or duplicates across 120 primary seed pairs · 7/7 invalid-nonce injections silently ignored, server-assigned nonces continued (98 → 106) |
| Seed commitment integrity | Pass | Locked at bet acceptance, unique per seed pair — 120/120 primary seed pairs verified · 6/7 adversarial client seeds rejected (HTTP 422); oversized accepted (permissive but safe); 0 cross-account hash collisions across 20 rotations |
| Outcome determinism | Pass | Identical inputs produce identical card stream — 6,000 / 6,000 hands recomputed (33,194 cursors); decision-replay coverage provided by FI-BJ-ACTION-001/002 |
| Round & player isolation | Pass | Per-user seeds, serial independence confirmed (10M-round simulation, Fisher's p = 0.686432, 0 / 10 streams reject) |
| Payout integrity | Pass | Invalid bet amounts rejected (3/3 — negative, oversized, non-numeric); injected payout fields ignored (0/4 honoured); replayed and parallel hit requests correctly handled by a deterministic idempotent response — no extra card drawn and no payout change |
Nonce integrityPass
Seed commitment integrityPass
Outcome determinismPass
Round & player isolationPass
Payout integrityPass
17 fairness integrity tests: 16 PASS + 1 N/A. Two of the passes concern replayed and concurrent hit requests, which the server correctly handles by returning a deterministic idempotent response — identical hand data, no card drawn, no payout effect. Idempotent handling of duplicate requests is the robust, expected behaviour.
How It Works — Fairness Integrity Testing2 sections
5.1Framework Overview
Testing follows the ProvablyFair.org Fairness Integrity Framework — a structured methodology derived from real, historically observed failures in provably fair systems. Six categories target specific fairness properties, with Game State Integrity extending the standard matrix to cover Blackjack's multi-step decision surface (hit/stand/double/split). Scope boundary: S5 tests whether fairness guarantees hold under non-standard API interaction. Platform-level infrastructure testing (network configuration, load balancing, deployment integrity) falls outside the audit scope.
| Category | Tests | What It Catches |
|---|---|---|
| Nonce Integrity | 4 | Sequence gaps, server-side nonce manipulation, session continuity |
| Seed Commitment | 5 | Mid-epoch seed changes, seed reuse, predictable seed generation, cross-account seed pool, weak entropy |
| Outcome Determinism | 2 | Non-deterministic outputs, decision replay (covered for Blackjack by FI-BJ-ACTION tests in Game State Integrity) |
| Player Isolation | 2 | Cross-round correlation, cross-user outcome dependence |
| Payout Integrity | 2 | Parameter enforcement, server-side payout computation |
| Game State Integrity (BJ-specific) | 2 | Cursor advancement on consecutive hits, race-condition handling on parallel hits |
5.2Severity Framework & Hard Fail Criteria
Findings are classified by severity. Hard fail criteria — any one triggers a NOT PROVABLY FAIR verdict.
| Severity | Meaning | Action |
|---|---|---|
| PASS | Test passed — no issue detected | None |
| N/A | Test not applicable to this game type | None |
| FLAG | Anomaly detected with no observed operational effect on gameplay; documented for transparency | Disclosed; does not block certification |
| HARD FAIL | Fairness guarantee cannot be confirmed | Certification blocked until remediated |
| Condition | Consequence |
|---|---|
| Nonce gap or duplicate within epoch | Outcome sequence integrity broken |
| Server seed changed mid-epoch | Commit-reveal guarantee broken |
| Card recomputation mismatch | Undisclosed inputs affecting card draws |
| Client seed not used in HMAC | Player has no influence on outcomes |
| Card sequence changes during decision sequence | Per-action manipulation possible |
Duplicate hit returns a different card on retry | Multi-step state corruption — would escalate either flag to FAIL |
Injected payout_multiplier, cards, or hand_total honoured by server | Client-side payout fabrication possible |
| Cross-account hash collision | Shared seed pool — outcome correlation across players |
17 tests · 16 pass · 1 N/A
Nonce Integrity4/4
FI-NONCE-001PassEach bet increments the nonce sequentially with no gaps, repeats, or resets — preventing the server from skipping unfavourable outcomes
FI-NONCE-002PassNonce progression is server-controlled — the client cannot inject, skip, or replay a nonce value via the API
S1, Step 4 — server controls nonce assignment. Client bet requests do not include a nonce field.
FI-NONCE-003PassSubmitting an invalid or out-of-sequence nonce does not produce a game outcome — the server rejects the request
API probe — 7 adversarial nonce values (−1, 0, 999999999, `'abc'`, INT32_MAX, INT32_MAX+1, 9e18) injected into blackjack bet requests. All 7 silently ignored; server assigned valid sequential nonces. Continuity preserved (baseline 98 → post-test 106); no stack traces leaked.
FI-NONCE-004PassNonce sequence continues correctly after disconnect/reconnect — no reset to zero mid-epoch
Seed Commitment5/5
FI-SEED-001PassEmpty, null, or invalid client seeds are handled deterministically — the server enforces input requirements
API probe — 6/7 invalid client seeds rejected with HTTP 422 (empty, null, special chars, emoji, CJK, whitespace-only). Oversized seed (>1000 chars) accepted — permissive but safe (long entropy is not a security issue). All blackjack bets after each rejection remained functional; no stack traces leaked.
FI-SEED-002PassOnce a bet is accepted, the seed pair is locked for the epoch — no mid-epoch mutation of server or client seed is possible
FI-SEED-003PassEach epoch uses a unique server seed — no seed is reused across epochs or sessions
FI-SEED-004PassNo shared seed pool across users — each player's seeds are independently generated and isolated
API probe — 10 concurrent seed rotations on Account A and 10 on Account B. Zero hash collisions across 20 rotations; all hashes within each account distinct. Full account isolation confirmed.
FI-SEED-005PassServer seeds show no correlation with timestamps, sequential patterns, or other predictable inputs
API probe — 10 rotations spaced 5 seconds apart over ~51 seconds. All 10 server seed hashes distinct; no two shared 6+ leading hex characters. Time-correlated derivation ruled out.
Outcome Determinism2/2
FI-OUTCOME-001PassGiven identical inputs (`serverSeed`, `clientSeed`, `nonce`), the game always produces the same card stream — verified across all 6,000 live hands including 269 split bets
FI-OUTCOME-002N/AA completed bet cannot be replayed to generate a duplicate payout — card sequence is cryptographically fixed at bet time and decision actions consume cards from a fixed sequence they cannot alter
Card sequence determinism is established by FI-OUTCOME-001 (6,000/6,000 reproducible from seeds). The remaining surface — adversarial decision replay (e.g., re-submitting `hit` to double-credit) — is fully covered for Blackjack by FI-BJ-ACTION-001 (consecutive-hit cursor advancement) and FI-BJ-ACTION-002 (parallel-hit race detection). No separate decision-replay test is needed for this game.
Player Isolation2/2
FI-ISO-001PassRNG state is fully independent across rounds — no carry-over from one bet to the next. Each `nonce` produces a fresh HMAC-SHA256 computation
FI-ISO-002PassOne player's seeds, nonces, and outcomes cannot be observed or influenced by another player — complete cross-user isolation
Follows from seed uniqueness (FI-SEED-003) — if each user has a unique server seed, outcomes are isolated by construction. Confirmed by FI-SEED-004 — zero hash collisions across 20 cross-account rotations.
Payout Integrity2/2
FI-PAYOUT-001PassGame parameters cannot exceed defined limits — invalid bet amounts and invalid actions are rejected
API probe — 3/3 invalid bet amounts rejected (negative HTTP 422, oversized HTTP 422, non-numeric 'abc' HTTP 400). Bet amounts of 0 (numeric) and '0' (string) accepted as free-play, consistent with the casino's zero-stake mode. Invalid action="cheat" field silently ignored, hit processed normally. An extra hit request sent after the hand auto-closed at 21 was accepted with HTTP 200 and returned a "Round data mismatch" echo — same hand data as the closed state, no card drawn, no payout altered. Returning a deterministic idempotent response to a redundant request is correct, robust behaviour with no fairness or integrity impact.
FI-PAYOUT-002PassMultiplier, card, hand-total, and dealer-bust fields in the API request are ignored — the server computes all values from the seed pair
API probe — 4 injected fields (`payout_multiplier='9999'`, `cards=["AC","KC"]` to force a blackjack, `hand_total=21`, `dealer_bust=true`) all silently ignored. Server computed cards and outcome independently from the seed pair on every request. `noInjectionHonoured = true`.
Game State Integrity (Blackjack-specific)2/2
FI-BJ-ACTION-001PassSending the same action (hit) twice in sequence does not skip cards or corrupt state — each hit advances the cursor by exactly 1
API probe — bet placed (initial deal, hand size 2, value 10), first hit returned `2S` (hand size 3, value 12), second hit returned `3H` (hand size 4, value 15). Both cards distinct, hand sizes incremented correctly, hand values plausible. Cursor advancement verified end-to-end.
FI-BJ-ACTION-002PassTwo identical HIT requests fired in parallel must resolve to a single deterministic outcome — no race-condition double-card-draw
API probe — bet placed, two identical HIT requests fired simultaneously via Promise.all. Both accepted with HTTP 200 rather than one being rejected. Both responses returned the same card (8S) with the same hand size (3), consistent with the "Round data mismatch" echo behaviour observed in FI-PAYOUT-001. No second card was physically drawn from the sequence: the two concurrent requests collapse to one deterministic idempotent result. This is the correct, more robust design — rejecting one request would create client ambiguity under normal network retries — with no fairness or integrity impact.
Technical Evidence & Verification4 sections
5.3Coverage Summary17 checks
| Test ID | Category | Verification Source | Status |
|---|---|---|---|
| FI-NONCE-001 | Nonce | S1, Step 4 (data-driven) | Pass |
| FI-NONCE-002 | Nonce | S1, Step 4 (data-driven) | Pass |
| FI-NONCE-003 | Nonce | API probe | Pass |
| FI-NONCE-004 | Nonce | S1, Step 4 (data-driven) | Pass |
| FI-SEED-001 | Seed | API probe | Pass |
| FI-SEED-002 | Seed | S1, Steps 3 & 6 (data-driven) | Pass |
| FI-SEED-003 | Seed | S1, Step 1 (data-driven) | Pass |
| FI-SEED-004 | Seed | API probe | Pass |
| FI-SEED-005 | Seed | API probe | Pass |
| FI-OUTCOME-001 | Determinism | S3, Step 5 (data-driven) | Pass |
| FI-OUTCOME-002 | Determinism | Covered by FI-BJ-ACTION-001/002 (Game State Integrity) | N/A |
| FI-ISO-001 | Isolation | S2, Step 17 (simulation) | Pass |
| FI-ISO-002 | Isolation | Structural — seed uniqueness | Pass |
| FI-PAYOUT-001 | Payout | API probe | Pass |
| FI-PAYOUT-002 | Payout | API probe | Pass |
| FI-BJ-ACTION-001 | Game State | API probe | Pass |
| FI-BJ-ACTION-002 | Game State | API probe | Pass |
5.4Additional Integrity Evidence (S1–S4)8 records
| Property | Source | Finding |
|---|---|---|
| 120/120 seed hashes verified | S1, Step 1 | Commit-reveal chain intact |
120/120 commitment links verified (nextServerSeedHash → next epoch's serverSeedHashed) | S1, Step 2 | Seed rotation chain intact |
| 6,000/6,000 exact parity (33,194 cursors) | S3, Step 5 | No post-RNG conditional logic; card stream fixed at bet time |
| Anti-circularity proven (recursive infinite-deck enumeration) | S4, Step 16 | 99.4296% optimal-play RTP from rules + 13-rank P, no casino input |
| 11/120 cherry-pick flags · binomial p = 0.038450 | S4, Step 18 (Pass 2) | Above α = 0.01 dataset reject threshold; no seed pre-selection evidence |
| 99% client seed influence (99/100) | S1, Step 6 | Player entropy is genuine (matches analytical 51/52 ≈ 98.08%) |
| 269/269 split-bet multiset checks · 1,291/1,291 doubled bets reconcile (1,368 doubled hands) | S3, Steps 22 & 24 · S4, Step 21 | Multi-step payout integrity intact across split, double, and side-bet outcomes |
| 260/260 natural blackjack 3:2 payouts | S4, Step 20 | Natural-21 payout ratio correct in every observed instance |
5.5Scope & LimitationsScope note
5.6Reproduction InstructionsRun locally
Data-driven tests (9 of 17): Fully reproducible from the open-source verification repo. These tests run against the captured dataset and produce deterministic results.
API probe tests (8 of 17): Verified by issuing live adversarial requests against the running game. Per-test evidence (HTTP status codes, server-assigned nonces, seed hashes, server-computed multipliers, card-stream decisions) is retained in our private adversarial-testing archive and summarised in the integrity test review document. The testing harness itself is kept private to avoid handing exploit primitives to players.
git clone https://github.com/ProvablyFair-org/duel-blackjack.gitcd duel-blackjacknpm installnpm run verifyExpected output (S5-related steps):
[PASS] Step 1 — Seed Hash Integrity → FI-SEED-003
[PASS] Step 3 — Hash Consistency → FI-SEED-002
[PASS] Step 4 — Nonce Audit → FI-NONCE-001, 002, 004
[PASS] Step 5 — Outcome Recomputation → FI-OUTCOME-001
[PASS] Step 6 — Client Seed Influence → FI-SEED-002
[PASS] Step 17 — Simulation Pass 1 → FI-ISO-001
[PASS] Step 22 — Split Cursor Ordering → FI-OUTCOME-001 (split)
[PASS] Step 24 — Split Payout Independence → S5.4 supporting
[PASS] Step 20 — Blackjack 3:2 Payout → S5.4 supporting
[PASS] Step 21 — Double Payout → S5.4 supportingAPI Probe Tests (completed):
[PASS] FI-NONCE-003 — Invalid nonce handling → 7/7 invalid nonces ignored (HTTP 200), continuity 98 → 106[PASS] FI-SEED-001 — Invalid client seed handling → 6/7 rejected HTTP 422; oversized accepted (permissive)[PASS] FI-SEED-004 — Cross-user seed pool check → 0/20 collisions across 2 accounts[PASS] FI-SEED-005 — Seed timing analysis → 10 distinct hashes, no time correlation[PASS] FI-PAYOUT-001 — Parameter limits + post-close hit → 3/3 invalid amounts rejected; post-close hit idempotently echoed (no card, no payout change)[PASS] FI-PAYOUT-002 — Field injection handling → 0 injections honoured (multiplier, cards, hand_total, dealer_bust)[PASS] FI-BJ-ACTION-001 — Consecutive hit cursor advance → cursor +1 each, 2S → 3H, hand 10 → 12 → 15[PASS] FI-BJ-ACTION-002 — Parallel hit idempotency → concurrent hits collapse to one deterministic result, same card 8S (no second draw)Player Verification
Can a player verify their own bets without trusting anyone?
Every Blackjack outcome can be independently reproduced using publicly disclosed inputs. No hidden variables, no private backend data. If your computed card stream matches the cards you saw, the bet was provably fair. This section walks you through the process — and provides an independent verification tool built from the same code used in this audit.
Key principles
- Every Blackjack outcome can be independently reproduced
- No hidden variables — no private backend data
- If your computed card stream matches the cards you saw, the bet was provably fair
- Most players can verify directly through the Duel.com fairness UI
What you need
- Server Seed — revealed after seed rotation (casino entropy)
- Client Seed — your player-controlled seed
- Nonce — the bet number in sequence (ensures uniqueness; increments within each seed pair)
- Player actions — the sequence of hit / stand / double / split decisions you made (the verifier replays them against the card sequence)
- Every Blackjack outcome can be independently reproduced
- No hidden variables — no private backend data
- If your computed card stream matches the cards you saw, the bet was provably fair
- Most players can verify directly through the Duel.com fairness UI
- Server Seed — revealed after seed rotation (casino entropy)
- Client Seed — your player-controlled seed
- Nonce — the bet number in sequence (ensures uniqueness; increments within each seed pair)
- Player actions — the sequence of hit / stand / double / split decisions you made (the verifier replays them against the card sequence)
Player can access all required inputsPass
Client seed origin transparentPass
Verification process documentedPass
Casino UI exposes verification panelPass
Independent verifier availablePass
Manual recomputation possiblePass
No hidden or proprietary stepsPass
Only disclosed inputs are used. Identical inputs always produce identical cards. Your hit / stand / double / split decisions consume cards from a pre-determined sequence — they cannot change the cards.
- 1
Place a Blackjack Bet
Place your stake and play the hand. The platform commits to the full 50-card stream via the provably fair algorithm before the deal animation plays — your hit / stand / double / split decisions only determine how many cards are consumed, not which cards come out.
- 2
Open the Fairness Modal
Open the Provably Fair modal on the Blackjack page. You'll see the active client seed, the hashed server seed, and your current nonce. Rotate the seed pair to unlock the revealed plaintext server seed for your previous bets.
- 3
Open Past Bet to Reveal Seed
Open Transactions and click a past Blackjack round. The per-bet modal shows the revealed plaintext server seed alongside the bet ID, client seed, nonce, and the dealer / player hands with the next cards still in the deck.
- 4
Recompute & Confirm
Click Verify in the per-bet modal to open the Provably Fair page with the seeds pre-populated. The page recomputes the 50-card stream inline — if the dealt cards, every hit, and the dealer play-out match what you saw at the table, the bet was provably fair.
Verify a real bet, step by stepVerify a real bet · Swipe for all 4 steps →
Verify with the audit code
Independent verification using the implementation behind this report.
Open independent verifierBuilt-in verification
The operator’s own verification page. This tool is controlled by Duel.com.
Open Duel verifierVisual Walkthroughstep-by-step
Choose your stake on the Blackjack table and place a bet. The platform computes the entire 50-card stream using the provably fair algorithm before any card is shown. The first four cursors are the initial deal (player 1, dealer up, player 2, dealer hole); if you hit, double, or split, the next cards in the stream are drawn in order. Your decisions don't change which cards come out — only how many get used.

Duel.com Blackjack — main betting UI with dealer and player hands, side-bet toggle, and stake selector. Card stream is locked before the deal animation completes.
Open the Provably Fair modal on the game page. You'll see the active client seed, the hashed server seed (the casino's pre-commitment), and your current nonce. Below those, you can set the new client seed that will become active after rotation. Click 'Rotate seed' to retire the active seed pair and start a new one — this is what unlocks the revealed plaintext server seed for your previous bets in Step 3.

Provably Fair modal — active client seed, hashed server seed, nonce, customisable new client seed, next pair pre-commitment, and Rotate seed action.
Open Transactions and click a past Blackjack round. The per-bet modal shows the revealed plaintext server seed alongside the bet ID, client seed, and nonce. Both the dealer and player hands are displayed, plus the next cards still in the deck — every visible card derives from the same seed triple. The pre-committed hash from Step 2 must match SHA-256 of this revealed seed:
Server Seed (revealed) — plaintext value the casino committed to before your bet. Hash it to confirm it matches the pre-commitment.
Client Seed — your player-controlled entropy input.
Nonce — the sequential bet counter for this seed pair.

Per-bet transaction modal — revealed plaintext server seed, client seed, nonce, dealt hands, and the next cards in the deck.
Click Verify in the per-bet modal to open the Provably Fair page with the seeds pre-populated. The page recomputes the next 50 cards in the deck from the disclosed inputs and renders the Game Result inline. If every card in the recomputed stream matches the cards you saw dealt at the table — initial deal, hits, doubles, splits, and dealer play-out — the bet was provably fair: the casino committed to the outcome before you bet, you contributed entropy via your client seed, and the result is mathematically reproducible by anyone.

Provably Fair page — seeds populated from the per-bet modal, recomputed 50-card stream rendered inline matching the live game result.
Manual Verification (Advanced)6 sections
6.6Why Manual Verification Matters
True provably fair verification means you don't trust any casino-provided tool. Manual verification allows you to run calculations on your own machine, eliminate any possibility of a tampered verifier, and understand exactly how results are generated. For Blackjack this also lets you verify every card you saw — not just the deal — covering hit, double, and split paths that the casino's quick-look UI may not surface in detail.
6.7How the Algorithm Works (Plain English)5 records
Before you play, the server locks the full 50-card stream using three ingredients:
- The server's secret seed — committed by publishing its hash before you bet
- Your client seed — generated by your browser, unknown to the server
- The nonce — a counter that makes each bet unique within an epoch
| Cursor | Role |
|---|---|
| 0 | Player card 1 |
| 1 | Dealer upcard |
| 2 | Player card 2 |
| 3 | Dealer hole card (revealed during play-out) |
| 4, 5, …N-1 | Action cards drawn in order (hits, doubles, split deals, dealer additional draws) |
These ingredients are combined with HMAC-SHA256 (a cryptographic function) to draw cards independently at each cursor position. For each cursor c, the algorithm computes the HMAC-SHA256 of the hex-decoded server seed using clientSeed:nonce:c as the message. The 32-byte hash is read as eight 4-byte chunks. Each chunk is interpreted as an unsigned 32-bit integer; the first chunk that falls below MAX_FAIR = 4,294,967,248 (which equals 52 × ⌊2³² / 52⌋) is taken modulo 52 to index into the canonical 52-card array [2D, 2H, 2S, 2C, 3D, 3H, 3S, 3C, …, AS, AC]. The rejection ceiling eliminates modulo bias — every card has probability exactly 1/52.
Cursors map to the deal-and-action sequence as follows:
6.8Casino Verifier vs ProvablyFair.org Verifier4 records
Two verification tools are available. Both should produce identical results — if they don't, something has changed.
| Duel.com Verifier | ProvablyFair.org Verifier | |
|---|---|---|
| Source | Casino-controlled | Independent (audit codebase) |
| Access | duel.com/fairness | audit.provablyfair.org/casino/duel/tools/verify-bets |
| Trust model | Requires trusting the casino | Open-source, version-controlled |
| Monitoring | No change detection | Mismatches detected if casino changes logic |
6.9JavaScript Verification Script
Copy and run this in Node.js to verify any Blackjack bet:
const crypto = require('crypto'); const CARDS = [ '2D','2H','2S','2C', '3D','3H','3S','3C', '4D','4H','4S','4C', '5D','5H','5S','5C', '6D','6H','6S','6C', '7D','7H','7S','7C', '8D','8H','8S','8C', '9D','9H','9S','9C', '10D','10H','10S','10C', 'JD','JH','JS','JC', 'QD','QH','QS','QC', 'KD','KH','KS','KC', 'AD','AH','AS','AC',]; const MAX_FAIR = 52 * Math.floor(0x100000000 / 52); // 4,294,967,248 function getCard(serverSeed, clientSeed, nonce, cursor) { const key = Buffer.from(serverSeed, 'hex'); const message = `${clientSeed}:${nonce}:${cursor}`; const hash = crypto.createHmac('sha256', key).update(message).digest(); for (let i = 0; i + 4 <= hash.length; i += 4) { const value = hash.readUInt32BE(i); if (value < MAX_FAIR) { return CARDS[value % 52]; } } throw new Error('Failed to generate unbiased card — all 8 chunks rejected');} function computeCardStream(serverSeed, clientSeed, nonce, cursorCount) { const cards = []; for (let c = 0; c < cursorCount; c++) { cards.push(getCard(serverSeed, clientSeed, nonce, c)); } return cards;} function verifyHash(serverSeed, serverSeedHashed) { const hash = crypto .createHash('sha256') .update(Buffer.from(serverSeed, 'hex')) .digest('hex'); return hash === serverSeedHashed;} // ─── Replace with your bet's values ──────────────────────────────────────// Example: real bet 13418619 from the audit datasetconst serverSeed = 'b6f2cbcd411eedbd53587902410f17f43e962f2e374e97ccbec24088debd0556';const serverSeedHashed = '94c218c91df8997d4e7b1280687e90a3573c98739bd9220cd2fdd595699ef34f';const clientSeed = 'pf_Naa0pBEOuWmz6';const nonce = 1;const cursorCount = 5; // initial 4 + 1 hit card console.log('Hash check:', verifyHash(serverSeed, serverSeedHashed) ? 'PASS' : 'FAIL');console.log('Card stream:', computeCardStream(serverSeed, clientSeed, nonce, cursorCount));// → ['6D', '10H', '9D', '7S', '4D']// cursor 0: 6D (Player card 1)// cursor 1: 10H (Dealer upcard)// cursor 2: 9D (Player card 2)// cursor 3: 7S (Dealer hole — revealed at play-out)// cursor 4: 4D (Player hit card → hand 6D+9D+4D = 19, beats dealer 10H+7S = 17)6.10Python Verification Script
The same verification in Python (standard library only):
import hashlib, hmac CARDS = [ '2D','2H','2S','2C','3D','3H','3S','3C','4D','4H','4S','4C', '5D','5H','5S','5C','6D','6H','6S','6C','7D','7H','7S','7C', '8D','8H','8S','8C','9D','9H','9S','9C','10D','10H','10S','10C', 'JD','JH','JS','JC','QD','QH','QS','QC','KD','KH','KS','KC', 'AD','AH','AS','AC',] MAX_FAIR = 52 * (0x100000000 // 52) # 4,294,967,248 def get_card(server_seed, client_seed, nonce, cursor): key = bytes.fromhex(server_seed) message = f'{client_seed}:{nonce}:{cursor}'.encode() h = hmac.new(key, message, hashlib.sha256).digest() for i in range(0, len(h) - 3, 4): value = int.from_bytes(h[i:i+4], 'big') if value < MAX_FAIR: return CARDS[value % 52] raise RuntimeError('Failed to generate unbiased card — all 8 chunks rejected') def compute_card_stream(server_seed, client_seed, nonce, cursor_count): return [get_card(server_seed, client_seed, nonce, c) for c in range(cursor_count)] def verify_hash(server_seed, server_seed_hashed): computed = hashlib.sha256(bytes.fromhex(server_seed)).hexdigest() return computed == server_seed_hashed # ─── Replace with your bet's values ──────────────────────────────────────# Example: real bet 13418619 from the audit datasetserver_seed = 'b6f2cbcd411eedbd53587902410f17f43e962f2e374e97ccbec24088debd0556'server_seed_hashed = '94c218c91df8997d4e7b1280687e90a3573c98739bd9220cd2fdd595699ef34f'client_seed = 'pf_Naa0pBEOuWmz6'nonce = 1cursor_count = 5 # initial 4 + 1 hit card print('Hash check:', 'PASS' if verify_hash(server_seed, server_seed_hashed) else 'FAIL')print('Card stream:', compute_card_stream(server_seed, client_seed, nonce, cursor_count))# → ['6D', '10H', '9D', '7S', '4D']# cursor 0: 6D (Player card 1)# cursor 1: 10H (Dealer upcard)# cursor 2: 9D (Player card 2)# cursor 3: 7S (Dealer hole — revealed at play-out)# cursor 4: 4D (Player hit card → hand 6D+9D+4D = 19, beats dealer 10H+7S = 17)6.11Evidence Screenshots3 records
| Evidence | Description |
|---|---|
| E02 | Fairness page overview — "What is Provably Fair?" and "How it works" sections |
| E03 | Fairness verification tool — Blackjack selected, showing game-specific verification inputs (server seed, client seed, nonce, cursor count) |
| E11 | Client seed rotation response — server echoes client-submitted seed, does not assign |
Verify a real bet, step by stepVerify a real bet · Swipe for all 4 steps →
How it works
- 1
Place a Blackjack Bet
Place your stake and play the hand. The platform commits to the full 50-card stream via the provably fair algorithm before the deal animation plays — your hit / stand / double / split decisions only determine how many cards are consumed, not which cards come out.
- 2
Open the Fairness Modal
Open the Provably Fair modal on the Blackjack page. You'll see the active client seed, the hashed server seed, and your current nonce. Rotate the seed pair to unlock the revealed plaintext server seed for your previous bets.
- 3
Open Past Bet to Reveal Seed
Open Transactions and click a past Blackjack round. The per-bet modal shows the revealed plaintext server seed alongside the bet ID, client seed, nonce, and the dealer / player hands with the next cards still in the deck.
- 4
Recompute & Confirm
Click Verify in the per-bet modal to open the Provably Fair page with the seeds pre-populated. The page recomputes the 50-card stream inline — if the dealt cards, every hit, and the dealer play-out match what you saw at the table, the bet was provably fair.
Visual Walkthroughstep-by-step
Choose your stake on the Blackjack table and place a bet. The platform computes the entire 50-card stream using the provably fair algorithm before any card is shown. The first four cursors are the initial deal (player 1, dealer up, player 2, dealer hole); if you hit, double, or split, the next cards in the stream are drawn in order. Your decisions don't change which cards come out — only how many get used.

Duel.com Blackjack — main betting UI with dealer and player hands, side-bet toggle, and stake selector. Card stream is locked before the deal animation completes.
Open the Provably Fair modal on the game page. You'll see the active client seed, the hashed server seed (the casino's pre-commitment), and your current nonce. Below those, you can set the new client seed that will become active after rotation. Click 'Rotate seed' to retire the active seed pair and start a new one — this is what unlocks the revealed plaintext server seed for your previous bets in Step 3.

Provably Fair modal — active client seed, hashed server seed, nonce, customisable new client seed, next pair pre-commitment, and Rotate seed action.
Open Transactions and click a past Blackjack round. The per-bet modal shows the revealed plaintext server seed alongside the bet ID, client seed, and nonce. Both the dealer and player hands are displayed, plus the next cards still in the deck — every visible card derives from the same seed triple. The pre-committed hash from Step 2 must match SHA-256 of this revealed seed:
Server Seed (revealed) — plaintext value the casino committed to before your bet. Hash it to confirm it matches the pre-commitment.
Client Seed — your player-controlled entropy input.
Nonce — the sequential bet counter for this seed pair.

Per-bet transaction modal — revealed plaintext server seed, client seed, nonce, dealt hands, and the next cards in the deck.
Click Verify in the per-bet modal to open the Provably Fair page with the seeds pre-populated. The page recomputes the next 50 cards in the deck from the disclosed inputs and renders the Game Result inline. If every card in the recomputed stream matches the cards you saw dealt at the table — initial deal, hits, doubles, splits, and dealer play-out — the bet was provably fair: the casino committed to the outcome before you bet, you contributed entropy via your client seed, and the result is mathematically reproducible by anyone.

Provably Fair page — seeds populated from the per-bet modal, recomputed 50-card stream rendered inline matching the live game result.
Technical evidence
6.6Why Manual Verification Matters
True provably fair verification means you don't trust any casino-provided tool. Manual verification allows you to run calculations on your own machine, eliminate any possibility of a tampered verifier, and understand exactly how results are generated. For Blackjack this also lets you verify every card you saw — not just the deal — covering hit, double, and split paths that the casino's quick-look UI may not surface in detail.
6.7How the Algorithm Works (Plain English)5 records
Before you play, the server locks the full 50-card stream using three ingredients:
- The server's secret seed — committed by publishing its hash before you bet
- Your client seed — generated by your browser, unknown to the server
- The nonce — a counter that makes each bet unique within an epoch
| Cursor | Role |
|---|---|
| 0 | Player card 1 |
| 1 | Dealer upcard |
| 2 | Player card 2 |
| 3 | Dealer hole card (revealed during play-out) |
| 4, 5, …N-1 | Action cards drawn in order (hits, doubles, split deals, dealer additional draws) |
These ingredients are combined with HMAC-SHA256 (a cryptographic function) to draw cards independently at each cursor position. For each cursor c, the algorithm computes the HMAC-SHA256 of the hex-decoded server seed using clientSeed:nonce:c as the message. The 32-byte hash is read as eight 4-byte chunks. Each chunk is interpreted as an unsigned 32-bit integer; the first chunk that falls below MAX_FAIR = 4,294,967,248 (which equals 52 × ⌊2³² / 52⌋) is taken modulo 52 to index into the canonical 52-card array [2D, 2H, 2S, 2C, 3D, 3H, 3S, 3C, …, AS, AC]. The rejection ceiling eliminates modulo bias — every card has probability exactly 1/52.
Cursors map to the deal-and-action sequence as follows:
6.8Casino Verifier vs ProvablyFair.org Verifier4 records
Two verification tools are available. Both should produce identical results — if they don't, something has changed.
| Duel.com Verifier | ProvablyFair.org Verifier | |
|---|---|---|
| Source | Casino-controlled | Independent (audit codebase) |
| Access | duel.com/fairness | audit.provablyfair.org/casino/duel/tools/verify-bets |
| Trust model | Requires trusting the casino | Open-source, version-controlled |
| Monitoring | No change detection | Mismatches detected if casino changes logic |
6.9JavaScript Verification Script
Copy and run this in Node.js to verify any Blackjack bet:
const crypto = require('crypto'); const CARDS = [ '2D','2H','2S','2C', '3D','3H','3S','3C', '4D','4H','4S','4C', '5D','5H','5S','5C', '6D','6H','6S','6C', '7D','7H','7S','7C', '8D','8H','8S','8C', '9D','9H','9S','9C', '10D','10H','10S','10C', 'JD','JH','JS','JC', 'QD','QH','QS','QC', 'KD','KH','KS','KC', 'AD','AH','AS','AC',]; const MAX_FAIR = 52 * Math.floor(0x100000000 / 52); // 4,294,967,248 function getCard(serverSeed, clientSeed, nonce, cursor) { const key = Buffer.from(serverSeed, 'hex'); const message = `${clientSeed}:${nonce}:${cursor}`; const hash = crypto.createHmac('sha256', key).update(message).digest(); for (let i = 0; i + 4 <= hash.length; i += 4) { const value = hash.readUInt32BE(i); if (value < MAX_FAIR) { return CARDS[value % 52]; } } throw new Error('Failed to generate unbiased card — all 8 chunks rejected');} function computeCardStream(serverSeed, clientSeed, nonce, cursorCount) { const cards = []; for (let c = 0; c < cursorCount; c++) { cards.push(getCard(serverSeed, clientSeed, nonce, c)); } return cards;} function verifyHash(serverSeed, serverSeedHashed) { const hash = crypto .createHash('sha256') .update(Buffer.from(serverSeed, 'hex')) .digest('hex'); return hash === serverSeedHashed;} // ─── Replace with your bet's values ──────────────────────────────────────// Example: real bet 13418619 from the audit datasetconst serverSeed = 'b6f2cbcd411eedbd53587902410f17f43e962f2e374e97ccbec24088debd0556';const serverSeedHashed = '94c218c91df8997d4e7b1280687e90a3573c98739bd9220cd2fdd595699ef34f';const clientSeed = 'pf_Naa0pBEOuWmz6';const nonce = 1;const cursorCount = 5; // initial 4 + 1 hit card console.log('Hash check:', verifyHash(serverSeed, serverSeedHashed) ? 'PASS' : 'FAIL');console.log('Card stream:', computeCardStream(serverSeed, clientSeed, nonce, cursorCount));// → ['6D', '10H', '9D', '7S', '4D']// cursor 0: 6D (Player card 1)// cursor 1: 10H (Dealer upcard)// cursor 2: 9D (Player card 2)// cursor 3: 7S (Dealer hole — revealed at play-out)// cursor 4: 4D (Player hit card → hand 6D+9D+4D = 19, beats dealer 10H+7S = 17)6.10Python Verification Script
The same verification in Python (standard library only):
import hashlib, hmac CARDS = [ '2D','2H','2S','2C','3D','3H','3S','3C','4D','4H','4S','4C', '5D','5H','5S','5C','6D','6H','6S','6C','7D','7H','7S','7C', '8D','8H','8S','8C','9D','9H','9S','9C','10D','10H','10S','10C', 'JD','JH','JS','JC','QD','QH','QS','QC','KD','KH','KS','KC', 'AD','AH','AS','AC',] MAX_FAIR = 52 * (0x100000000 // 52) # 4,294,967,248 def get_card(server_seed, client_seed, nonce, cursor): key = bytes.fromhex(server_seed) message = f'{client_seed}:{nonce}:{cursor}'.encode() h = hmac.new(key, message, hashlib.sha256).digest() for i in range(0, len(h) - 3, 4): value = int.from_bytes(h[i:i+4], 'big') if value < MAX_FAIR: return CARDS[value % 52] raise RuntimeError('Failed to generate unbiased card — all 8 chunks rejected') def compute_card_stream(server_seed, client_seed, nonce, cursor_count): return [get_card(server_seed, client_seed, nonce, c) for c in range(cursor_count)] def verify_hash(server_seed, server_seed_hashed): computed = hashlib.sha256(bytes.fromhex(server_seed)).hexdigest() return computed == server_seed_hashed # ─── Replace with your bet's values ──────────────────────────────────────# Example: real bet 13418619 from the audit datasetserver_seed = 'b6f2cbcd411eedbd53587902410f17f43e962f2e374e97ccbec24088debd0556'server_seed_hashed = '94c218c91df8997d4e7b1280687e90a3573c98739bd9220cd2fdd595699ef34f'client_seed = 'pf_Naa0pBEOuWmz6'nonce = 1cursor_count = 5 # initial 4 + 1 hit card print('Hash check:', 'PASS' if verify_hash(server_seed, server_seed_hashed) else 'FAIL')print('Card stream:', compute_card_stream(server_seed, client_seed, nonce, cursor_count))# → ['6D', '10H', '9D', '7S', '4D']# cursor 0: 6D (Player card 1)# cursor 1: 10H (Dealer upcard)# cursor 2: 9D (Player card 2)# cursor 3: 7S (Dealer hole — revealed at play-out)# cursor 4: 4D (Player hit card → hand 6D+9D+4D = 19, beats dealer 10H+7S = 17)6.11Evidence Screenshots3 records
| Evidence | Description |
|---|---|
| E02 | Fairness page overview — "What is Provably Fair?" and "How it works" sections |
| E03 | Fairness verification tool — Blackjack selected, showing game-specific verification inputs (server seed, client seed, nonce, cursor count) |
| E11 | Client seed rotation response — server echoes client-submitted seed, does not assign |
Only disclosed inputs are used. Identical inputs always produce identical cards. Your hit / stand / double / split decisions consume cards from a pre-determined sequence — they cannot change the cards.
Reproducibility & Artifacts
Can anyone independently reproduce every finding in this audit?
This section consolidates the open-source repository, datasets, output artifacts, and reproducibility posture of the audit. Every finding, every statistic, every pass/fail result can be independently reproduced by anyone with a computer and an internet connection. The repository is the credential — not this report.
Repository Details
- GitHub: ProvablyFair-org/duel-blackjack
- Commit: bff0ef5f791208731f9ef113dabb506204b3dbb0
- Game: Blackjack (duel-blackjack)
- Public Verifier: audit.provablyfair.org/casino/duel/tools/verify-bets
Prerequisites
- Node.js 18+
- npm 8+
- Git
- TypeScript (installed via npm)
- GitHub: ProvablyFair-org/duel-blackjack
- Commit: bff0ef5f791208731f9ef113dabb506204b3dbb0
- Game: Blackjack (duel-blackjack)
- Public Verifier: audit.provablyfair.org/casino/duel/tools/verify-bets
- Node.js 18+
- npm 8+
- Git
- TypeScript (installed via npm)
All audit results can be independently reproduced using the pinned commit, dataset, and commands above. The dataset hash ensures you're running against the same 6,000 bets across 33,194 card cursors.
Commands to Reproduce
Installs TypeScript, ts-node, and cryptographic dependencies. npm test runs mocha (56 unit tests), then the 11.2M-round simulation, then the 33-step verification pipeline.
Runs Pass 1 (10 streams × 1,000,000 rounds = 10M) and Pass 2 (120 captured server seeds × 10,000 nonces each = 1.2M), then computes the analytical optimal-play RTP via the recursive infinite-deck EV solver. Total runtime ~5–10 minutes. Output written to outputs/simulation-results.json and outputs/rtp-convergence.html.
Audit Reproducibility Pinning
- Git Commit
- bff0ef5f791208731f9ef113dabb506204b3dbb0
- Node Version
- v18+ (tested on v22.x)
- Primary Dataset
- data/blackjack-dataset-6000hands.json (6,000 bets, 120 epochs, 33,194 cursors)
- Primary Dataset Hash (SHA-256)
- ad9cdffcea2f6cf535361e2265ae0bf95bb1290b3bbf9fee70dda053d917fe12
- Audit Date
- May 2026
- Audit ID
- PF-2026-DL09
- Headline RTP
- 99.4296% (optimal-play, recursive infinite-deck)
- House Edge
- 0.5704%
Output Artifacts3 files generated
| File | Contents | Size |
|---|---|---|
| verification-results.json | Steps 1–33 results, per-step pass/fail with evidence detail strings | ~10 KB |
| simulation-results.json | 11.2M-round simulation: optimal-play RTP, Pass 1 (10M Fisher-combined), Pass 2 (120 captured seeds × 10K cherry-pick), full convergence chart | ~25 KB |
| rtp-convergence.html | Interactive RTP convergence chart (self-contained HTML) | ~2 KB |
Repository Structure
duel-blackjack/ ├── src/ │ ├── rng.ts → HMAC-SHA256 card generation (52-card array, MAX_FAIR rejection) │ ├── optimal-play.ts → Recursive infinite-deck EV solver (anti-circularity) │ ├── simulate.ts → Monte Carlo — Pass 1 (10M rounds) + Pass 2 (1.2M cherry-pick) │ ├── sidebets.ts → Perfect Pairs + 21+3 classifiers and multipliers │ ├── strategy.ts → Basic-strategy oracle (used by Pass 1 simulator) │ ├── loader.ts → Dataset loader + SHA-256 hash guard │ └── types.ts → Type definitions ├── tests/ │ ├── verify.ts → 33-step verification pipeline │ ├── steps/ │ │ ├── commitment.ts → Steps 1–4: Seed hash integrity, commitment linkage, nonce audit │ │ ├── determinism.ts → Steps 5–6: Outcome recomputation + client seed influence │ │ ├── payouts.ts → Steps 7–9: Payout math, multiplier provenance, bet-size invariance │ │ ├── dataset.ts → Steps 10–15: House edge audit, config, epoch size, dataset hash, Phase D │ │ ├── simulation.ts → Steps 16–18: Anti-circularity, Pass 1 (10M), Pass 2 (1.2M cherry-pick) │ │ ├── game-specific.ts → Steps 19–27: Dealer rules, BJ payout 3:2, double, split, insurance, side bets │ │ ├── rules.ts → Steps 28–33: Action set, infinite-deck, distribution, stake brackets │ │ ├── statistical.ts → Informational: RTP, serial independence, distribution │ │ └── context.ts → Shared context + pass/fail helpers │ └── blackjack/ │ └── BlackjackTests.ts → 56 unit tests (Mocha) ├── data/ │ └── blackjack-dataset-6000hands.json → 6,000 bets across 6 phases (120 epochs, 33,194 cursors) ├── outputs/ → Generated by npm test │ ├── verification-results.json → Steps 1–33 pass/fail │ ├── simulation-results.json → 11.2M rounds (10M Pass 1 + 1.2M Pass 2) + optimal-play RTP │ └── rtp-convergence.html → Interactive RTP convergence chart ├── results/ → Reserved for run artifacts (.gitkeep) ├── evidence/ │ └── E01–E11 *.png → Game UI, fairness page, phase captures, seed rotation ├── capture/ │ └── capture.reference.js → Browser bet capture script ├── package.json ├── package-lock.json ├── tsconfig.json ├── .mocharc.yml ├── .gitignore ├── MANIFEST.md → Audit ID, scope, reproduction steps └── README.md → Repo overview + offline npm test instructions
Step-to-Section Cross-Reference33 verification steps mapped
| Step | Test Name | Report Section |
|---|---|---|
| 1 | Seed Hash Integrity | S1, S5 |
| 2 | Commitment Linkage | S1, S5 |
| 3 | Hash Consistency Within Epoch | S1, S5 |
| 4 | Nonce Audit | S1, S5 |
| 5 | Outcome Recomputation | S3, S5 |
| 6 | Client Seed Influence | S1, S2, S5 |
| 7 | Payout Math | S3 |
| 8 | Multiplier Provenance | S3 |
| 9 | Bet-Size Invariance | S3 |
| 10 | House Edge Audit | S4 |
| 11 | Config Completeness | S3 |
| 12 | Epoch Size | S1, S3 |
| 13 | Phase Labels | S3 |
| 14 | Dataset Hash | S7 |
| 15 | Phase D Client Seed Variation | S1, S2, S3 |
| 16 | Anti-Circularity (Optimal-Play RTP Engine) | S4 |
| 17 | Simulation Pass 1 | S2, S4, S5 |
| 18 | Pass 2 Cherry-Pick Detection | S4 |
| 19 | Dealer Rule Compliance | S3 |
| 20 | Blackjack Payout (3:2) | S3, S4 |
| 21 | Double Payout | S3, S4 |
| 22 | Split Cursor Ordering | S3 |
| 23 | Split Rules Verification | S3 |
| 24 | Split Payout Independence | S3 |
| 25 | Insurance Prompt Condition | S3 |
| 26 | Perfect Pairs + 21+3 Payout | S3 |
| 27 | Side Bet Independence | S3 |
| 28 | Available Actions Set | S3 |
| 29 | Infinite-Deck Confirmation | S3 |
| 30 | Outcome Distribution | S3 |
| 31 | Side Bet Stake Equality | S3 |
| 32 | Initial Deal Structure | S3 |
| 33 | Stake Bracket Bounds | S3 |
reproduce-audit.sh
ad9cdffcea2f6cf535361e2265ae0bf95bb1290b3bbf9fee70dda053d917fe12 — Step 14 verifies the loaded dataset hashes to this exact value before any other check runs.All audit results can be independently reproduced using the pinned commit, dataset, and commands above. The dataset hash ensures you're running against the same 6,000 bets across 33,194 card cursors.