
Video Poker
Independent verification, backed by open-source code.
audit and dataset.
- Live Bets Verified
- 5,400
- Parity Rate
- 100%
- Simulated Rounds
- 10M
- Enumerated RTP
- 99.9%
Verification pipeline
Real bets. Independently reproduced.
Placed anonymously at Duel.com. We rebuilt the game logic from scratch and matched all 5,400 recorded outcomes.
The casino result, reproduced.
- Casino outcome
- No winning hand
- Our reproduction
- No winning hand
- Recorded payout
- $0.0000
- 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
- Hand ClassificationPass
- Hold ValidityPass
- Replacement PoolPass
- Deck IntegrityPass
- Card Rank DistributionPass
- Card Suit DistributionPass
- Phase E Hold PatternsPass
- Optimal-Play RTP (99.9%)Pass
- Reconstructed HandsPass
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: 27 verification steps, 10M simulated rounds, the C(52,5) = 2,598,960-hand optimal-strategy enumeration, and 5,400 live bets re-verified.
This audit independently validates the Video Poker 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 5,400 real bets across 110 seed pairs and independently verified every single deal and final hand using our own implementation of the algorithm.
Audit Verdict
| Check | Result | Evidence |
|---|---|---|
| Overall Status | Pass | |
| RTP Verified | Pass | 99.9% optimal-play RTP from exhaustive C(52,5) = 2,598,960-hand enumeration · cross-validated against 9/6 Jacks or Better at 99.5439% · 10M-round chi-squared on hand distribution passes (Fisher's p = 0.7982) · flat 0.1% house edge |
| Live ↔︎ Verifier Parity | Pass | 100% — 5,400 / 5,400 bets matched (deck + final hand) |
| Commit-Reveal System | Pass | SHA-256 verified, 110 bet-bearing seed pairs — commitment intact across all rotations |
| Client Seed | Pass | Browser-generated + player customizable — server commits before client seed is known |
| RNG Analysis | Pass | HMAC-SHA256 backward Fisher-Yates of a 52-card deck — bias-free rejection sampling, no hidden inputs |
| Payout Logic | Pass | All 5,400 payouts verified — amount_won = amount_coins × multiplier matches the 10-rank pay table for every bet |
| Anti-Circularity | Pass | Theoretical deal-only RTP = 33.7238% from independent C(52,5) = 2,598,960 enumeration; optimal-play RTP = 99.9% from exhaustive 134,459-class strategy solver |
| Fairness Integrity | Pass | 16 fairness integrity tests (15 standard + 1 Video-Poker-specific) — 14 pass, 1 N/A (no replay surface — card sequence fixed at deal time), 1 flag disclosed (FI-PAYOUT-001: duplicate held card accepted — input-validation gap, does not affect fairness) |
| Determinism | Pass | Full reproducibility confirmed — every deck, replacement pool, and final hand recomputable from (serverSeed, clientSeed, nonce) |
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
- Server seeds are cryptographically committed via SHA-256 before play
- Client seed is browser-generated and players can customize it
- Nonces increment correctly and are never reused
- Each bet's 52-card deck is computed via HMAC-SHA256 backward Fisher-Yates with bias-free rejection sampling
- Deal outcomes (initial 5 cards) and draw outcomes (final 5 cards after holds) are reproducible from server seed, client seed, and nonce
- Hand classification matches the published 10-rank pay table for all 5,400 bets
- Optimal-play RTP is 99.9% (exhaustive C(52,5) = 2,598,960-hand enumeration)
- Bet amount does not influence the deck or replacement pool
- Players can independently verify every bet — both the deal and the draw
What Audit Excludes
- Infrastructure or server security
- Wallet, payments, or operational systems outside game logic
- Rakeback layer — 99.9% is the certified RTP; 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, browser-generated — server commits before your seed is known |
| Seed Handling | Client seed control, nonce lifecycle, seed pair rotation |
| RNG Analysis | HMAC-SHA256 backward Fisher-Yates 52-card shuffle, rejection sampling, bias analysis |
| Two-Step Game Flow | Deal (5 initial cards) and Draw (final hand from holds + replacement pool) — both halves verified independently |
| Payout Logic | 10-rank pay table accuracy, house edge verification, bet-size invariance (Phase C) |
| Live Parity | Independent deck shuffle + held-card replacement recomputation vs live game results |
| RTP Validation | Anti-circularity proof (deal-only), simulated RTP (10M rounds), optimal-play enumeration (2,598,960 hands), cross-validation against 9/6 Jacks or Better, cherry-pick detection (Pass 2) |
| Hand Classification | Independent re-evaluation of all 5,400 final hands against the 10-rank pay table |
| Hold Pattern Coverage | Phase E systematic hold patterns across 4 distinct hold counts (0, 1, 2, 5) |
| 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 (5,400 / 5,400)
- Both halves of every bet are verified — the initial deal AND the final hand after holds
- Optimal-play RTP is proven analytically: exhaustive C(52,5) = 2,598,960-hand enumeration with 134,459 canonical classes returns 99.9000%; cross-validated by re-running the same solver against the standard 9/6 Jacks or Better pay table to its known 99.5439% target
- Client seed is a genuine, browser-generated input that materially influences results (100% Deck Change)
- The house edge is a flat 0.1% — no scaling edge, no bet-size dependence
- The player's hold choices cannot influence the deck composition — the 10-card outcome is fixed at deal time, holds only select which positions consume the replacement pool
References
Video Poker — Game Rules6 sections▶
Video Poker on Duel.com is a 5-card draw poker game played against a fixed pay table. The player is dealt 5 cards from a 52-card deck, chooses which to hold and which to discard, and the discarded cards are replaced from a pre-determined pool to form a final 5-card hand. The full 52-card shuffle (initial 5 + 5-card replacement pool) is committed via HMAC-SHA256 backward Fisher-Yates before the deal animation — your hold choice cannot change the cards available to you.
How to Play
1. Enter bet amount — Choose how much to wager on the hand.
2. Deal — The platform deals 5 cards from positions 0–4 of the shuffled 52-card deck.
3. Hold — Select which of the 5 cards to keep (0 to 5 cards). The remaining cards are discarded.
4. Draw — The platform replaces the discarded cards with positions 5–9 of the same shuffle, consumed left-to-right.
5. Outcome — The final 5-card hand is evaluated. Payout = bet amount × multiplier for the hand rank.
Win Conditions
The win condition in Video Poker depends on the rank of your final 5-card hand against the pay table.
| Outcome | Condition | Example |
|---|---|---|
| Royal Flush | 10, J, Q, K, A all of the same suit | 10♠ J♠ Q♠ K♠ A♠ → 812.97× — ~1 in 649,740 chance |
| Straight Flush | Five sequential cards of the same suit | 5♠ 6♠ 7♠ 8♠ 9♠ → 58× |
| Four of a Kind | Four cards of the same rank | K♠ K♥ K♦ K♣ 4♠ → 26× |
| Full House | Three of a kind plus a pair | J♠ J♥ J♦ 5♣ 5♠ → 9× |
| Flush | Five cards of the same suit (non-sequential) | 2♠ 7♠ 9♠ J♠ K♠ → 6× |
| Straight | Five sequential cards (mixed suits) | 5♦ 6♠ 7♥ 8♦ 9♣ → 4× |
| Three of a Kind | Three cards of the same rank | Q♠ Q♥ Q♦ 6♣ 2♦ → 3× |
| Two Pair | Two pairs of different ranks | 10♠ 10♥ 4♦ 4♣ 7♠ → 2× |
| Jacks or Better | A pair of Jacks, Queens, Kings, or Aces | K♦ K♠ 7♥ 5♣ 3♠ → 1× |
| No Win | Anything below a pair of Jacks | 9♦ 6♠ 4♥ J♣ 2♦ → 0× |
Hold Strategy & RTP
The core mechanic of Video Poker is the player's hold decision — which cards to keep from the initial 5-card deal.
- Hold choice does not change the deck — the 10-card window (5 initial + 5 replacement pool) is committed before the deal; holds only select which positions consume from the pool
- Hold choice determines RTP achieved — the game's headline 99.9% RTP is the optimal-play return, achievable only when each hand is held according to the EV-maximizing strategy
- Sub-optimal play earns less — a player who randomly held cards (deal-only RTP) would receive 33.7% return; the strategy gap between random and optimal is the player's responsibility
- House edge is flat at 0.1% — there is one pay table; bet size, time of day, and seed history have no effect on RTP
Parameters
| Parameter | Value | Notes |
|---|---|---|
| Deck Size | 52 cards | Standard playing-card deck (4 suits × 13 ranks) |
| Initial Hand Size | 5 cards | Dealt from positions 0–4 of the shuffle |
| Replacement Pool | 5 cards | Positions 5–9 of the same shuffle, consumed left-to-right by hold pattern |
| Hand Ranks | 10 (Royal Flush → No Win) | Standard 5-card poker rankings |
| Hold Choices | 0–5 cards | Player decides per hand which to keep |
| House Edge | 0.1% flat | No scaling — same edge for all bet sizes |
| Optimal-Play RTP | 99.9000% | From exhaustive C(52,5) = 2,598,960 × 32 hold patterns enumeration |
| Deal-Only RTP | 33.7238% | Theoretical RTP if cards were randomly held — anti-circularity proof |
| RNG Algorithm | HMAC-SHA256 | Backward Fisher-Yates over 52 cards with bias-free rejection sampling |
Seed Formats
Every Video Poker hand uses three cryptographic inputs to generate the deck.
| Seed Type | Format | Example | Purpose |
|---|---|---|---|
| Server Seed | 64-char hex (32 bytes) | 2424d436197860c9… | Casino-provided randomness |
| Client Seed | Alphanumeric string | cpc4ihmPtvpHpapv | Player-contributed entropy |
| Nonce | Integer (0–49) | 6 | Ensures uniqueness per bet within epoch |
Multiplier Table & Payout
Payouts in Video Poker are determined by the rank of the final 5-card hand. The multiplier table is calibrated so that the expected return under optimal play is exactly 99.9%.
win_amount = bet_amount × multiplier_table[hand_rank]| Hand Rank | Multiplier | Hand Count (of 2,598,960) | P(rank) × multiplier |
|---|---|---|---|
| Royal Flush | 812.9719× | 4 | 0.0012512 |
| Straight Flush | 58× | 36 | 0.0008034 |
| Four of a Kind | 26× | 624 | 0.0062425 |
| Full House | 9× | 3,744 | 0.0129652 |
| Flush | 6× | 5,108 | 0.0117924 |
| Straight | 4× | 10,200 | 0.0156986 |
| Three of a Kind | 3× | 54,912 | 0.0633854 |
| Two Pair | 2× | 123,552 | 0.0950780 |
| Jacks or Better | 1× | 337,920 | 0.1300212 |
| No Win | 0× | 2,062,860 | 0.0000000 |
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 a result before the player bets
- The player contributes randomness that the casino cannot predict
- Anyone can verify the outcome after the fact
High-Level Overview7 sections▶
Checklist Reference
Based on the ProvablyFair.org Audit Execution Checklist, here are the tests covered under this audit document.
1. Commit-Reveal System & Seed Handling
| Test | Description |
|---|---|
| Server seed commit exists before play | SHA-256 hash shown to player before betting |
| Server seed reveal matches commit | SHA-256(hexDecode(revealed)) = committed hash |
| Client seed control | Player can set/change client seed via rotation UI |
| Nonce increments correctly | Starts at 0, +1 per bet, resets at epoch boundary |
| Full determinism | Same inputs → same deck (same initial cards AND same replacement pool) |
2. Randomness & Entropy Model
| Test | Description |
|---|---|
| RNG depends only on seeds + nonce | No external inputs (drand absent from Video Poker) |
| No mixed entropy sources | No timestamps, Math.random, etc. |
| Unbiased mapping | Bias-free rejection sampling with maxFair ceiling eliminates modulo bias for all ranges (2–52) |
| No state leakage | RNG isolated per round — each shuffle step uses unique cursor |
3. Verifier ↔︎ Live Parity
| Test | Description |
|---|---|
| Live outcomes match verifier | 5,400 / 5,400 bets recomputed with 0 mismatches — both initial cards AND final hand after holds |
| Multi-phase verification | Phases A (broad sampling, 3,000), B (targeted hands, 1,000), C (bet-size invariance, 100), D (auditor seed, 500), E (systematic hold patterns, 800) |
| Bet-size invariance | $10 bets produce same decks as $0.01 bets (Phase C, 100/100 verified) |
| Hold pattern coverage | All 4 systematic hold counts verified (hold-0/1/2/5: 100/500/100/100 in Phase E) |
4. Game Logic & RTP Validation
| Test | Description |
|---|---|
| Anti-circularity proof | Σ P(rank) × multiplier = 33.7238% from independent C(52,5) = 2,598,960 enumeration (deal-only) |
| Optimal-play RTP solver | 99.9000% from exhaustive C(52,5) × 32 hold patterns enumeration; 134,459 canonical strategy classes |
| Solver cross-validation | Same solver on standard 9/6 Jacks or Better → 99.5439% (target match to 6 decimal places) |
| House edge audit | Flat 0.1% confirmed across all 5,400 bets — no scaling edge |
| Payout rules correctness | Win amount matches multiplier × bet within 1e-8 |
| Simulated RTP convergence | 10M Pass 1 rounds, Fisher's combined p = 0.7982; deal-only RTP converges on theoretical 33.7238% |
| Cherry-pick detection | 5/110 captured server seeds flagged at α=0.05 (binomial p = 0.6485, well within noise) — no evidence of seed pre-selection |
5. Fairness Integrity & Player Verification
| Test | Description |
|---|---|
| Player can reproduce results offline | Using seeds + nonce + backward Fisher-Yates 52-card shuffle |
| Verifier logic matches live logic | Same HMAC-SHA256 backward Fisher-Yates algorithm |
| Verifier publicly accessible | ProvablyFair.org verifier — no login required |
| No reliance on private APIs | Fully client-side verification |
| Hold/discard integrity (VP-specific) | Hold choice cannot alter deck composition — 5,400/5,400 verified across 4 hold counts |
| 27 verification steps | Commit-reveal, determinism, payout, distribution, anti-manipulation, hand classification, hold validity, replacement pool, deck integrity, optimal-play RTP |
High-Level Flow
To get an overview of how the process works, here is a high-level breakdown:
1. Player Bets — Selects bet amount and clicks “deal”
2. Seeds Combined — HMAC-SHA256(hexDecode(serverSeed), clientSeed:nonce:cursor) for each shuffle step
3. RNG Output — Backward Fisher-Yates over 52 cards: 51 iterations from i=51 downto 1, each step swaps positions[i] with a bias-free random index
4. Initial Deal — deck[0..4] become the player’s 5-card initial hand
5. Player Holds — Player selects which cards to keep (0–5 cards); discarded positions are tracked
6. Draw — Discarded positions are filled from deck[5..9], consumed left-to-right
7. Hand Evaluation — Final 5-card hand classified into one of 10 ranks
8. Payout Result — win_amount = bet_amount × multiplier_table[hand_rank]
Provably Fair Model
Provably fair gambling systems use cryptographic primitives to guarantee the integrity of outcomes. The model relies on three components: a server seed committed via hash before play, a player-controlled client seed, and an incrementing nonce. These inputs are combined using HMAC-SHA256 to produce deterministic, verifiable results. This section documents the global provably fair architecture used by almost all casinos and all relevant games.
Commit-Reveal Model
The Commit-Reveal model is integral to ensuring fairness and transparency in online gambling. This model involves several key phases:
Commit Phase:
Before any bets are placed, the casino generates a random server seed. To prove its authenticity and prevent later manipulation, only the SHA-256 hash of the hex-decoded seed is sent to the player. This ensures that while the player cannot know the seed initially, they can verify it later.
Bet Phase:
The player places their bet. The server computes the entire 52-card shuffled deck using HMAC-SHA256 with the server seed (hex-decoded) as the HMAC key and the client seed, nonce, and cursor as the message. The same server seed and client seed pair are used for every bet in the epoch; the player can rotate the seed pair at any time to end the epoch.
Reveal Phase:
After the player rotates to a new seed pair, the server reveals the plaintext server seed from the completed epoch. The player can now independently verify SHA-256(hexDecode(serverSeed)) = committedHash.
Verify Phase:
Anyone can recompute every bet's full 52-card shuffled deck from the revealed server seed, client seed, and nonce using the published backward Fisher-Yates algorithm. The initial cards (deck[0..4]) and replacement pool (deck[5..9]) are both verifiable. Combined with the recorded hold pattern, the final hand is fully reconstructible.
Player-Controlled Client Seed
The player's client seed is generated by the browser and submitted to the server via the seed rotation UI. Players can set their own client seed at any time. This ensures:
- The casino cannot predict the full RNG input
- Players contribute entropy that they control
- Results depend on both parties' inputs
Nonce Lifecycle
The nonce is a counter that increments with each bet within an epoch:
- Starts at 0 for each new server seed
- Increments by exactly 1 per bet
- Never reused within the same seed epoch
- Resets to 0 when the player rotates to a new server seed
Seed Epoch: (serverSeed, clientSeed) Bet 1: nonce = 0 → Deck A → Initial + PoolBet 2: nonce = 1 → Deck B → Initial + PoolBet 3: nonce = 2 → Deck C → Initial + Pool...Bet N: nonce = N−1 → Deck Z → Initial + Pool [Player rotates seed — epoch complete]Next bet: nonce = 0 → Deck X (new seed pair)Determinism Guarantee
Given identical inputs, the output is always identical:
HMAC-SHA256(hexDecode(serverSeed), clientSeed:nonce:cursor) → Always same hashSame hash per step → Always same swap index in Fisher-YatesSame swaps across all 51 steps → Always same shuffled 52-card deckSame deck → Always same initial cards (deck[0..4]) AND same replacement pool (deck[5..9])Same initial + same hold pattern + same pool → Always same final handSame final hand → Always same hand rank → Always same payoutTechnical Glossary5 categories▶
Core Concepts
| Term | Definition |
|---|---|
| Provably Fair | A cryptographic system that allows players to mathematically verify that game outcomes were not altered. Relies on commit-reveal schemes and deterministic algorithms. |
| Commit-Reveal Protocol | A two-phase process in which the casino commits to a result (by showing its hash) before the player bets, then reveals the actual value after the bet. |
| Determinism | The property that identical inputs always produce identical outputs. Same server seed, client seed, and nonce must always generate the same shuffled deck. |
| Client Seed Origin | The method by which the client seed is generated. Full Pass: browser-generated default + player customizable seed. Conditional Pass: server-assigned default + player customizable seed. Hard Fail: player cannot set own seed. |
Seed System
| Term | Definition |
|---|---|
| Server Seed | A random 32-byte value generated by Duel.com, transmitted as a 64-character hex string. Hashed and shown to players before betting, revealed after epoch rotation. |
| Client Seed | A random value controlled by the player, generated by the browser. Players can change it at any time via the seed rotation UI. |
| Nonce | A sequential counter that increments per bet within an epoch. Starts at 0 and resets to 0 when the player rotates to a new server seed. |
| Hashed Server Seed | SHA-256(hexDecode(serverSeed)) — the commitment hash shown before betting. After rotation, players verify the revealed seed produces this hash. |
| Cursor | Index used in the HMAC message during the backward Fisher-Yates shuffle. cursor = 51 − i, starting at 0 for the first step (i=51). Each cursor value produces one shuffle step. |
| Epoch | A sequence of consecutive bets sharing the same server seed and client seed pair. Ends when the player rotates to a new seed pair. This audit covered 110 bet-bearing epochs. |
Cryptographic Functions
| Term | Definition |
|---|---|
| HMAC-SHA256 | Hash-based Message Authentication Code using SHA-256. Duel Video Poker uses HMAC-SHA256 with the hex-decoded server seed as key and clientSeed:nonce:cursor as message. 51 calls per bet (one per shuffle step). |
| SHA-256 | Secure Hash Algorithm 256-bit. Used for server seed commitment: SHA-256(hexDecode(serverSeed)) = serverSeedHashed. |
| Hex Decoding | Converting a 64-character hex string to 32 raw bytes. Critical for the HMAC key — using the hex string as UTF-8 produces wrong outputs. |
| Backward Fisher-Yates Shuffle | A variant of the Fisher-Yates algorithm that iterates from index 51 down to 1, swapping each position with a random lower index. Produces a uniformly random permutation of the 52-card deck. |
Verification Terms
| Term | Definition |
|---|---|
| Verifier | A tool that independently calculates the shuffled 52-card deck (initial + replacement pool) using provided seeds and nonce. The ProvablyFair.org verifier is built from the audit codebase. |
| Parity | Degree of matching between verifier and live game results. 100% parity = every deck (both halves) matches and every final hand reconstructs. This audit: 5,400/5,400 exact match. |
| Anti-Circularity | Proof that RTP derives from first principles, not casino's own claims. Σ P(rank) × multiplier from independent C(52,5) enumeration = 33.7238% (deal-only); cross-validated optimal-play solver returns 99.9000%. |
| Optimal-Play RTP | The expected return achievable with the EV-maximizing hold decision for every dealt hand. Computed by exhaustive enumeration of all 2,598,960 hands × 32 hold patterns. Duel.com Video Poker = 99.9000%. |
| Cross-Validation | Re-running the optimal-play solver against the standard 9/6 Jacks or Better pay table (target 99.5439%) before computing the Duel.com RTP. Confirms the solver is correct independent of the audited pay table. Match: 99.543904% vs 99.5439%. |
| Rejection Sampling | Bias elimination technique used in the shuffle. A maxFair ceiling discards values that would cause modulo bias, ensuring uniform randomness for all ranges (2–52). |
Game Mechanics
| Term | Definition |
|---|---|
| Initial Cards | The first 5 cards dealt to the player (deck[0..4]). Determined cryptographically before the deal animation plays. |
| Replacement Pool | The next 5 cards in the shuffled deck (deck[5..9]). These replace any cards the player discards, consumed left-to-right by the hold pattern. |
| Hold Pattern | A 5-bit pattern indicating which positions the player chose to keep from the initial deal. The player can hold 0 to 5 cards. Held cards remain; non-held positions consume from the replacement pool in order. |
| Final Hand | The 5-card hand after the draw. Constructed deterministically from (initial_cards, hold_pattern, replacement_pool). Evaluated against the 10-rank pay table. |
| Hand Rank | The poker rank of the final 5-card hand: Royal Flush, Straight Flush, Four of a Kind, Full House, Flush, Straight, Three of a Kind, Two Pair, Jacks or Better, or No Win. |
| Pay Table | The mapping from hand rank to multiplier. Duel.com uses a custom pay table calibrated for 99.9% optimal-play RTP, with a Royal Flush multiplier of 812.97× to support the headline RTP claim. |
| Anti-Circularity (Deal-Only RTP) | The expected return computed from independent textbook 5-card poker hand counts × the casino's pay table. Equals 33.7238% — a non-circular proof of the underlying card probabilities. |
Seed, Nonce & Determinism
Can the casino change your outcome after you bet?
Every Video Poker 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 the deck after you bet — and because the entire 52-card shuffle (your initial 5 cards plus the 5-card replacement pool) is determined at deal time, your hold choice cannot alter the cards available to you.
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 (Full Pass origin)
- Players can set or change their client seed at any time via the rotation UI
- Nonce increments by 1 per bet across all 110 seed pairs
- The full 52-card deck — and therefore both the initial deal and the final hand — is determined by the seed inputs before the deal is shown
- Identical inputs always produce the same shuffled deck — confirmed across all 5,400 bets
- Your client seed is a genuine input — changing it changes the deck
What this means for you
- The casino cannot change your initial 5 cards or your replacement pool 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 an outcome
- Any deck and final 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 (Full Pass origin)
- Players can set or change their client seed at any time via the rotation UI
- Nonce increments by 1 per bet across all 110 seed pairs
- The full 52-card deck — and therefore both the initial deal and the final hand — is determined by the seed inputs before the deal is shown
- Identical inputs always produce the same shuffled deck — confirmed across all 5,400 bets
- Your client seed is a genuine input — changing it changes the deck
- The casino cannot change your initial 5 cards or your replacement pool 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 an outcome
- Any deck and final 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 epochPass
Seed hash integrityPass
Deterministic outputPass
Client seed participationPass
All 110 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, and nonce always produce the same 52-card deck, which fixes both the initial 5-card deal and the replacement pool used for the draw. The casino cannot change your result after you bet.
| Check | Result | Evidence |
|---|---|---|
| Server seed committed before bet | Pass | SHA-256 hash of server seed published before play — casino cannot change randomness after betting |
| Client seed origin | Pass | Browser-generated (Full Pass) — 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 110 seed pairs |
| Hash consistency within epoch | Pass | serverSeedHashed constant across all bets within each of 110 epochs |
| Seed hash integrity | Pass | 110 / 110 revealed seeds hash-verified — commitment chain intact |
| Deterministic output | Pass | Same (serverSeed, clientSeed, nonce) always produces same 52-card deck — 5,400 / 5,400 confirmed (initial deal + final hand) |
| 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 epochPass
Seed hash integrityPass
Deterministic outputPass
Client seed participationPass
All 110 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, and nonce always produce the same 52-card deck, which fixes both the initial 5-card deal and the replacement pool used for the draw. The casino cannot change your result 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.
export function verifyHash(serverSeed: string, serverSeedHashed: string): boolean { const seedBytes = Buffer.from(serverSeed, 'hex'); const computed = crypto.createHash('sha256').update(seedBytes).digest('hex'); return computed === serverSeedHashed;}Real Example from Live Data:
// Seed block captured at bet time (hand_id 589162, Phase A, nonce=0){ "serverSeedHashed": "107cca7f92491e65e438a779d79dd253a16ccfef10d5ce5d31d2bcbb3ff22f7b", "clientSeed": "cpc4ihmPtvpHpapv", "nonce": 0} // After seed rotation, the plaintext was revealed as:// serverSeed = "2424d436197860c90bad4022573f14eab6c9da3dcc2d50e57ad7d52357f0fdc9"//// SHA-256 of the revealed seed equals the committed hash above — verified below.Verification:
const crypto = require('crypto'); const serverSeed = "2424d436197860c90bad4022573f14eab6c9da3dcc2d50e57ad7d52357f0fdc9"; const hashedServerSeed = crypto .createHash("sha256") .update(Buffer.from(serverSeed, 'hex')) .digest("hex"); console.log(hashedServerSeed);// Output: 107cca7f92491e65e438a779d79dd253a16ccfef10d5ce5d31d2bcbb3ff22f7b ✅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 previous epoch began.
// Step 2: Commitment Linkagefor (let i = 0; i < seeds.length - 1; i++) { const curr = seeds[i]; const next = seeds[i + 1]; if (curr.seed.nextServerSeedHash === next.seed.serverSeedHashed) { linkVerified++; } else { linkFailed++; }}1.3Hash Consistency Within Epoch
For each of the 110 bet-bearing epochs, the serverSeedHashed 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 115 seed entries across 110 bet-bearing epochs. The 5 non-bet-bearing entries are 4 phase-boundary marker rotations (the capture script forces a clean seed rotation at each phase transition A→B→C→D→E) and 1 fresh-start entry at the beginning with no prior commitment. Every bet-bearing epoch had its commit hash verified against the revealed server seed; marker rotations carry valid commitments but no bets reference them.
// Step 3: Hash Consistency Within Epochlet epochsChecked = 0;let inconsistent = 0;for (const [hash, epochBets] of byHash.entries()) { epochsChecked++; for (const b of epochBets) { if (b.seed.serverSeedHashed !== hash) inconsistent++; }}serverSeedHashed constant within every epoch. Zero mid-epoch changes detected.1.4Client Seed Origin & Control
Players have full control over their client seed through the Duel.com fairness UI, allowing them to view, modify, or randomize it at any time before placing bets. The client seed is an input to every HMAC-SHA256 computation — changing it produces a completely different 52-card deck. The client seed is browser-generated (Full Pass origin) — the server commits to its seed before the client seed for that epoch is known, making cherry-picking structurally impossible.
06cSUjVZ68knDfoa0EQGQGelg8FzMyqV0LdEzKLcV16prTqX0ON0D9AwUXWoAKJf1P1dumPdS3fV4hOi
pfauditmod66jae) — every one recomputed correctly, proving the server honours arbitrary player-supplied seeds. Evidence: video-poker-dataset-5400bets.json1.5Nonce Incrementation
The nonce begins at 0 and increments by 1 for each bet under the same server seed. In Video Poker, the nonce advances once per bet — each bet (deal + draw together) uses one nonce value, regardless of which cards the player chooses to hold. 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. In two epochs, our capture tool re-sent a bet request after a network timeout, leaving a one-nonce gap in our recorded sequence; the missing nonces were fetched and confirmed afterwards via commit-reveal recomputation, so the full sequence is verified. This was a capture-side artifact on our end, not a gap produced by the game.
// Step 4: Nonce Auditconst ss = seedMap.get(hash);const clientSeed = epochBets[0].seed.clientSeed; if (ss) { // Server seed revealed — recompute the missed nonce's deck for (const missedNonce of missingNonces) { const deck = computeShuffledDeck(ss, clientSeed, missedNonce); const hand = deck.slice(0, 5); const rank = evaluateHand(hand); initialDealDerivable++; gapDetails.push( `Epoch ${hash.slice(0, 8)}: nonce ${missedNonce} missed (capture-retry); ` + `initial deal derivable from revealed seed: ${hand.join(',')} → ${rank} (full missing round not verified)` ); }} else { for (const missedNonce of missingNonces) { unverifiable++; gapDetails.push(`Epoch ${hash.slice(0, 8)}: nonce ${missedNonce} missed; seed UNREVEALED — unverifiable`); }}1.6Deterministic Mapping
The RNG algorithm is fully deterministic: given the same server seed, client seed, and nonce, it always produces the exact same 52-card deck. The algorithm is a backward Fisher-Yates shuffle with HMAC-SHA256. The server seed is hex-decoded to 32 raw bytes before use as the HMAC key. HMAC message format: clientSeed:nonce:cursor where cursor starts at 0 for the first shuffle step (i=51). Each step produces a uint32 from a 4-byte HMAC chunk; rejection sampling via the maxFair ceiling ensures bias-free index selection. After 51 steps, the deck is fully shuffled — and deck[0..4] and deck[5..9] together form the bet's full entropy footprint.
export function computeShuffledDeck( serverSeed: string, clientSeed: string, nonce: number,): string[] { const key = Buffer.from(serverSeed, 'hex'); return computeShuffledDeckFromBuffer(key, clientSeed, nonce);}initial_cards, final_cards, and the inferred replacement pool. Zero mismatches across initial deal or final hand. No external entropy sources — outcomes derive solely from (serverSeed, clientSeed, nonce).Real Bet Verified:
// Source: data/video-poker-dataset-5400bets.json// hand_id 589162 (Phase A, nonce 0 — first bet of the epoch)// ✅ VERIFIED — deck recomputed from revealed server seed { "serverSeed": "2424d436197860c90bad4022573f14eab6c9da3dcc2d50e57ad7d52357f0fdc9", "serverSeedHashed": "107cca7f92491e65e438a779d79dd253a16ccfef10d5ce5d31d2bcbb3ff22f7b", "clientSeed": "cpc4ihmPtvpHpapv", "nonce": 0, "deal": { "initial_cards": ["8H", "JS", "4D", "10D", "4C"] }, "draw": { "held_cards": ["JS", "4D", "10D"], "final_cards": ["4S", "JS", "4D", "10D", "KS"], "combination": "none", "multiplier": "0", "amount_won": "0" }}Verification:
// computeShuffledDeck("2424d436...", "cpc4ihmPtvpHpapv", 0)//// Backward Fisher-Yates: 51 swaps (i=51 downto 1), 1 HMAC per swap.// Each step: HMAC-SHA256(hexDecode(serverSeed), "cpc4ihmPtvpHpapv:0:{cursor}")// cursor = 51 - i (starts at 0)// Rejection sampling: chunk < maxFair → j = chunk % range; swap deck[i] ↔ deck[j]//// Step cursor=0 (i=51, range=52, maxFair=0xffffffd0):// HMAC = 4ed6dc5abf2a0d7f4c905c4d32f34c9c55d7e62c4c33db49eb1e816300541c26// chunk[0] = 0x4ed6dc5a → j = 1322703962 % 52 = 34// swap deck[51]=AC ↔ deck[34]=10S//// Step cursor=1 (i=50, range=51):// HMAC = d51a7eafd857be2a9839e30a781fae9a9adfccd034cf80ed9bf07055aae5c807// chunk[0] = 0xd51a7eaf → j = 3575283375 % 51 = 30// swap deck[50]=AS ↔ deck[30]=9S//// Step cursor=2 (i=49, range=50, maxFair=0xffffffd2):// HMAC = 4010cbe1c26f386251a8de38fcbece8f3045f3f58e95ef754739b605c6bfeff1// chunk[0] = 0x4010cbe1 → j = 1074842593 % 50 = 43// swap deck[49]=AH ↔ deck[43]=QC//// ... 48 more swaps ...//// Final deck[0..9] = ["8H","JS","4D","10D","4C","4S","KS","AH","KD","QD"]//// initial_cards = deck[0..4] = ["8H","JS","4D","10D","4C"] ✅// replacement_pool = deck[5..9] = ["4S","KS","AH","KD","QD"] ✅1.7Two-Step Game Flow & Determinism at Deal Time
Video Poker is a two-step interactive game: every bet involves two API calls — a deal (server returns the player's initial 5 cards) and a draw (player sends their held_cards choice; server returns the final 5-card hand and payout). What makes the game provably fair despite the player input in the middle is that the entire 10-card outcome is fixed at deal time. The first 51-step Fisher-Yates shuffle commits the full deck; the player's hold choice only selects which positions consume from the replacement pool — it adds no entropy.
Concretely: deck[0..4] is the initial hand shown to the player. deck[5..9] is the replacement pool. When the player submits held_cards, the server walks positions 0 through 4 in order, keeping any position whose card is in held_cards and consuming the next pool card (left-to-right, starting at deck[5]) for any position that isn't held. This means a player who holds zero cards gets deck[5..9] as their final hand; a player who holds all five gets deck[0..4] back unchanged; and any partial hold composes deterministically from the same fixed deck.
export function computeFinalHand( initialCards: readonly string[], heldCards: readonly string[], replacementPool: readonly string[],): string[] { const heldSet = new Set(heldCards); const final: string[] = []; let poolIdx = 0; for (let pos = 0; pos < HAND_SIZE; pos++) { const card = initialCards[pos]; if (heldSet.has(card)) { final.push(card); } else { if (poolIdx >= replacementPool.length) { throw new Error(`Pool exhausted at position ${pos}`); } final.push(replacementPool[poolIdx++]); } } return final;}(initial_cards, held_cards, replacement_pool) matches the server's final_cards for every bet. The player's hold choice cannot alter the deck composition — it only determines which deck positions are consumed.Real Bet Walkthrough:
// Step 1 — Deal (server-side, before player input):// Shuffle deck from (serverSeed, clientSeed, nonce=0)// Server returns deck[0..4] to the player as initial_cards:// initial_cards = ["8H", "JS", "4D", "10D", "4C"]//// Step 2 — Hold (player input, no entropy added):// Player selects held_cards = ["JS", "4D", "10D"]// This freezes positions 1, 2, 3 of the initial hand.//// Step 3 — Draw (server-side, deterministic):// replacement_pool = deck[5..9] = ["4S", "KS", "AH", "KD", "QD"] (already fixed)// Walk positions 0..4 in order:// pos 0: "8H" — not held → consume pool[0] = "4S"// pos 1: "JS" — held → keep "JS"// pos 2: "4D" — held → keep "4D"// pos 3: "10D" — held → keep "10D"// pos 4: "4C" — not held → consume pool[1] = "KS"// final_cards = ["4S", "JS", "4D", "10D", "KS"] ✅//// Hand classification: "none" (no pair of jacks-or-better)// Multiplier: 0× · Payout: $0.001.8Client Seed Influence
To confirm the client seed is a genuine input to the HMAC-SHA256 computation, two independent tests were run. First, a sample of 500 bets was recomputed with a deliberately incorrect client seed (wrong-client-seed-test); 500 of 500 (100.0%) produced different decks. Second — and more durable as a structural proof — Phase D of the capture used a single auditor-chosen client seed (pfauditmod66jae) across 500 bets. All 500 recomputed correctly to the live decks and final hands, demonstrating that the server honours arbitrary player-supplied client seeds rather than ignoring them or substituting a server-controlled value.
// Step 6: Client Seed Influencefor (const b of bets) { if (tested >= 500) break; // sample 500 bets const ss = seedMap.get(b.seed.serverSeedHashed); if (!ss) continue; const correctDeck = computeShuffledDeck(ss, b.seed.clientSeed, b.seed.nonce); const wrongDeck = computeShuffledDeck(ss, 'wrong-client-seed-test', b.seed.nonce); tested++; if (!arrEq(correctDeck, wrongDeck)) changed++;} const changeRate = tested > 0 ? changed / tested : 0;const s6 = step(6, 'Client Seed Influence', changeRate >= 0.95 ? 'PASS' : 'FAIL', `${changed}/${tested} sampled bets produce different decks with wrong client seed (${(changeRate * 100).toFixed(1)}% change rate)`,);Technical Evidence & Verification5 sections
1.9Evidence Coverage Summary6 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Seed hash integrity (Step 1) | 110/110 revealed seeds hash-verified | Pass |
| Commitment linkage (Step 2) | 114/114 chain links + 115/115 promotions | Pass |
| Hash consistency (Step 3) | 110/110 epochs | Pass |
| Nonce audit (Step 4) | 0 gaps, 0 duplicates; 2 capture-retry verified | Pass |
| Outcome recomputation (Step 5) | 5,400/5,400 (deck + final hand) | Pass |
| Client seed influence (Step 6) | 500/500 (100.0%) + 500 Phase D auditor-seed bets | Pass |
1.10Code References6 source files
| File | Purpose |
|---|---|
| tests/verify.ts | 27-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: Deck recomputation and client seed influence |
| src/rng.ts | HMAC-SHA256 backward Fisher-Yates 52-card shuffle (computeShuffledDeck, computeFinalHand, verifyHash, recomputeRound) |
| src/loader.ts | Dataset loading + seed/bet parsing + dataset hash guard |
| capture/capture.reference.js | Browser-based data collection script |
1.11Datasets UsedSource artifacts
| Property | Value |
|---|---|
| Source | Live Video Poker game data from Duel.com |
| Total Records | 5,400 bets across 110 bet-bearing epochs (115 seed entries) |
| SHA-256 | 363fd4d4c072a1180ca2e7ab61d0c8dcc6f3300f315449af0d7101dd45dd0654 |
Fields used: serverSeed, serverSeedHashed, nextServerSeedHash, clientSeed, nonce, deal.response.initial_cards, draw.request.held_cards, draw.response.final_cards, draw.response.combination, draw.response.multiplier, draw.response.amount_won
1.12Verified Invariants9 invariants
| Invariant | Result |
|---|---|
| SHA-256(hexDecode(serverSeed)) = serverSeedHashed for all 110 revealed seeds | Pass |
| Next-seed promotion chain intact for all 114 transitions | Pass |
| serverSeedHashed constant within epoch for all 110 epochs | Pass |
| Zero nonce gaps within any epoch | Pass |
| Zero nonce duplicates within any epoch | Pass |
| Same inputs produce same 52-card deck for all 5,400 bets (initial deal + replacement pool) | Pass |
| Held cards select positions only — replacement pool is deck[5..9] consumed left-to-right | Pass |
| Wrong client seed changes deck in 100.0% of tests (500/500) | Pass |
| Client seed is browser-generated (Full Pass origin) | Pass |
1.13Reproduction InstructionsRun locally
Clone the repository, install dependencies, and run the verification suite:
git clone https://github.com/ProvablyFair-org/duel-video-poker.gitcd duel-video-poker && npm installnpm run verify# Expected output: Steps 1–6 all PASSSteps 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 5 — Outcome Recomputation
[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 Video Poker random number generation produces cryptographically sound, unbiased outputs using only the disclosed inputs. The RNG uses a backward Fisher-Yates shuffle of the 52-card deck with HMAC-SHA256 — each of the 51 swap steps derives an index from a 4-byte HMAC chunk with bias-free rejection sampling via the maxFair ceiling. We independently implemented this algorithm, verified it produces the same shuffle as the live game across 5,400 bets, and confirmed no hidden inputs can influence outcomes.
What we verified
- HMAC-SHA256 produces cryptographically sound, unpredictable output for each shuffle step
- Only disclosed inputs affect outcomes — no timestamps, no server-side state, no hidden entropy
- Rejection sampling via
maxFairceiling eliminates modulo bias for all ranges (2–52) - Hand-rank distribution follows the textbook C(52,5) = 2,598,960 expectation (confirmed over 10M simulated rounds via Fisher's combined test)
- Per-card rank and suit distributions are uniform across all 27,000 dealt cards
- Consecutive outcomes are statistically independent — no patterns, no streaks
- 100.0% of outcomes change with a different client seed (500/500 sampled bets)
What this means for you
- Each shuffled deck is generated fairly and cannot be skewed
- All 52 cards are equally likely to land in any deck position — 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 shuffle step
- Only disclosed inputs affect outcomes — no timestamps, no server-side state, no hidden entropy
- Rejection sampling via
maxFairceiling eliminates modulo bias for all ranges (2–52) - Hand-rank distribution follows the textbook C(52,5) = 2,598,960 expectation (confirmed over 10M simulated rounds via Fisher's combined test)
- Per-card rank and suit distributions are uniform across all 27,000 dealt cards
- Consecutive outcomes are statistically independent — no patterns, no streaks
- 100.0% of outcomes change with a different client seed (500/500 sampled bets)
- Each shuffled deck is generated fairly and cannot be skewed
- All 52 cards are equally likely to land in any deck position — 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
Hand-rank distributionPass
Card-level distributionPass
Serial independencePass
The Video Poker RNG uses only the disclosed inputs, produces a hand-rank distribution matching the C(52,5) = 2,598,960 textbook expectation across 10M simulated rounds, and shows no serial dependence. The client seed is a genuine input — changing it changes the deck.
| 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 mutable state |
| Algorithm independently implemented | Pass | Independent implementation produces identical 52-card decks for all 5,400 bets |
| Modulo bias | Pass | Rejection sampling via maxFair ceiling eliminates bias for all ranges (2–52) |
| Key encoding verified | Pass | Server seed hex-decoded to bytes (not UTF-8) — confirmed via 5,400-bet recomputation |
| Hand-rank distribution | Pass | Fisher's combined p = 0.7982 across 10 streams × 1M rounds; aggregate chi-squared p = 0.7193 across 11M cherry-pick rounds |
| Card-level distribution | Pass | Rank χ²(12) = 8.39, p = 0.7541; Suit χ²(3) = 0.52, p = 0.9150 across 27,000 live-bet cards |
| Serial independence | Pass | Lag-1 autocorrelation = -0.000233 and runs test p = 0.1000 across 10M rounds |
RNG derived only from disclosed inputsPass
Entropy purityPass
Algorithm independently implementedPass
Modulo biasPass
Key encoding verifiedPass
Hand-rank distributionPass
Card-level distributionPass
Serial independencePass
The Video Poker RNG uses only the disclosed inputs, produces a hand-rank distribution matching the C(52,5) = 2,598,960 textbook expectation across 10M simulated rounds, and shows no serial dependence. The client seed is a genuine input — changing it changes the deck.
How It Works — RNG & Entropy Model8 sections
2.1RNG Function Implementation
Each Video Poker bet shuffles a 52-card deck using a backward Fisher-Yates algorithm. The shuffle iterates from index 51 down to 1 (51 steps). At each step, an HMAC-SHA256 hash is computed with the hex-decoded server seed as key and clientSeed:nonce:cursor as the message, where cursor = 51 − i. The first 4-byte chunk below the maxFair ceiling is used: j = chunk % range determines the swap index. After all 51 steps, the deck is fully shuffled — deck[0..4] is the player's initial hand and deck[5..9] is the replacement pool consumed at draw time.
| Component | Detail |
|---|---|
| Hash function | HMAC-SHA256 |
| Key | Buffer.from(serverSeed, 'hex') — 32 bytes |
| Message | clientSeed:nonce:cursor |
| Extraction | 4-byte chunks → parseInt(hex, 16) → uint32 |
| Rejection | value < maxFair — bias-free ceiling per range |
| Reduction | uint32 % range → swap index j ∈ [0, range) |
| Shuffle | Backward Fisher-Yates: i=51 downto 1 (51 steps) |
| Output | deck[0..51] — fully shuffled 52-card deck |
| Hand split | deck[0..4] = initial hand · deck[5..9] = replacement pool |
// computeShuffledDeckFromBuffer — backward Fisher-Yates 52-card shuffleexport function computeShuffledDeckFromBuffer( keyBuffer: Buffer, clientSeed: string, nonce: number,): string[] { const deck: string[] = [...ALL_CARDS]; for (let i = DECK_SIZE - 1; i > 0; i--) { const range = i + 1; const maxFair = MAX_UINT32 - (MAX_UINT32 % range); const cursor = DECK_SIZE - 1 - i; const message = `${clientSeed}:${nonce}:${cursor}`; const hmac = crypto.createHmac('sha256', keyBuffer).update(message).digest('hex'); let found = false; for (let off = 0; off + 8 <= hmac.length; off += 8) { const value = parseInt(hmac.substring(off, off + 8), 16); if (value < maxFair) { const j = value % range; [deck[i], deck[j]] = [deck[j], deck[i]]; found = true; break; } } if (!found) { // All 8 chunks >= maxFair. Probability ≈ (4 / 2^32)^8 ≈ 10^-75 — astronomically unlikely. // The published algorithm throws rather than incrementing cursor. throw new Error(`Fisher-Yates rejection exhausted at i=${i}, nonce=${nonce}`); } } return deck;}2.2Entropy Sources
All randomness derives exclusively from the deterministic HMAC-SHA256 function combining three 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-step isolation (0 to 50, one per Fisher-Yates step) |
How we know: 5,400/5,400 bets were recomputed using only the three declared inputs. If any hidden entropy source existed, recomputation would fail. It does not.
Verified absent: No timestamps, no Math.random(), no external APIs, no server-side mutable state. Only: HMAC-SHA256(hexDecode(serverSeed), clientSeed:nonce:cursor)
2.3Modulo Bias Analysis
Each shuffle step's swap index is determined by chunk % range where range varies from 2 to 52 across the 51 Fisher-Yates steps. Modulo bias occurs when 2^32 is not evenly divisible by the range. The algorithm eliminates this bias entirely via rejection sampling: a ceiling maxFair = 0xFFFFFFFF − (0xFFFFFFFF % range) ensures only unbiased chunks are used. Any chunk ≥ maxFair is discarded. 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³²)⁸ ≈ 10⁻⁶⁴ for the worst-case range; this branch has never been observed.
For range = 52 (first step, i=51):
maxFair = 0xFFFFFFFF − (0xFFFFFFFF % 52)
= 4,294,967,295 − 47
= 4,294,967,248 (= 0xFFFFFFD0)
Rejected values: [4,294,967,248 .. 4,294,967,295] = 48 values
Rejection rate: 48 / 4,294,967,296 ≈ 1.1 × 10⁻⁸
Accepted values: exactly divisible by 52
→ each residue 0–51 equally likely (zero bias)maxFair ceiling ensures uniform swap indices for all 51 Fisher-Yates steps.For range = 2 (last step, i=1), maxFair = 0xFFFFFFFF − (0xFFFFFFFF % 2) = 0xFFFFFFFE. Only two values (0xFFFFFFFE, 0xFFFFFFFF) are rejected — negligible. For all 51 ranges in the shuffle, the rejection ceiling makes every swap index uniformly distributed. 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 roughly (48/2^32)^8 ≈ 10⁻⁶⁴ for the worst range and would cause the algorithm to throw.
2.4RNG Isolation
Each shuffle step within a single bet uses a unique cursor value in the HMAC message (clientSeed:nonce:cursor), ensuring per-step outputs are cryptographically independent. Each bet uses a unique nonce, ensuring per-bet outputs are independent. HMAC-SHA256 is a pseudorandom function — knowing one step's output gives zero information about other steps' outputs. There is no shared state between steps, between bets, or between epochs.
Evidence: The computeShuffledDeckFromBuffer implementation in 2.1 confirms this — it is a pure function with no class state, no external calls, and no cross-bet memory. The function takes (keyBuffer, clientSeed, nonce) explicitly and returns an array. Same inputs always produce the same output.
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 an independent runtime-generated seed pair) verified that the deal distribution matches the C(52,5) = 2,598,960 textbook expectation. Video Poker is a single-configuration game (one pay table, no risk levels), so the methodology uses Fisher's combined test to aggregate per-stream chi-squared 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.7982 (T = 14.61, df = 20) |
| Streams failing chi-squared at α=0.01 | 0 / 10 |
| Theoretical deal-only RTP | 33.7238% |
| Simulated deal-only RTP | 33.6999% |
| Serial independence fails | 0 |
| Stream | χ² (df=8) | p-value | Stream RTP |
|---|---|---|---|
| 0 | 2.79 | 0.9470 | 33.7783% |
| 1 | 10.13 | 0.2558 | 33.9553% |
| 2 | 8.05 | 0.4289 | 33.6137% |
| 3 | 5.61 | 0.6906 | 33.4908% |
| 4 | 6.63 | 0.5765 | 33.7638% |
| 5 | 7.68 | 0.4658 | 33.7676% |
| 6 | 9.21 | 0.3247 | 33.6854% |
| 7 | 8.56 | 0.3805 | 33.6398% |
| 8 | 7.39 | 0.4956 | 33.6640% |
| 9 | 6.70 | 0.5697 | 33.6400% |
| Hand Rank | Theoretical (per C(52,5)) | Expected in 10M | Observed |
|---|---|---|---|
| Royal flush | 4 / 2,598,960 | 15.4 | 15 |
| Straight flush | 36 / 2,598,960 | 138.5 | 128 |
| Four of a kind | 624 / 2,598,960 | 2,401.0 | 2,431 |
| Full house | 3,744 / 2,598,960 | 14,405.8 | 14,375 |
| Flush | 5,108 / 2,598,960 | 19,654.0 | 19,439 |
| Straight | 10,200 / 2,598,960 | 39,246.5 | 39,653 |
| Three of a kind | 54,912 / 2,598,960 | 211,284.5 | 210,806 |
| Two pair | 123,552 / 2,598,960 | 475,390.2 | 474,827 |
| Jacks or better | 337,920 / 2,598,960 | 1,300,212.4 | 1,300,469 |
| None | 2,062,860 / 2,598,960 | 7,937,251.8 | 7,937,857 |
/** * Monte Carlo simulation for Duel.com Video Poker audit. * * Pass 1 — Multi-stream Fisher's method (10 streams × 100K rounds = 1M total). * Video Poker is a single-config game (Jacks or Better), so we use multi-stream * with Fisher's combined p-value instead of per-config Bonferroni. This matches * the methodology for Crash (also single-config). Each stream uses an independent * pinned seed pair. Per-stream chi-squared on hand-rank distribution, combined via * Fisher's method: T = -2 Σ ln(p_i) ~ χ²(2K). Serial independence tested on the * full 1M combined stream. * * Methodology rule: multi-config → Bonferroni; single-config → Fisher's. *Methodology rule (per src/simulate.ts): multi-config → Bonferroni; single-config → Fisher's. Each stream runs independently against pinned seeds; per-stream chi-squared on hand-rank distribution; combined via Fisher's method (T = −2 Σ ln(p_i) ~ χ²(2K)). Serial independence tested on the full 10M combined stream via lag-1 autocorrelation and Wald-Wolfowitz runs test.
Pass 2 (cherry-pick detection): all 110 captured server seeds were each replayed for 100,000 simulated nonces (11,000,000 rounds total) to confirm no captured seed produces an unusually favourable distribution. 5/110 seeds were flagged at α=0.05 — the binomial probability of observing 5 or more flags out of 110 at the 5% expected fail rate is p = 0.6485 (well within noise). Aggregated chi-squared across all 11M rounds: χ²(9) = 6.20, p = 0.7193. Aggregated RTP: 33.7329%.
2.6Serial Independence
Serial independence ensures consecutive bet outcomes are not correlated — winning or losing on one bet does not affect the next. Two tests were applied to the full 10M-round Pass 1 sequence:
Lag-1 autocorrelation: Measures correlation between consecutive payout multipliers. Expected r ≈ 0 for independent sequences. Threshold: |z| > 3 (where z = r × √n).
Wald-Wolfowitz runs test: Tests whether the sequence of above/below-median outcomes has the expected number of runs. p < 0.01 indicates non-random structure.
// lag1Autocorrelation — correlation between consecutive series valuesexport function lag1Autocorrelation(series: readonly number[]): number { const n = series.length; const mean = series.reduce((a, b) => a + b, 0) / n; let num = 0, den = 0; for (let i = 0; i < n - 1; i++) num += (series[i] - mean) * (series[i + 1] - mean); for (let i = 0; i < n; i++) den += (series[i] - mean) ** 2; return den === 0 ? 0 : num / den;}2.7Card-Level Distribution (Live Bets)
Beyond the hand-rank distribution test, two finer-grained tests verify the deck shuffle has no per-card bias. Across all 5,400 live bets, the 27,000 cards in the initial 5-card deals were tallied by rank (13 ranks: 2 through Ace) and by suit (4 suits: ♦ ♥ ♠ ♣). A biased shuffle would show under- or over-representation of specific cards even if the overall hand distribution looked correct. Both distributions came back uniform with high p-values:
| Test | Statistic | df | p-value | Interpretation |
|---|---|---|---|---|
| Card rank distribution (Step 23) | χ² = 8.39 | 12 | 0.7541 | All 13 ranks appear with the expected frequency (≈2,076 each) |
| Card suit distribution (Step 24) | χ² = 0.52 | 3 | 0.9150 | Suits balanced: ♦=6,756 · ♥=6,707 · ♠=6,790 · ♣=6,747 |
Why this matters: the hand-rank chi-squared in S2.5 confirms the combined shuffle distribution is correct, but a sufficiently subtle bias (e.g., a specific card always landing in deck[0]) could in principle slip past it. The per-card rank and suit tests close that gap by checking that every individual card type lands in the initial-hand window with its expected frequency. Both pass comfortably — there is no per-card positional bias.
2.8Worked Example — Full RNG Trace
Real bet from the dataset — hand_id 589162 (Phase A, nonce 0, first bet of epoch 0). Verified from data/video-poker-dataset-5400bets.json:
serverSeed = 2424d436197860c90bad4022573f14eab6c9da3dcc2d50e57ad7d52357f0fdc9
clientSeed = cpc4ihmPtvpHpapv
nonce = 0| i | cursor | range | HMAC[:8] | uint32 | j | swap |
|---|---|---|---|---|---|---|
| 51 | 0 | 52 | 4ed6dc5a | 1,322,703,962 | 34 | deck[51]↔︎deck[34] |
| 50 | 1 | 51 | d51a7eaf | 3,575,283,375 | 30 | deck[50]↔︎deck[30] |
| 49 | 2 | 50 | 4010cbe1 | 1,074,842,593 | 43 | deck[49]↔︎deck[43] |
| 48 | 3 | 49 | 7f1e7e17 | 2,132,704,791 | 28 | deck[48]↔︎deck[28] |
| 47 | 4 | 48 | 9ee28fc3 | 2,665,648,067 | 35 | deck[47]↔︎deck[35] |
hand_id 589162 — initial cards deck[0..4] and replacement pool deck[5..9] match backward Fisher-Yates recomputation exactly. Player held [JS, 4D, 10D]; final hand [4S, JS, 4D, 10D, KS] recomputes from (initial, held, pool) deterministically. Hand classification: none. Multiplier: 0×. Payout: $0.00.The remaining 46 swaps (i=46 down to i=1) follow this same pattern — each consumes one HMAC-SHA256 output, takes the first chunk below maxFair, and swaps deck[i] with deck[chunk % range]. The full chain is reproducible from the verifier code in S2.13.
Technical Evidence & Verification5 sections
2.9Evidence Coverage Summary9 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Algorithm implementation (Step 5) | 5,400/5,400 bets | Pass |
| Key encoding (hex vs UTF-8) | Confirmed via recomputation | Pass |
| Modulo bias analysis | Rejection sampling: maxFair ceiling for all ranges 2–52 | Pass |
| External entropy non-participation (Step 5) | 5,400/5,400 reproduced from (serverSeed, clientSeed, nonce) only | Pass |
| Simulation Pass 1 (Step 17) | Fisher's combined p = 0.7982; 0/10 streams fail at α=0.01 | Pass |
| Simulation Pass 2 cherry-pick (Step 18) | 5/110 flagged (binomial p = 0.6485); aggregate χ² p = 0.7193 | Pass |
| Card rank distribution (Step 23) | χ²(12) = 8.39, p = 0.7541 | Pass |
| Card suit distribution (Step 24) | χ²(3) = 0.52, p = 0.9150 | Pass |
| Serial independence (Step 17) | r₁ = -0.000233; runs test p = 0.1000 | Pass |
2.10Code References6 source files
| File | Purpose |
|---|---|
| src/rng.ts | HMAC-SHA256 backward Fisher-Yates 52-card shuffle (computeShuffledDeck, computeShuffledDeckFromBuffer) |
| src/simulate.ts | Monte Carlo simulation (10M Pass 1 + 11M Pass 2, Fisher's method) |
| src/stats.ts | Chi-squared, lag-1 autocorrelation, Wald-Wolfowitz runs test, Fisher's combined |
| tests/steps/determinism.ts | Steps 5–6: Deck recomputation and client seed influence |
| tests/steps/simulation.ts | Steps 16–18: Anti-circularity, Pass 1 integrity, Pass 2 cherry-pick detection |
| tests/steps/game-specific.ts | Steps 23–24: Card rank and suit distribution tests |
2.11Verified Invariants12 invariants
| Invariant | Result |
|---|---|
| HMAC-SHA256 output produces correct 52-card deck for all 5,400 bets | Pass |
| Key is hex-decoded (not UTF-8) — wrong encoding produces wrong decks | Pass |
| Rejection sampling eliminates modulo bias for all ranges (2–52) | Pass |
| No external entropy sources required for deck computation | Pass |
| Hand-rank distribution matches C(52,5) textbook counts at 10M rounds (Fisher's p = 0.7982) | Pass |
| Card rank distribution uniform across 27,000 dealt cards (χ²(12) = 8.39, p = 0.7541) | Pass |
| Card suit distribution uniform across 27,000 dealt cards (χ²(3) = 0.52, p = 0.9150) | Pass |
| Lag-1 autocorrelation near zero on 10M-round combined stream (r₁ = -0.000233) | Pass |
| Runs test p > 0.01 on 10M-round combined stream (p = 0.1000) | Pass |
| Per-stream simulated RTP converges to 33.7238% theoretical deal-only | Pass |
| Pass 2 cherry-pick detection: 5/110 flagged at α=0.05 (binomial p = 0.6485, well within noise) | Pass |
| Client seed change produces different deck in 100.0% of bets (500/500) | 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-video-poker.gitcd duel-video-poker && npm installnpm run simulate # 10M-round Pass 1 + 11M-round Pass 2 (~10–15 min)npm run verify # Steps 5, 17, 18, 23, 24 cover S2S2-related steps:
[PASS] Step 5 — Outcome Recomputation
[PASS] Step 17 — Simulation Validation — Pass 1 (Deal-Only Distribution)
[PASS] Step 18 — Pass 2 Cherry-Pick Detection
[PASS] Step 23 — Card Rank Distribution
[PASS] Step 24 — Card Suit DistributionVerifier Parity
Does the live game actually follow its own rules?
This section validates that the independent verifier produces the exact same shuffled deck as the live game for every single bet. Because Video Poker is a two-step game (deal then draw), both halves must match — the initial 5-card hand from deck[0..4] AND the final hand reconstructed by applying the player's hold choice to the replacement pool at deck[5..9]. Any mismatch in either half would invalidate the fairness guarantee.
What we verified
- Every bet independently recomputed from seeds — full 52-card deck verified, not just the payout
- Both halves of every bet verified — initial 5-card deal AND final 5-card hand after holds
- Hand classification: server's combination field matches independent evaluateHand() for all 5,400 bets across all 10 hand ranks
- Payout correctness: amount_won = amount_coins × multiplier, exact for all 5,400 bets
- The 10-rank pay table produces the correct multiplier for every hand classification
- Bet amount is not an input to the RNG — decks depend only on seeds and nonce
- The player's hold choice does not change the deck — the 10-card outcome is fixed at deal time
What this means for you
- The verifier isn't a simulation — it produces the exact same deal, replacement pool, and final hand 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 cards you hold
- The game engine in production matches the published algorithm exactly
- Every bet independently recomputed from seeds — full 52-card deck verified, not just the payout
- Both halves of every bet verified — initial 5-card deal AND final 5-card hand after holds
- Hand classification: server's combination field matches independent evaluateHand() for all 5,400 bets across all 10 hand ranks
- Payout correctness: amount_won = amount_coins × multiplier, exact for all 5,400 bets
- The 10-rank pay table produces the correct multiplier for every hand classification
- Bet amount is not an input to the RNG — decks depend only on seeds and nonce
- The player's hold choice does not change the deck — the 10-card outcome is fixed at deal time
- The verifier isn't a simulation — it produces the exact same deal, replacement pool, and final hand 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 cards you hold
- The game engine in production matches the published algorithm exactly
Deck recomputationPass
Hand classificationPass
Final-hand compositionPass
Payout correctnessPass
Pay table integrityPass
Bet-size independencePass
Hold pattern coveragePass
All 5,400 bets matched the independent verifier exactly — initial deck, replacement pool, final hand after holds, hand classification, multiplier, and payout all reproduce from (serverSeed, clientSeed, nonce) across all five capture phases. Both halves of every bet (deal and draw) are recoverable from the recorded inputs.
| Check | Result | Evidence |
|---|---|---|
| Deck recomputation | Pass | 5,400/5,400 exact match — initial cards deck[0..4] and replacement pool deck[5..9] verified for every bet |
| Hand classification | Pass | 5,400/5,400 — independent evaluateHand(final_cards) matches server combination across all 10 hand ranks |
| Final-hand composition | Pass | 5,400/5,400 — final cards reconstruct deterministically from (initial_cards, held_cards, replacement_pool) |
| Payout correctness | Pass | All 5,400 bets: amount_won = amount_coins × multiplier, tolerance 1×10⁻⁶ |
| Pay table integrity | Pass | All 10 hand ranks → multiplier match videoPokerConfig.json for every bet |
| Bet-size independence | Pass | Phase C (100 bets at $10/hand) — same algorithm produces the deck regardless of bet amount |
| Hold pattern coverage | Pass | Phase E (800 bets, 8 hold patterns × 4 hold counts) — deck composition independent of hold choice |
Deck recomputationPass
Hand classificationPass
Final-hand compositionPass
Payout correctnessPass
Pay table integrityPass
Bet-size independencePass
Hold pattern coveragePass
All 5,400 bets matched the independent verifier exactly — initial deck, replacement pool, final hand after holds, hand classification, multiplier, and payout all reproduce from (serverSeed, clientSeed, nonce) across all five capture phases. Both halves of every bet (deal and draw) are recoverable from the recorded inputs.
How It Works — Verifier Parity9 sections
3.1Why Parity Matters
If the verifier produces results that differ from the live game, players cannot trust the verification — the entire provably fair system becomes meaningless. 100% parity is required because even a single discrepancy would indicate either a bug in the verification logic, manipulation in the live game, or inconsistent RNG implementation. Players must be able to take the revealed seeds after gameplay, input them into the independent verifier, and receive the exact same 52-card deck — and therefore the exact same initial hand, replacement pool, and (after applying the recorded hold choice) the exact same final hand they experienced during live play.
3.2Five-Phase Collection Design
Data was collected across five structured phases, each designed to test a specific fairness property. The phases are complementary — together they cover broad RNG sampling, targeted hand-type coverage, bet-size invariance, auditor-controlled client seeds, and hold-pattern independence.
| Phase | Bets | Bet Amount | Client Seed | Purpose |
|---|---|---|---|---|
| A — Broad sampling | 3,000 | $0.01 | 61 distinct (player-rotated) | RNG sampling across many seed pairs and random hold choices |
| B — Targeted hands | 1,000 | $0.01 | 20 distinct | Targeted sampling of specific hand types (pairs, low-pair holds) for distribution coverage |
| C — Bet-size invariance | 100 | $10.00 | 2 distinct | Confirm bet amount is not an RNG input |
| D — Auditor client seed | 500 | $0.01 | 1 (pfauditmod66jae) | Verify server honours arbitrary player-supplied client seeds |
| E — Hold pattern coverage | 800 | $0.01 | 16 distinct | Verify deck composition independent of hold choice (8 systematic patterns × 4 hold counts) |
3.3Deck Recomputation (Step 5)
For every bet belonging to an epoch with a revealed server seed, the verifier independently computed the full 52-card shuffled deck using computeShuffledDeck(serverSeed, clientSeed, nonce) and compared deck[0..4] to the server-reported initial_cards and the inferred replacement pool deck[5..9] against the cards consumed during the draw. The computation uses HMAC-SHA256 backward Fisher-Yates with the hex-decoded server seed as key.
// Step 5: Outcome Recomputationconst ss = seedMap.get(b.seed.serverSeedHashed);if (!ss) { skipped++; continue; } const deck = computeShuffledDeck(ss, b.seed.clientSeed, b.seed.nonce);const expInitial = deck.slice(0, 5);const expPool = deck.slice(5, 10);const expFinal = computeFinalHand(expInitial, b.draw.response.held_cards, expPool); const initialMatch = arrEq(expInitial, b.draw.response.initial_cards!);const finalMatch = arrEq(expFinal, b.draw.response.final_cards!);final_cards after applying the recorded held_cards to the deterministic deal.3.4Payout Math (Step 7)
For each of the 5,400 bets, the verifier computed amount_coins × multiplier and compared the result to the server-reported amount_won. The tolerance is 1×10⁻⁶ — any difference larger than this would indicate the server is applying hidden fees, rounding errors, or incorrect multipliers. Losing hands (multiplier 0×) are checked against won = 0 directly.
// Step 7: Payout Mathfor (const b of bets) { const mult = parseFloat(b.draw.response.multiplier); const amt = parseFloat(b.deal.request.amount_coins); const won = parseFloat(b.draw.response.amount_won); if (!Number.isFinite(amt)) { payoutErrors++; continue; } // reject NaN — never silently skip if (mult === 0) { // Loss — amount_won should be 0 or very close if (Math.abs(won) > 1e-12) { payoutErrors++; if (payoutDetails.length < 3) payoutDetails.push(`hand_id=${b.draw.response.hand_id}: loss but won=${won}`); } } else { // Win — amount_won should equal amount × multiplier const expected = amt * mult; if (Math.abs(expected - won) > 1e-6) { payoutErrors++; if (payoutDetails.length < 3) payoutDetails.push(`hand_id=${b.draw.response.hand_id}: expected=${expected}, got=${won}`); } }}amount_won = amount_coins × multiplier within tolerance 1×10⁻⁶. 0 errors. Payout math is exact.3.5Pay Table Provenance (Step 8)
For each of the 5,400 bets, the observed multiplier was compared against the 10-rank pay table in videoPokerConfig.json, keyed by the combination field. The lookup is direct — no risk-level or picks-count parameterization, just multipliers[combination]. All 5,400 bets match exactly — confirming the live game uses the same pay table as the published configuration.
| Hand Rank | Multiplier | Observed (live) |
|---|---|---|
| Royal flush | 812.9719437907615× | 0 |
| Straight flush | 58× | 1 |
| Four of a kind | 26× | 2 |
| Full house | 9× | 15 |
| Flush | 6× | 15 |
| Straight | 4× | 27 |
| Three of a kind | 3× | 144 |
| Two pair | 2× | 294 |
| Jacks or better | 1× | 698 |
| None | 0× | 4,204 |
// Step 8: Multiplier Provenancefor (const b of bets) { const combo = b.draw.response.combination as HandRank; const expected = MULTIPLIERS[combo]; const actual = parseFloat(b.draw.response.multiplier); if (expected === undefined) { multErrors++; if (multErrDetails.length < 3) multErrDetails.push(`hand_id=${b.draw.response.hand_id}: unknown combination "${combo}"`); } else if (Math.abs(expected - actual) > 1e-10) { multErrors++; if (multErrDetails.length < 3) multErrDetails.push(`hand_id=${b.draw.response.hand_id}: ${combo} expected=${expected}, got=${actual}`); }}videoPokerConfig.json. All 10 hand ranks confirmed. 0 mismatches. Flat 0.1% house edge across the table — derived from Duel's paytable values in videoPokerConfig.json; effective_edge metadata field constant at 0.1 (Step 10, metadata-tampering check).3.6Phase C Bet-Size Equivalence (Step 9)
Phase C placed 100 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 100 decks were recomputed correctly from revealed seeds; the initial 5-card deal deck[0..4] and replacement pool deck[5..9] are determined entirely by (serverSeed, clientSeed, nonce) regardless of bet amount. Equivalence is proven deterministically — 100/100 exact deck matches.
3.7Phase D Auditor Client Seed (Step 15)
Phase D placed 500 bets using a single auditor-chosen client seed (pfauditmod66jae) across 10 epochs. Every bet's deck was recomputed from the revealed server seed and the auditor's client seed; all 500 matched the live deal and final hand exactly, and all 500 hand classifications matched the server's combination field. This proves the server honours arbitrary player-supplied client seeds rather than ignoring them or substituting a server-controlled value — a structural defense against pre-selection of "favourable" client seeds.
pfauditmod66jae; 500/500 hand classifications match. Server honours player-supplied client seeds.3.8Game-Specific Verification (Steps 19–22, 25)
Beyond deck recomputation and payout math, five Video-Poker–specific checks verify the draw half of every bet — confirming that hand classification, hold validity, replacement pool determinism, deck integrity, and hold-pattern independence all behave as specified.
| Check | Step | Coverage | Result |
|---|---|---|---|
| Hand Classification Accuracy | 19 | 5,400/5,400 — independent evaluateHand(final_cards) matches server combination across all 10 hand ranks (royal flush precedence over straight flush, ace-low straights, jacks-or-better excluding low pairs all handled correctly) | Pass |
| Hold Validity | 20 | 5,400/5,400 — held_cards ⊆ initial_cards and |held_cards| ≤ 5 for every bet; no held card ever fabricated by the server | Pass |
| Replacement Pool Determinism | 21 | 5,400/5,400 — non-held positions consume deck[5..9] sequentially left-to-right; pool position is determined entirely by which positions the player held | Pass |
| Deck Integrity | 22 | 5,400/5,400 — every recomputed deck[0..9] window contains 10 unique valid cards from the standard 52-card deck (no duplicates, no fabricated cards, no off-deck values) | Pass |
| Phase E Hold Pattern Coverage | 25 | 800/800 Phase E bets verified across 4 distinct hold counts: hold-0 (100 bets), hold-1 (500 bets), hold-2 (100 bets), hold-5 (100 bets); deck composition identical regardless of hold pattern | Pass |
Why these matter: Steps 19–22 close every gap a player might wonder about in the draw half. Step 19 — does the server classify hands correctly? Step 20 — could the server "fabricate" a held card the player never selected? Step 21 — does the replacement pool consume in a deterministic order, or could the server cherry-pick which pool card lands in which position? Step 22 — could the server slip a fabricated or duplicate card into deck[0..9]? Step 25 — could the player's hold pattern itself influence the deck composition? All five close cleanly — 0 violations across 5,400 bets.
3.9Worked Example — Full Parity Verification
Real bet from Phase D — hand_id 593949, auditor's client seed pfauditmod66jae, nonce 28. The player was dealt 4 clubs (6C, 2C, 3C, 5C) and one off-suit QC; held the 4 clubs; the replacement pool delivered 4C to position 3, completing a straight flush 2C–3C–4C–5C–6C for a 58× payout. Verified end-to-end from data/video-poker-dataset-5400bets.json:
serverSeed = f7c670b05ebea748011cfd9df0b4ebc82fa3e296f687aeb924abbd5c0fd60dd2
clientSeed = pfauditmod66jae
nonce = 28
phase = D (auditor-chosen client seed)
amount = $0.01| Step | Process | Output |
|---|---|---|
| 1 | Backward Fisher-Yates: 51 HMAC-SHA256 calls with cursor 0–50 | Fully shuffled 52-card deck |
| 2 | deck[0..4] → initial hand | [6C, 2C, 3C, QC, 5C] |
| 3 | deck[5..9] → replacement pool | [4C, KC, KS, 7C, 10C] |
| 4 | Apply hold: [6C, 2C, 3C, 5C] kept; position 3 consumes pool[0] = 4C | [6C, 2C, 3C, 4C, 5C] |
| 5 | Hand classification: 5 consecutive clubs | straight_flush |
| 6 | Multiplier lookup: multipliers.straight_flush | 58× |
| 7 | Payout: 0.01 × 58 | $0.58 ✅ |
// computeShuffledDeck("f7c670b0...", "pfauditmod66jae", 28)// 51 Fisher-Yates steps → fully shuffled 52-card deck//// deck[0..4] = ["6C", "2C", "3C", "QC", "5C"] ← initial hand// deck[5..9] = ["4C", "KC", "KS", "7C", "10C"] ← replacement pool//// Player holds [6C, 2C, 3C, 5C] — keep positions 0, 1, 2, 4// Position 3 (QC) — not held → consume pool[0] = "4C"//// final_cards = ["6C", "2C", "3C", "4C", "5C"]//// evaluateHand(final_cards) → "straight_flush" (5 consecutive clubs)// videoPokerConfig.multipliers.straight_flush → 58//// Payout: 0.01 × 58 = 0.58// Live amount_won: 0.58 ✅hand_id 593949 — initial hand, replacement pool, hold composition, final hand, hand classification, multiplier, and payout all match exactly between the live game and the independent verifier. Auditor's chosen client seed pfauditmod66jae produced a verifiable 58× win — proving both that the server honours arbitrary client seeds and that high-multiplier outcomes are recoverable from public inputs.Technical Evidence & Verification5 sections
3.10Evidence Coverage Summary12 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Deck recomputation (Step 5) | 5,400/5,400 bets — initial hand + replacement pool verified | Pass |
| Final-hand composition (Step 5) | 5,400/5,400 bets — computeFinalHand(initial, held, pool) matches final_cards | Pass |
| Payout math (Step 7) | 5,400/5,400 bets (tolerance 1×10⁻⁶) | Pass |
| Pay table provenance (Step 8) | 5,400/5,400 bets — multiplier matches videoPokerConfig.json for all 10 hand ranks | Pass |
| Phase C bet-size invariance (Step 9) | 100/100 bets at $10/hand — same algorithm produces the deck regardless of bet amount | Pass |
| Phase D auditor client seed (Step 15) | 500/500 bets under pfauditmod66jae — recompute + classification | Pass |
| Hand Classification Accuracy (Step 19) | 5,400/5,400 — evaluateHand matches server combination | Pass |
| Hold Validity (Step 20) | 5,400/5,400 — held_cards ⊆ initial_cards, |held| ≤ 5 | Pass |
| Replacement Pool Determinism (Step 21) | 5,400/5,400 — pool consumed L→R from deck[5..9] | Pass |
| Deck Integrity (Step 22) | 5,400/5,400 — deck[0..9] is 10 unique valid cards | Pass |
| Phase E Hold Pattern Coverage (Step 25) | 800/800 — 4 hold counts × 8 patterns identical decks | Pass |
| Phase coverage | 5 phases: A (3,000) + B (1,000) + C (100) + D (500) + E (800) | Pass |
3.11Code References6 source files
| File | Purpose |
|---|---|
| tests/steps/determinism.ts | Step 5: Deck recomputation + final-hand composition |
| tests/steps/payouts.ts | Steps 7–9: Payout math, pay table provenance, Phase C bet-size invariance |
| tests/steps/dataset.ts | Steps 10–15: House edge, config completeness, epoch size, phase labels, dataset hash, Phase D variation |
| tests/steps/game-specific.ts | Steps 19–22, 25: Hand classification, hold validity, pool determinism, deck integrity, Phase E hold patterns |
| src/rng.ts | HMAC-SHA256 backward Fisher-Yates 52-card shuffle + computeFinalHand |
| src/hand-evaluator.ts | Independent 10-rank hand classification (royal flush precedence, ace-low straights, jacks-or-better filter) |
3.12Datasets UsedSource artifacts
3.13Verified Invariants14 invariants
| Invariant | Result |
|---|---|
Computed deck[0..4] matches live initial_cards for all 5,400 bets | Pass |
Computed deck[5..9] matches the inferred replacement pool for all 5,400 bets | Pass |
Computed final_cards from (initial, held, pool) matches live final_cards for all 5,400 bets | Pass |
Independent evaluateHand(final_cards) matches server combination for all 5,400 bets across all 10 ranks | Pass |
amount_won = amount_coins × multiplier, exact to 1×10⁻⁶ for all 5,400 bets | Pass |
Every multiplier matches videoPokerConfig.json for all 10 hand ranks | Pass |
| Phase C ($10/hand) decks produced via same algorithm as $0.01 | Pass |
Phase D (pfauditmod66jae) — 500/500 decks + classifications match | Pass |
| Phase E hold patterns produce identical decks regardless of player hold choice (800/800) | Pass |
held_cards ⊆ initial_cards, |held| ≤ 5 for all 5,400 bets | Pass |
Replacement pool consumed sequentially L→R from deck[5..9] for all 5,400 bets | Pass |
deck[0..9] window contains 10 unique valid cards for all 5,400 bets | Pass |
| All 5 phases (A, B, C, D, E) present and labeled in dataset | Pass |
No hidden inputs beyond (serverSeed, clientSeed, nonce) | Pass |
3.14Reproduction InstructionsRun locally
git clone https://github.com/ProvablyFair-org/duel-video-poker.gitcd duel-video-poker && npm installnpm run verify# Expected output: Steps 5, 7–9, 15, 19–22, 25 all PASSS3-related steps:
[PASS] Step 5 — Outcome Recomputation
[PASS] Step 7 — Payout Math
[PASS] Step 8 — Multiplier Provenance
[PASS] Step 9 — Phase C Bet-Size Invariance
[PASS] Step 15 — Phase D Client Seed Variation
[PASS] Step 19 — Hand Classification Accuracy
[PASS] Step 20 — Hold Validity
[PASS] Step 21 — Replacement Pool Determinism
[PASS] Step 22 — Deck Integrity
[PASS] Step 25 — Phase E Hold Pattern CoverageRTP & Payout Logic
Is the house edge what the casino claims?
Video Poker has two RTPs: a deal-only RTP — the raw return from the cards alone, no strategy — proven from textbook combinatorial counts times the paytable, and the headline optimal-play RTP of 99.9%, computed exactly by enumerating all C(52,5) = 2,598,960 deals and solving the best hold for each. It cross-validates against the published 9/6 Jacks-or-Better return of 99.5439%, uses the operator's paytable alone (no operator RTP figure enters the chain), and is corroborated by a 10-million-round Monte Carlo. Every captured hand reconciles against the published paytable, and cherry-pick detection replays every committed server seed to rule out seed pre-selection.
What we verified
- House edge is exactly 0.1% — flat across all bets, all hand classes, all bet sizes
- Optimal-play RTP proven from first principles: exhaustive C(52,5) = 2,598,960-hand enumeration with 134,459 canonical strategy classes returns exactly 99.9000%
- Cross-validated by re-running the same solver against the standard 9/6 Jacks or Better pay table (target 99.5439%) — match to 6 decimal places
- Deal-only RTP (no strategy) is 33.7238% theoretical / 33.6999% simulated across 10M rounds
- Cherry-pick detection: 110 captured server seeds × 100,000 simulated nonces — no evidence of seed pre-selection
- Bet amount does not influence the deck — confirmed at $0.01 and $10
What this means for you
- The house keeps exactly 0.1% — no scaling structure, no bet-size dependence
- The 99.9% claim is the optimal-play RTP — players who hold sub-optimally 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
- Your bet amount doesn't affect which cards are dealt
- House edge is exactly 0.1% — flat across all bets, all hand classes, all bet sizes
- Optimal-play RTP proven from first principles: exhaustive C(52,5) = 2,598,960-hand enumeration with 134,459 canonical strategy classes returns exactly 99.9000%
- Cross-validated by re-running the same solver against the standard 9/6 Jacks or Better pay table (target 99.5439%) — match to 6 decimal places
- Deal-only RTP (no strategy) is 33.7238% theoretical / 33.6999% simulated across 10M rounds
- Cherry-pick detection: 110 captured server seeds × 100,000 simulated nonces — no evidence of seed pre-selection
- Bet amount does not influence the deck — confirmed at $0.01 and $10
- The house keeps exactly 0.1% — no scaling structure, no bet-size dependence
- The 99.9% claim is the optimal-play RTP — players who hold sub-optimally 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
- Your bet amount doesn't affect which cards are dealt
Anti-circularity (deal-only)Pass
Optimal-play RTP solverPass
Solver cross-validationPass
House edge auditPass
Simulated RTP (Pass 1)Pass
Cherry-pick detection (Pass 2)Pass
Bet-size invariancePass
Pay table integrityPass
Both RTPs verified. The deal-only RTP of 33.7238% is proven from textbook combinatorial counts × multipliers — a first-principles non-circular proof of the underlying card probabilities. The optimal-play RTP of 99.9000% is proven from an exhaustive C(52,5) = 2,598,960-hand strategy enumeration cross-validated against standard 9/6 Jacks or Better. 10M simulated rounds and 110-seed cherry-pick detection confirm no anomalies. The house edge is flat at 0.1%.
The separate deal-only proof gives 33.7238% RTP from hand-rank probabilities × multipliers.
| Check | Result | Evidence |
|---|---|---|
| Anti-circularity (deal-only) | Pass | Σ P(rank) × multiplier(rank) = 33.7238% from independent C(52,5) = 2,598,960 enumeration — derived from textbook 5-card poker counts, no casino-supplied probability data |
| Optimal-play RTP solver | Pass | Exhaustive C(52,5) × 32 hold patterns enumeration → 99.900000%; 134,459 canonical strategy classes |
| Solver cross-validation | Pass | Same solver against standard 9/6 JoB pay table → 99.543904% (target 99.5439%, tolerance 1×10⁻⁶) |
| House edge audit | Pass | Flat 0.1% house edge derived from Duel's paytable in videoPokerConfig.json; effective_edge metadata field constant at 0.1 across all 5,400 live bets (metadata-tampering check); 99.9000% optimal-play RTP cross-validated against 99.5439% 9/6 JoB target |
| Simulated RTP (Pass 1) | Pass | 10M rounds, Fisher's combined p = 0.7982; 0/10 streams fail chi-squared at α=0.01 |
| Cherry-pick detection (Pass 2) | Pass | 110 captured server seeds × 100,000 nonces each — 5/110 flagged at α=0.05 (binomial p = 0.6485, well within noise); aggregate χ² p = 0.7193 |
| Bet-size invariance | Pass | Phase C: 100/100 bets at $10/hand — same algorithm produces the deck regardless of bet amount |
| Pay table integrity | Pass | All 5,400 live bets — observed multiplier matches videoPokerConfig.json for all 10 hand ranks |
Anti-circularity (deal-only)Pass
Optimal-play RTP solverPass
Solver cross-validationPass
House edge auditPass
Simulated RTP (Pass 1)Pass
Cherry-pick detection (Pass 2)Pass
Bet-size invariancePass
Pay table integrityPass
Both RTPs verified. The deal-only RTP of 33.7238% is proven from textbook combinatorial counts × multipliers — a first-principles non-circular proof of the underlying card probabilities. The optimal-play RTP of 99.9000% is proven from an exhaustive C(52,5) = 2,598,960-hand strategy enumeration cross-validated against standard 9/6 Jacks or Better. 10M simulated rounds and 110-seed cherry-pick detection confirm no anomalies. The house edge is flat at 0.1%.
How It Works — RTP & Payout Logic9 sections
4.1The Two RTPs of Video Poker
When Duel.com advertises Video Poker as having a 99.9% RTP, that number means: with perfect strategy, on average, you get back 99.9¢ for every $1 wagered over a long-enough sample. It does not mean every deal returns 99.9% — it means the optimal hold choice over millions of dealt hands averages out to 99.9%.
But Video Poker also has a second, less-discussed RTP: the deal-only RTP — what you'd get if you randomly held cards every time, with no strategy at all. This number is much lower (33.72%) because most random-hold decisions throw away winning hands. The deal-only RTP is what we use for the anti-circularity proof — we derive it from independent textbook combinatorial counts, then confirm the live game produces the same distribution.
Both are verified in this section. The deal-only RTP proves the underlying probabilities are correct (no biased shuffle). The optimal-play RTP proves the published 99.9% claim is mathematically achievable with perfect play.
| RTP type | Value | What it measures | Where verified |
|---|---|---|---|
| Deal-only (theoretical) | 33.7238% | Σ P(rank) × multiplier — random holds | S4.2 (Step 16) |
| Deal-only (simulated, 10M rounds) | 33.6999% | Live RNG produces same distribution | S4.5 (Step 17) |
| Optimal-play (theoretical) | 99.9000% | Best possible hold decision per hand | S4.3 (Step 26) |
| Cross-validation (9/6 JoB) | 99.5439% | Same solver, standard pay table | S4.3 (Step 26) |
4.2Anti-Circularity Proof — Deal-Only RTP (Step 16)
The anti-circularity proof establishes the deal-only RTP of 33.7238% from first principles without using any casino-supplied probability data. This is what separates a mathematical proof from a statistical estimate. The proof has two components — each derived independently:
| Component | Formula | Source |
|---|---|---|
| Hand-rank probability | P(rank) = count(rank) / C(52,5) | Textbook 5-card poker counts — pure combinatorics, not from casino |
| Multiplier | videoPokerConfig.multipliers[rank] | Casino-supplied — observed from videoPokerConfig.json |
| Deal-only RTP computation | Σ P(rank) × multiplier(rank) for all 10 ranks | Independent probability × observed multiplier |
| Result | = 33.7238% (theoretical deal-only) | First-principles proof of underlying probabilities |
| Hand Rank | Count | P(rank) | Multiplier | P × M |
|---|---|---|---|---|
| Royal flush | 4 | 0.000002 | 812.97× | 0.001251 |
| Straight flush | 36 | 0.000014 | 58× | 0.000803 |
| Four of a kind | 624 | 0.000240 | 26× | 0.006242 |
| Full house | 3,744 | 0.001441 | 9× | 0.012965 |
| Flush | 5,108 | 0.001965 | 6× | 0.011792 |
| Straight | 10,200 | 0.003925 | 4× | 0.015699 |
| Three of a kind | 54,912 | 0.021128 | 3× | 0.063385 |
| Two pair | 123,552 | 0.047539 | 2× | 0.095078 |
| Jacks or better | 337,920 | 0.130021 | 1× | 0.130021 |
| None | 2,062,860 | 0.793725 | 0× | 0.000000 |
| Total | 2,598,960 | 1.000000 | — | 0.337238 |
Anti-Circularity Verification:
// Step 16: Anti-Circularity (Deal-Only Distribution Check)let theoreticalRTP = 0;let totalCounted = 0;const rtpBreakdown: string[] = []; for (const rank of HAND_RANKS) { const count = THEORETICAL_COUNTS[rank]; const prob = count / TOTAL_HANDS; const mult = MULTIPLIERS[rank]; const contribution = prob * mult; theoreticalRTP += contribution; totalCounted += count; if (mult > 0) { rtpBreakdown.push(`${rank}: ${count}/${TOTAL_HANDS} × ${mult} = ${(contribution * 100).toFixed(6)}%`); }} // Verify the counts sum to C(52,5)const countsSumCorrect = totalCounted === TOTAL_HANDS;Why this proof is non-circular: P(rank) comes from textbook 5-card poker counts — a mathematical property of C(52,5) = 2,598,960 total hands and the structural rules of poker, not from anything Duel.com publishes. The multiplier table is the only casino-sourced input. When we multiply independent probabilities by observed multipliers and get 33.7238%, the deal-only RTP is proven — and the simulated value of 33.6999% across 10M rounds (S4.5) confirms the live RNG produces this distribution in practice.
4.3Optimal-Play RTP via Strategy Solver (Step 26)
The 99.9% RTP that Duel.com advertises is the optimal-play RTP — the expected return given the best possible hold decision for each dealt hand. Computing it requires enumerating all C(52,5) = 2,598,960 dealt hands, evaluating all 32 hold patterns for each, and picking the maximum-EV hold. The audit's solver implements this exhaustively in src/compute-optimal-rtp.ts using inclusion-exclusion over pre-aggregated multiplier sums.
| Step | Process | Result |
|---|---|---|
| 1 | Build score table for all 2,598,960 hands → rank index 0–9 | Distribution: 4 royal, 36 SF, 624 quads, … 2,062,860 none — matches textbook |
| 2 | Pre-aggregate multiplier sums for all 1-, 2-, 3-, 4-card subsets of the 52-card deck | ~85M additions instead of 2.77 trillion direct lookups |
| 3 | For each dealt hand × 32 hold patterns: EV via inclusion-exclusion on discarded cards | Max-EV hold per dealt hand = optimal play |
| 4 | Aggregate: RTP = total_optimal_EV / 2,598,960 | 99.900000% for Duel pay table |
| 5 | Cross-validate: re-run solver against standard 9/6 JoB pay table | 99.543904% (target 99.5439%, tolerance 10⁻⁶ — PASS) |
| Optimal hold count | Hands | Share | Typical scenario |
|---|---|---|---|
| 0 cards | 84,360 | 3.25% | Garbage hand — discard all 5 |
| 1 card | 403,968 | 15.54% | Single high card (J/Q/K/A) — keep, redraw 4 |
| 2 cards | 1,651,440 | 63.54% | Pair (any rank) — keep, redraw 3 |
| 3 cards | 148,392 | 5.71% | Three of a kind, or 3 to a flush/straight |
| 4 cards | 291,312 | 11.21% | Two pair, or 4 to a flush/straight |
| 5 cards | 19,488 | 0.75% | Made hand: straight, flush, full house, quads, royal flush |
// Step 26: Optimal-Play RTP Verificationconst artifact = JSON.parse(fs.readFileSync(artifactPath, 'utf-8')); // Check cross-validation passedif (!artifact.crossValidation?.pass) { return step(26, 'Optimal-Play RTP Verification', 'FAIL', `Cross-validation FAILED: standard 9/6 JoB RTP = ${artifact.crossValidation?.rtpPercent}% (target: 99.5439%)`);} // Check pay table hash matches current configconst configPath = path.join(__dirname, '../../videoPokerConfig.json');const configHash = require('crypto').createHash('sha256') .update(require('fs').readFileSync(configPath)) .digest('hex'); if (artifact.duel?.payTableHash !== configHash) { return step(26, 'Optimal-Play RTP Verification', 'FAIL', `Pay table hash mismatch — artifact was computed against a different config. Rerun npm run compute-optimal-rtp`);}Why the cross-validation matters: a strategy solver could in principle be tuned to produce 99.9% for any pay table by manipulating the EV computation. The defense against that is to run the same solver against a different, well-known pay table and check that it produces that table's known RTP. We use 9/6 Jacks or Better — the most-studied video poker variant in the world, with a known optimal-play RTP of 99.5439% (target taken from Wizard of Odds). Our solver returns 99.543904% on that pay table — confirming it correctly computes optimal play before we trust its 99.9% answer for Duel's table.
4.4House Edge Audit (Step 10)
The house edge is implemented by the paytable values themselves in videoPokerConfig.json. Duel's variant uses a paytable that differs from standard 9/6 Jacks-or-Better (royal flush 812.97×, straight flush 58×, four-of-a-kind 26×) — the higher multipliers are what produce a 99.9% optimal-play RTP (0.1% house edge). The audit computes this independently by running a recursive infinite-deck expected-value solver against Duel's paytable (Step 26), yielding 99.9000% exact. The solver is cross-validated by running the same algorithm against the standard 9/6 JoB paytable and confirming it produces the published 99.5439% target — proving the solver is correct, and therefore the 99.9% claim against Duel's paytable is trustworthy.
Every bet response also carries an effective_edge field. Per the operator's published mechanism, this field represents the player's net edge after Zero Edge rakeback is applied: bets are tagged 0.1 at settlement and updated to 0 asynchronously once an operator-side rewards queue processes the rakeback. All 5,400 bets in the captured dataset read 0.1, captured at settlement time. A sample re-fetch of bets initially captured at 0.1 showed the field had since updated to 0 — consistent with the operator's description of the rakeback mechanism as an asynchronous post-settlement queue. The audit does not verify the rewards transaction layer itself; it certifies the game-engine 0.1% house edge, which comes from the paytable values and is independent of the effective_edge field. See the audit scope exclusions for the rakeback layer.
effective_edge metadata field is constant at 0.1 across all 5,400 live bets (Step 10, metadata-tampering check). The paytable values are what produce the 99.9% RTP — the effective_edge field is not a game-logic input. The operator-side rakeback layer that updates net edge to 0% is outside this audit's scope.4.5Simulation Pass 1 — 10M Rounds (Step 17)
S4.2 proves the deal-only RTP = 33.7238% mathematically; S4.3 proves the optimal-play RTP = 99.9% via exhaustive enumeration. But does the live RNG actually produce these distributions in practice? To find out, we simulated 10 million rounds locally using the same RNG algorithm and 10 independent runtime-generated seed pairs. The simulation tests the deal-only distribution against the textbook hand-rank counts via Fisher's combined chi-squared test.
Fisher's combined test: Per-stream chi-squared p-values aggregated via T = −2 Σ ln(p_i) ~ χ²(2K). Fisher's combined p = 0.7982 across 10 streams of 1M rounds — comfortably non-rejection at any standard α.
Simulated deal-only RTP: 33.6999% across 10M rounds vs 33.7238% theoretical. Deviation of −0.024 pp (z ≈ −0.55) is well within expected sampling variance (per-round payout SD ≈ 1.37, so SE of mean RTP at N=10M is 0.043 pp). The chi-squared test on the full 10-bin distribution returns Fisher's p = 0.7982, confirming distribution fit.
Serial independence: Lag-1 autocorrelation r₁ = -0.000233 (negligible); Wald-Wolfowitz runs test p = 0.1000. Both within noise — no serial-independence failures.
/** * Monte Carlo simulation for Duel.com Video Poker audit. * * Pass 1 — Multi-stream Fisher's method (10 streams × 100K rounds = 1M total). * Video Poker is a single-config game (Jacks or Better), so we use multi-stream * with Fisher's combined p-value instead of per-config Bonferroni. This matches * the methodology for Crash (also single-config). Each stream uses an independent * pinned seed pair. Per-stream chi-squared on hand-rank distribution, combined via * Fisher's method: T = -2 Σ ln(p_i) ~ χ²(2K). Serial independence tested on the * full 1M combined stream. * * Methodology rule: multi-config → Bonferroni; single-config → Fisher's. *Detailed per-stream chi-squared values, theoretical-vs-observed counts, and the variance derivation are presented in S2 S2.5 (RNG distribution test). S4 cites the simulation here for its RTP convergence evidence — the 10M-round point estimate of 33.6999% confirms the deal-only RTP proof, and the chi-squared test confirms the underlying card probabilities are correct.
4.6Cherry-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 110 captured epochs and simulates 100,000 rounds under each one — 11 million rounds total — to check whether any of them are statistically biased against players.
Why the binomial test: at α=0.05, 5% of seeds are expected to fail by chance even under perfect fairness. Across 110 seeds, the expected number of failures is ~5.5; observing 5 with binomial p = 0.6485 means there is no statistical signal of seed pre-selection.
Aggregated chi-squared: when we pool all 11 million Pass 2 rounds and test the distribution against the textbook C(52,5) counts, the result is χ²(9) = 6.20, p = 0.7193 — far above any rejection threshold.
| Test | Result |
|---|---|
| Captured server seeds tested | 110 |
| Rounds per seed | 100,000 |
| Total Pass 2 rounds | 11,000,000 |
| Seeds flagged at α=0.05 | 5 / 110 — within expected noise |
| Binomial p-value (5 of 110 at 5% expected fail rate) | 0.6485 — well within noise |
| Aggregated chi-squared (across all 11M rounds) | χ²(9) = 6.20, p = 0.7193 |
| Aggregated RTP (across all 11M rounds) | 33.7329% — matches theoretical 33.7238% |
// Step 18: Pass 2 Cherry-Pick Detection// 5/110 flagged at α=0.05; binomial p = 0.6485// aggregate chi-squared p = 0.7193 across 11M rounds4.7Bet-Size Invariance (Step 9)
Phase C placed 100 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 100 decks were recomputed correctly from revealed seeds using the same HMAC-SHA256 backward Fisher-Yates path. Bet amount is not an input to the RNG and does not affect deck composition.
| Metric | $0.01 Phase A/B/D/E | $10.00 Phase C |
|---|---|---|
| Bets | 5,300 | 100 |
| Bet amount | $0.01 | $10.00 |
| Decks recomputed | 5,300/5,300 | 100/100 |
| Pay table | videoPokerConfig.json | videoPokerConfig.json |
| RNG path | HMAC-SHA256 Fisher-Yates | HMAC-SHA256 Fisher-Yates |
4.8Informational Items (Not Scored)
These items are reported for transparency but are not scored audit steps. They provide context on the empirical RTP observed during data collection. Note: live-bet empirical RTPs reflect player hold decisions (post-draw), not raw deal probabilities. Authoritative RTP evidence is in S4.2 (deal-only theoretical) and S4.3 (optimal-play theoretical).
| Item | Value | Context |
|---|---|---|
| Phase A empirical RTP | 32.97% | 3,000 bets at $0.01 — broad sampling, random/varied holds |
| Phase B empirical RTP | 60.00% | 1,000 bets at $0.01 — targeted hands (low-pair hold strategy) |
| Phase C empirical RTP | 27.00% | 100 bets at $10 — bet-size invariance test, high variance at N=100 |
| Phase D empirical RTP | 44.80% | 500 bets at $0.01 — auditor's client seed pfauditmod66jae |
| Phase E empirical RTP | 40.13% | 800 bets at $0.01 — systematic hold patterns |
4.9Worked Example — Payout & RTP Verification
Real bet from Phase A — hand_id 589168, nonce 6. The player was dealt [AC, 6C, 10H, QC, JD] (a single high card: Ace of clubs). They held only the Ace and drew 4 new cards. The replacement pool delivered [KD, KS, 10S, 5C] — two of which formed a pair of Kings, completing a jacks_or_better win for a 1× payout. Note: the win came from the pool, not from the held Ace. Verified from data/video-poker-dataset-5400bets.json:
serverSeed = 2424d436197860c90bad4022573f14eab6c9da3dcc2d50e57ad7d52357f0fdc9
clientSeed = cpc4ihmPtvpHpapv
nonce = 6
phase = A (broad sampling)
amount = $0.01
initial = [AC, 6C, 10H, QC, JD] (single high card: Ace of clubs)
held = [AC]
final = [AC, KD, KS, 10S, 5C] (pair of Kings from replacement pool)
combination = jacks_or_betterStep 1 — Pay table lookup: videoPokerConfig.multipliers.jacks_or_better = 1
Step 2 — Payout: 0.01 × 1 = 0.01 → amount_won = 0.01 ✅ (the bet returns the original stake)
Step 3 — Deal-only RTP contribution: Jacks-or-better appears 337,920 times in C(52,5) = 2,598,960 hands. Per-deal contribution: (337,920 / 2,598,960) × 1 = 0.130021 = 13.0021% of the 33.7238% deal-only RTP — the largest single contribution of any winning rank.
Step 4 — Why this hand is pedagogically rich: the held Ace is irrelevant to the win. The pair of Kings came from deck[5] and deck[6] — entirely from the replacement pool. The held card and the winning cards are different cards. This demonstrates that the deck composition, not the player's hold choice, determines whether a win is possible (the hold only determines which positions consume the pool).
| Step | Process | Output |
|---|---|---|
| 1 | Backward Fisher-Yates: 51 HMAC calls produce the deck | deck[0..4] = [AC,6C,10H,QC,JD] · deck[5..9] = [KD,KS,10S,5C,3C] |
| 2 | Apply hold [AC] → discard [6C, 10H, QC, JD] → consume pool[0..3] = [KD, KS, 10S, 5C] | final_cards = [AC, KD, KS, 10S, 5C] |
| 3 | Independent evaluateHand(final_cards) → pair of Kings (rank ≥ J) | jacks_or_better |
| 4 | Multiplier lookup: multipliers.jacks_or_better | 1× |
| 5 | Payout: 0.01 × 1 | $0.01 ✅ |
| 6 | Deal-only RTP contribution: (337,920 / 2,598,960) × 1 | 0.130021 (13.0021% of 33.7238%) |
// Anti-circularity: per-rank contribution to deal-only RTP//// jacks_or_better: 337,920 / 2,598,960 × 1 = 0.130021 (13.0021%)//// All 10 rank contributions sum to 33.7238% (theoretical deal-only RTP):// royal_flush: 4 / 2,598,960 × 812.97 = 0.001251 (0.1251%)// straight_flush: 36 / 2,598,960 × 58 = 0.000803 (0.0803%)// four_of_a_kind: 624 / 2,598,960 × 26 = 0.006242 (0.6242%)// full_house: 3,744 / 2,598,960 × 9 = 0.012965 (1.2965%)// flush: 5,108 / 2,598,960 × 6 = 0.011792 (1.1792%)// straight: 10,200 / 2,598,960 × 4 = 0.015699 (1.5699%)// three_of_a_kind: 54,912 / 2,598,960 × 3 = 0.063385 (6.3385%)// two_pair: 123,552 / 2,598,960 × 2 = 0.095078 (9.5078%)// jacks_or_better: 337,920 / 2,598,960 × 1 = 0.130021 (13.0021%)// none: 2,062,860 / 2,598,960 × 0 = 0.000000 (0.0000%)//// Σ = 0.337238 (33.7238%) ✅hand_id 589168 — payout chain matches live game exactly. Jacks-or-better's per-rank contribution to the deal-only RTP proof is (337,920 / 2,598,960) × 1 = 13.0021%. The full per-rank table (S4.2) sums to exactly 33.7238% — the anti-circularity proof.Technical Evidence & Verification5 sections
4.10Evidence Coverage Summary7 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Anti-circularity (Step 16) | 10 hand ranks × C(52,5) = 2,598,960 hands; deal-only RTP = 33.7238% | Pass |
| Optimal-play RTP solver (Step 26) | Exhaustive C(52,5) × 32 hold patterns; 134,459 canonical classes; RTP = 99.9000% | Pass |
| Solver cross-validation (Step 26) | Standard 9/6 JoB pay table → 99.5439% (target match to 6 decimal places) | Pass |
| House edge audit (Step 10) | effective_edge metadata field constant at 0.1 across all 5,400 bets (metadata-tampering check); operational house edge derived from paytable | Pass |
| Simulation Pass 1 (Step 17) | 10M rounds, Fisher's combined p = 0.7982; 0/10 streams fail | Pass |
| Simulation Pass 2 cherry-pick (Step 18) | 110 seeds × 100K nonces; 5/110 flagged (binomial p = 0.6485); aggregate χ² p = 0.7193 | Pass |
| Bet-size invariance (Step 9) | 100/100 Phase C bets at $10 — same algorithm produces the deck regardless of bet amount | Pass |
4.11Code References7 source files
| File | Purpose |
|---|---|
| tests/steps/simulation.ts | Steps 16–18: Anti-circularity, Pass 1 integrity, Pass 2 cherry-pick detection |
| tests/steps/payouts.ts | Steps 7–9: Payout math, pay table provenance, Phase C bet-size invariance |
| tests/steps/dataset.ts | Step 10: House edge audit |
| tests/steps/game-specific.ts | Step 26: Optimal-play RTP verification (cross-validation gate) |
| src/compute-optimal-rtp.ts | Optimal-play solver: C(52,5) enumeration with inclusion-exclusion |
| src/simulate.ts | Monte Carlo simulation (10M Pass 1 + 11M Pass 2) |
| src/stats.ts | Chi-squared, Fisher's combined, hypergeometric PMF, lag-1 autocorrelation |
4.12Datasets UsedSource artifacts
4.13Verified Invariants13 invariants
| Invariant | Result |
|---|---|
Σ count(rank) × multiplier(rank) / C(52,5) = 33.7238% (deal-only, non-circular) | Pass |
C(52,5) = 2,598,960 and the 10 textbook rank counts sum to exactly C(52,5) | Pass |
| Optimal-play RTP from exhaustive enumeration = 99.9000% | Pass |
| Solver cross-validation against standard 9/6 JoB → 99.5439% (match to 6 decimal places) | Pass |
Pay table hash in optimal-play artifact matches current videoPokerConfig.json (tamper detection) | Pass |
Flat 0.1% house edge — derived from Duel's paytable in videoPokerConfig.json; effective_edge metadata field constant at 0.1 across all 5,400 live bets (metadata-tampering check) | Pass |
| Simulated deal-only RTP = 33.6999% across 10M rounds (within sampling variance of theoretical 33.7238%) | Pass |
| Fisher's combined p = 0.7982 across 10 streams of 1M rounds | Pass |
| 0/10 streams reject chi-squared at α=0.01 | Pass |
| Lag-1 autocorrelation negligible (r₁ = -0.000233); runs test p = 0.1000 | Pass |
| No evidence of seed pre-selection across 110 captured server seeds (Pass 2 binomial p = 0.6485) | Pass |
| Phase C ($10) decks produced via same algorithm as $0.01 | Pass |
| Bet amount absent from RNG input by construction | Pass |
4.14Reproduction InstructionsRun locally
git clone https://github.com/ProvablyFair-org/duel-video-poker.gitcd duel-video-poker && npm installnpm run compute-optimal-rtp # exhaustive C(52,5) × 32-pattern solver (~95 sec)npm run simulate # 10M Pass 1 + 11M Pass 2 (~10–15 min)npm run verify # Steps 9, 10, 16, 17, 18, 26 cover S4cat outputs/optimal-play-rtp.jsoncat outputs/simulation-results.jsonS4-related steps:
[PASS] Step 9 — Phase C Bet-Size Invariance
[PASS] Step 10 — House Edge Audit
[PASS] Step 16 — Anti-Circularity (Deal-Only Distribution Check)
[PASS] Step 17 — Simulation Validation — Pass 1 (Deal-Only Distribution)
[PASS] Step 18 — Pass 2 Cherry-Pick Detection
[PASS] Step 26 — Optimal-Play RTP VerificationFairness 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 16 fairness integrity tests covering nonce integrity, seed commitment, outcome determinism, cross-player isolation, payout integrity, and a Video-Poker-specific hold/discard state-integrity check. 14 tests passed, 1 raised a FLAG (server-side input-validation gap on a duplicate held card — disclosed to Duel.com, no fairness impact), and 1 is not applicable to this game type (no replay surface — card sequence is fixed at deal time).
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 or payout values be injected client-side?
- Parameter limits — can invalid held cards or bet amounts be submitted?
- Hold/discard integrity — can the player's hold choice alter the deck, or can replacement cards be redirected?
What this means for you
- No one — not the player, not the casino — can alter outcomes through the API
- Once a bet is placed, the result cannot be changed or replayed
- Each bet is cryptographically unique and isolated
- Your results are independent of every other player
- Your hold choice cannot influence the deck — it only selects which positions consume from the pre-shuffled pool
- 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 or payout values be injected client-side?
- Parameter limits — can invalid held cards or bet amounts be submitted?
- Hold/discard integrity — can the player's hold choice alter the deck, or can replacement cards be redirected?
- No one — not the player, not the casino — can alter outcomes through the API
- Once a bet is placed, the result cannot be changed or replayed
- Each bet is cryptographically unique and isolated
- Your results are independent of every other player
- Your hold choice cannot influence the deck — it only selects which positions consume from the pre-shuffled pool
- The server rejects malformed, out-of-range, and duplicate requests
Nonce integrityPass
Seed commitment integrityPass
Outcome determinismPass
Hold/discard integrityPass
Round & player isolationPass
Payout integrityFlag
["8D","8D"]) accepted by server — input-validation gap, no fairness impact (final hand still computed from pre-committed shuffle). FI-PAYOUT-002 pass: injected payout/card fields ignored. Disclosed to Duel.com.16 fairness integrity tests (15 standard + 1 Video-Poker-specific): 14 pass, 1 N/A (no replay surface — card sequence fixed at deal time), 1 FLAG (FI-PAYOUT-001: a duplicate held card accepted by server — input-validation gap, disclosed to Duel.com, does not break fairness).
Category Coverage
| Check | Result | Evidence |
|---|---|---|
| Nonce integrity | Pass | Sequential, server-controlled, no gaps or duplicates across 110 bet-bearing epochs |
| Seed commitment integrity | Pass | Locked at bet acceptance, unique per epoch — 115/115 seed entries verified |
| Outcome determinism | Pass | Identical inputs produce identical decks — 5,400/5,400 confirmed; both halves (deal + draw) recover from (serverSeed, clientSeed, nonce) |
| Hold/discard integrity | Pass | Player hold choice cannot alter deck composition — 5,400/5,400 verified across 4 hold counts in Phase E (800 bets) |
| Round & player isolation | Pass | Per-user seeds, serial independence confirmed (Fisher's combined p = 0.7982 across 10M rounds) |
| Payout integrity | Flag | FI-PAYOUT-001 flagged: a duplicate held card (["8D","8D"]) accepted by server — input-validation gap, no fairness impact (final hand still computed from pre-committed shuffle). FI-PAYOUT-002 pass: injected payout/card fields ignored. Disclosed to Duel.com. |
Nonce integrityPass
Seed commitment integrityPass
Outcome determinismPass
Hold/discard integrityPass
Round & player isolationPass
Payout integrityFlag
["8D","8D"]) accepted by server — input-validation gap, no fairness impact (final hand still computed from pre-committed shuffle). FI-PAYOUT-002 pass: injected payout/card fields ignored. Disclosed to Duel.com.16 fairness integrity tests (15 standard + 1 Video-Poker-specific): 14 pass, 1 N/A (no replay surface — card sequence fixed at deal time), 1 FLAG (FI-PAYOUT-001: a duplicate held card accepted by server — input-validation gap, disclosed to Duel.com, does not break fairness).
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 (the standard five plus one Video-Poker-specific category for hold/discard integrity). Scope boundary: S5 tests whether fairness guarantees hold under non-standard API interaction. Platform-level implementation testing falls outside standard certification.
| 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 |
| Outcome Determinism | 2 | Non-deterministic outputs, outcome replay |
| Player Isolation | 2 | Cross-round correlation, cross-user outcome dependence |
| Payout Integrity | 2 | Parameter enforcement, server-side computation verification |
| Game State Integrity (VP-specific) | 1 | Hold/discard manipulation, replacement pool redirection |
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 |
| TBD | Test not yet completed | Must complete before final certification |
| FLAG | Anomaly detected, documented for transparency | Disclosed |
| 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 |
| Deck recomputation mismatch | Undisclosed inputs affecting outcomes |
| Client seed not used in HMAC | Player has no influence on outcomes |
| Hold choice alters deck composition (VP-specific) | Player gains an information advantage; structural fairness violated |
16 tests · 14 pass · 1 flag · 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
FI-NONCE-003PassSubmitting an invalid or out-of-sequence nonce does not produce a game outcome — the server rejects the request
Adversarial API probe — 7 adversarial nonce values (−1, 0, 999999999, `'abc'`, INT32_MAX, INT32_MAX+1, 9e18) injected into video poker start requests. All 7 silently ignored; the server assigned valid sequential nonces. Continuity preserved (baseline 568 → post-test 576); 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
Adversarial API probe — 6/7 invalid client seeds rejected with HTTP 422 (empty, null, special chars, emoji, CJK, whitespace-only). Oversized seed (>800 chars) accepted — permissive but safe (long entropy is not a security issue). All video poker 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
Adversarial API probe — 10 concurrent seed rotations on Account A and 10 on Account B produced 20 distinct hashes with zero cross-account collisions; all hashes within each account unique.
FI-SEED-005PassServer seeds show no correlation with timestamps, sequential patterns, or other predictable inputs
Adversarial API probe — 10 sequential seed rotations spaced 5 seconds apart produced 10 fully distinct hashes; no two shared 6 or more leading hex characters, ruling out time-correlated derivation.
Outcome Determinism2/2
FI-OUTCOME-001PassGiven identical inputs (`serverSeed`, `clientSeed`, `nonce`), the game always produces the same card stream
FI-OUTCOME-002N/AA completed bet cannot be replayed to generate a duplicate payout — card sequence is cryptographically fixed at bet time
Structural — the card sequence is fixed from the seed pair at deal time and decision actions consume cards from that fixed sequence. Video Poker has no multi-step cashout surface, so there is no replay vector to probe.
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
Structural — seed uniqueness enforced per user; corroborated by FI-SEED-004 (zero cross-account hash collisions across 20 rotations).
Payout Integrity1/2
FI-PAYOUT-001FlagGame parameters cannot exceed defined limits — invalid bet amounts and invalid held cards are rejected
Adversarial API probe — invalid bet amounts (negative, oversized, non-numeric, numeric zero) rejected with HTTP 422/400; out-of-bounds held cards rejected. FLAG: a draw request with a duplicated held card (`held_cards: ["8D","8D"]`) was accepted with HTTP 200 rather than rejected — a server-side input-validation gap. No fairness impact: the final hand is still computed from the pre-committed shuffle. Disclosed to Duel.com.
FI-PAYOUT-002PassMultiplier, card, hand-rank, and replacement-card fields in the API request are ignored — the server computes all values from the seed pair
Adversarial API probe — `payout_multiplier`, `cards`, `hand_rank`, and `replacement_cards` injected into start requests. Zero injections honoured; the server computed its own card sequence and outcome for every request.
Game State Integrity (Video-Poker-specific)1/1
FI-VP-HOLD-001PassThe player's hold/discard choice cannot alter the deck — held cards are preserved exactly and discarded positions draw replacements from the pre-committed shuffle sequence
Technical Evidence & Verification4 sections
5.3Coverage Summary16 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 | Adversarial API probe | Pass |
| FI-NONCE-004 | Nonce | S1, Step 4 (data-driven) | Pass |
| FI-SEED-001 | Seed | Adversarial 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 | Adversarial API probe | Pass |
| FI-SEED-005 | Seed | Adversarial API probe | Pass |
| FI-OUTCOME-001 | Determinism | S3, Step 5 (data-driven) | Pass |
| FI-OUTCOME-002 | Determinism | Structural — card sequence fixed at deal time; no multi-step cashout surface | N/A |
| FI-ISO-001 | Isolation | S2, Step 17 (simulation) | Pass |
| FI-ISO-002 | Isolation | Structural — seed uniqueness | Pass |
| FI-PAYOUT-001 | Payout | Adversarial API probe | Flag |
| FI-PAYOUT-002 | Payout | Adversarial API probe | Pass |
| FI-VP-HOLD-001 | Game State | S3, Steps 20–22 + 25 (data-driven, 5,400 bets) | Pass |
5.4Additional Integrity Evidence (S1–S4)14 records
| Property | Source | Finding |
|---|---|---|
| 110/110 epochs verified | S1, Steps 1–3 | Commit-reveal chain intact; 115 seed entries (110 bet-bearing + 5 unused/active) |
| 114/114 next-seed promotions | S1, Step 2 | Seed rotation chain intact across all epoch transitions |
| 5,400/5,400 exact parity | S3, Step 5 | No post-RNG conditional logic; both halves (deal + draw) recoverable |
| Hand classification accuracy | S3, Step 19 | 5,400/5,400 — independent evaluateHand() matches server combination for all 10 ranks |
| Hold validity | S3, Step 20 | 5,400/5,400 — held_cards ⊆ initial_cards and |held| ≤ 5 for every bet |
| Replacement pool determinism | S3, Step 21 | 5,400/5,400 — non-held positions consume deck[5..9] sequentially L→R |
| Deck integrity | S3, Step 22 | 5,400/5,400 — deck[0..9] window contains 10 unique valid cards |
| Phase E hold pattern coverage | S3, Step 25 | 800/800 across 4 distinct hold counts (hold-0/1/2/5) |
| Card rank distribution | S2, Step 23 | χ²(12) = 8.39, p = 0.7541 across 27,000 dealt cards |
| Card suit distribution | S2, Step 24 | χ²(3) = 0.52, p = 0.9150 across 27,000 dealt cards |
| Anti-circularity proven | S4, Step 16 | Deal-only RTP = 33.7238% from independent C(52,5) enumeration |
| Optimal-play RTP solver | S4, Step 26 | 99.9000% with 9/6 JoB cross-validation at 99.5439% (target match to 6 dp) |
| 5/110 cherry-pick flags (binomial p = 0.6485) | S4, Step 18 | No seed pre-selection bias |
| 100.0% client seed influence | S1, Step 6 | Player entropy is genuine — 500/500 sampled bets produce different decks with alternate client seed |
5.5Scope & LimitationsScope note
5.6Reproduction InstructionsRun locally
Data-driven tests (10 of 16): Reproducible from the open-source repo. These tests run against the captured 5,400-bet dataset and produce deterministic results.
API probe tests (6 of 16): Verified by issuing live adversarial requests against the running game. Per-test evidence (HTTP status codes, server-assigned nonces, seed hashes, server-computed card streams, and rejection responses) 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-video-poker.gitcd duel-video-pokernpm 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 20 — Hold Validity → FI-VP-HOLD-001
[PASS] Step 21 — Replacement Pool Determinism → FI-VP-HOLD-001
[PASS] Step 22 — Deck Integrity → FI-VP-HOLD-001
[PASS] Step 25 — Phase E Hold Pattern Coverage → FI-VP-HOLD-001API Probe Tests (completed):
# FI-NONCE-003: PASS — 7/7 invalid nonces silently ignored; sequence continuity maintained (568 → 576)# FI-SEED-001: PASS — 6/7 invalid seeds rejected HTTP 422; oversized seed accepted (gray area); all subsequent bets functional# FI-SEED-004: PASS — 0 hash collisions across 20 concurrent rotations on two accounts; full account isolation# FI-SEED-005: PASS — 10 rotations at 5-sec intervals; all hashes distinct; no suspicious prefix correlation# FI-PAYOUT-001: FLAG — invalid amounts rejected, out-of-bounds held cards rejected, BUT duplicate held_cards ["8D","8D"] accepted HTTP 200 (input-validation gap, no fairness impact)# FI-PAYOUT-002: PASS — 0 injection attempts honoured; cards and outcomes computed server-side# Status: 5 PASS, 1 FLAG (FI-PAYOUT-001 disclosed to Duel.com — see integrity-test repo testing/tests/videopoker/)held_cards array containing the same card twice (e.g. ["8D","8D"]) with HTTP 200 instead of rejecting. This is a server-side input-validation hygiene gap. It does not affect fairness or payout: the deck is pre-committed before the deal, the duplicate resolves to a single hold, and the final hand still settles from the pre-committed replacement pool exactly as it would for a valid hold. Disclosed to Duel.com for remediation.Player Verification
Can a player verify their own bets without trusting anyone?
Every Video Poker outcome can be independently reproduced using publicly disclosed inputs. No hidden variables, no private backend data. If your computed deck matches the live result, 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 Video Poker outcome can be independently reproduced
- No hidden variables — no private backend data
- If your computed deck matches the game result, 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)
- Hold Pattern — which positions you held
- Every Video Poker outcome can be independently reproduced
- No hidden variables — no private backend data
- If your computed deck matches the game result, 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)
- Hold Pattern — which positions you held
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 output — both the initial deal and the final hand after holds.
- 1
Place a Video Poker Bet
Place your stake and play the hand. The platform commits to the full 10-card window (initial 5 + replacement pool) via the provably fair algorithm before any card is shown — your hold/discard choices only determine which positions in the replacement pool get consumed.
- 2
Open the Fairness Modal
Open the Provably Fair modal on the Video Poker 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 Video Poker round. The per-bet modal shows the revealed plaintext server seed alongside the bet ID, client seed, and nonce.
- 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 52-card shuffle inline — if the initial 5 cards and the replacement pool both 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
Place a Video Poker bet — you'll be dealt 5 cards from a 52-card deck (positions 0–4 of the shuffle). Choose which cards to hold, then click "deal" to replace the discarded cards from positions 5–9 of the same shuffle. The casino committed to the entire 10-card window — initial deal AND replacement pool — before you placed the bet. Your hold pattern only determines which positions in the replacement pool get consumed.

Duel.com Video Poker — final hand displayed with paytable, payout multiplier, and Bet Again action. Both the initial 5 cards and the replacement pool are locked before any animation plays.
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 Video Poker round. The per-bet modal shows the revealed plaintext server seed alongside the bet ID, client seed, and nonce. 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, and nonce for the round.
Click Verify in the per-bet modal to open the Provably Fair page with the seeds pre-populated. The page recomputes the 52-card shuffle from the disclosed inputs and renders the Game Result inline — the Initial Cards (deck positions 0–4) and the Next 5 cards in the deck (the replacement pool, positions 5–9 consumed left-to-right by your hold pattern). If the recomputed initial cards and replacement pool both match what you saw at the table, the bet was provably fair — the casino committed to the deck 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 initial cards and replacement pool rendered inline matching the live game result.
Manual Verification (Advanced)5 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.
6.7How the Algorithm Works (Plain English)
Before you play, the server locks the entire deck shuffle 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 hand unique
These three ingredients are combined with HMAC-SHA256 (a cryptographic function) to drive a backward Fisher-Yates shuffle of a 52-card deck — a textbook algorithm with one HMAC call per swap. Bias-free rejection sampling ensures every shuffle order is equally likely. Your initial 5-card hand is the top of the shuffled deck (positions 0-4); the next 5 cards (positions 5-9) form the replacement pool that fills any cards you discard. Because the server committed to its seed before you bet, and your client seed is generated in your browser, neither party can predict or influence the deal — and the entire outcome (initial hand + every possible draw replacement) is determined the moment the deal happens, before you choose what to hold.
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 Video Poker hand. The script reproduces the full 52-card shuffle and returns your initial 5-card hand:
const crypto = require('crypto'); const ALL_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_UINT32 = 0xFFFFFFFF; function computeShuffledDeck(serverSeed, clientSeed, nonce) { const key = Buffer.from(serverSeed, 'hex'); const deck = [...ALL_CARDS]; for (let i = 51; i > 0; i--) { const range = i + 1; const maxFair = MAX_UINT32 - (MAX_UINT32 % range); const cursor = 51 - i; const message = `${clientSeed}:${nonce}:${cursor}`; const hmac = crypto.createHmac('sha256', key).update(message).digest('hex'); let found = false; for (let off = 0; off + 8 <= hmac.length; off += 8) { const value = parseInt(hmac.substring(off, off + 8), 16); if (value < maxFair) { const j = value % range; [deck[i], deck[j]] = [deck[j], deck[i]]; found = true; break; } } if (!found) throw new Error(`Rejection exhausted at i=${i}`); } return deck;} function verifyHash(serverSeed, serverSeedHashed) { const hash = crypto .createHash('sha256') .update(Buffer.from(serverSeed, 'hex')) .digest('hex'); return hash === serverSeedHashed;} // Replace with your valuesconst serverSeed = 'YOUR_SERVER_SEED';const serverSeedHashed = 'YOUR_SERVER_SEED_HASH';const clientSeed = 'YOUR_CLIENT_SEED';const nonce = 0; console.log('Hash check:', verifyHash(serverSeed, serverSeedHashed) ? 'PASS' : 'FAIL');const deck = computeShuffledDeck(serverSeed, clientSeed, nonce);console.log('Initial hand: ', deck.slice(0, 5).join(' '));console.log('Replacement pool:', deck.slice(5, 10).join(' '));6.10Python Verification Script
The same verification in Python (standard library only):
import hashlib, hmac ALL_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_UINT32 = 0xFFFFFFFF def compute_shuffled_deck(server_seed, client_seed, nonce): key = bytes.fromhex(server_seed) deck = list(ALL_CARDS) for i in range(51, 0, -1): range_ = i + 1 max_fair = MAX_UINT32 - (MAX_UINT32 % range_) cursor = 51 - i message = f'{client_seed}:{nonce}:{cursor}'.encode() h = hmac.new(key, message, hashlib.sha256).hexdigest() found = False for off in range(0, len(h) - 7, 8): value = int(h[off:off+8], 16) if value < max_fair: j = value % range_ deck[i], deck[j] = deck[j], deck[i] found = True break if not found: raise RuntimeError(f'Rejection exhausted at i={i}') return deck def verify_hash(server_seed, server_seed_hashed): computed = hashlib.sha256(bytes.fromhex(server_seed)).hexdigest() return computed == server_seed_hashed # Replace with your valuesserver_seed = 'YOUR_SERVER_SEED'server_seed_hashed = 'YOUR_SERVER_SEED_HASH'client_seed = 'YOUR_CLIENT_SEED'nonce = 0 print('Hash check:', 'PASS' if verify_hash(server_seed, server_seed_hashed) else 'FAIL')deck = compute_shuffled_deck(server_seed, client_seed, nonce)print('Initial hand: ', ' '.join(deck[:5]))print('Replacement pool:', ' '.join(deck[5:10]))Verify a real bet, step by stepVerify a real bet · Swipe for all 4 steps →
How it works
- 1
Place a Video Poker Bet
Place your stake and play the hand. The platform commits to the full 10-card window (initial 5 + replacement pool) via the provably fair algorithm before any card is shown — your hold/discard choices only determine which positions in the replacement pool get consumed.
- 2
Open the Fairness Modal
Open the Provably Fair modal on the Video Poker 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 Video Poker round. The per-bet modal shows the revealed plaintext server seed alongside the bet ID, client seed, and nonce.
- 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 52-card shuffle inline — if the initial 5 cards and the replacement pool both match what you saw at the table, the bet was provably fair.
Visual Walkthroughstep-by-step
Place a Video Poker bet — you'll be dealt 5 cards from a 52-card deck (positions 0–4 of the shuffle). Choose which cards to hold, then click "deal" to replace the discarded cards from positions 5–9 of the same shuffle. The casino committed to the entire 10-card window — initial deal AND replacement pool — before you placed the bet. Your hold pattern only determines which positions in the replacement pool get consumed.

Duel.com Video Poker — final hand displayed with paytable, payout multiplier, and Bet Again action. Both the initial 5 cards and the replacement pool are locked before any animation plays.
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 Video Poker round. The per-bet modal shows the revealed plaintext server seed alongside the bet ID, client seed, and nonce. 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, and nonce for the round.
Click Verify in the per-bet modal to open the Provably Fair page with the seeds pre-populated. The page recomputes the 52-card shuffle from the disclosed inputs and renders the Game Result inline — the Initial Cards (deck positions 0–4) and the Next 5 cards in the deck (the replacement pool, positions 5–9 consumed left-to-right by your hold pattern). If the recomputed initial cards and replacement pool both match what you saw at the table, the bet was provably fair — the casino committed to the deck 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 initial cards and replacement pool 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.
6.7How the Algorithm Works (Plain English)
Before you play, the server locks the entire deck shuffle 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 hand unique
These three ingredients are combined with HMAC-SHA256 (a cryptographic function) to drive a backward Fisher-Yates shuffle of a 52-card deck — a textbook algorithm with one HMAC call per swap. Bias-free rejection sampling ensures every shuffle order is equally likely. Your initial 5-card hand is the top of the shuffled deck (positions 0-4); the next 5 cards (positions 5-9) form the replacement pool that fills any cards you discard. Because the server committed to its seed before you bet, and your client seed is generated in your browser, neither party can predict or influence the deal — and the entire outcome (initial hand + every possible draw replacement) is determined the moment the deal happens, before you choose what to hold.
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 Video Poker hand. The script reproduces the full 52-card shuffle and returns your initial 5-card hand:
const crypto = require('crypto'); const ALL_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_UINT32 = 0xFFFFFFFF; function computeShuffledDeck(serverSeed, clientSeed, nonce) { const key = Buffer.from(serverSeed, 'hex'); const deck = [...ALL_CARDS]; for (let i = 51; i > 0; i--) { const range = i + 1; const maxFair = MAX_UINT32 - (MAX_UINT32 % range); const cursor = 51 - i; const message = `${clientSeed}:${nonce}:${cursor}`; const hmac = crypto.createHmac('sha256', key).update(message).digest('hex'); let found = false; for (let off = 0; off + 8 <= hmac.length; off += 8) { const value = parseInt(hmac.substring(off, off + 8), 16); if (value < maxFair) { const j = value % range; [deck[i], deck[j]] = [deck[j], deck[i]]; found = true; break; } } if (!found) throw new Error(`Rejection exhausted at i=${i}`); } return deck;} function verifyHash(serverSeed, serverSeedHashed) { const hash = crypto .createHash('sha256') .update(Buffer.from(serverSeed, 'hex')) .digest('hex'); return hash === serverSeedHashed;} // Replace with your valuesconst serverSeed = 'YOUR_SERVER_SEED';const serverSeedHashed = 'YOUR_SERVER_SEED_HASH';const clientSeed = 'YOUR_CLIENT_SEED';const nonce = 0; console.log('Hash check:', verifyHash(serverSeed, serverSeedHashed) ? 'PASS' : 'FAIL');const deck = computeShuffledDeck(serverSeed, clientSeed, nonce);console.log('Initial hand: ', deck.slice(0, 5).join(' '));console.log('Replacement pool:', deck.slice(5, 10).join(' '));6.10Python Verification Script
The same verification in Python (standard library only):
import hashlib, hmac ALL_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_UINT32 = 0xFFFFFFFF def compute_shuffled_deck(server_seed, client_seed, nonce): key = bytes.fromhex(server_seed) deck = list(ALL_CARDS) for i in range(51, 0, -1): range_ = i + 1 max_fair = MAX_UINT32 - (MAX_UINT32 % range_) cursor = 51 - i message = f'{client_seed}:{nonce}:{cursor}'.encode() h = hmac.new(key, message, hashlib.sha256).hexdigest() found = False for off in range(0, len(h) - 7, 8): value = int(h[off:off+8], 16) if value < max_fair: j = value % range_ deck[i], deck[j] = deck[j], deck[i] found = True break if not found: raise RuntimeError(f'Rejection exhausted at i={i}') return deck def verify_hash(server_seed, server_seed_hashed): computed = hashlib.sha256(bytes.fromhex(server_seed)).hexdigest() return computed == server_seed_hashed # Replace with your valuesserver_seed = 'YOUR_SERVER_SEED'server_seed_hashed = 'YOUR_SERVER_SEED_HASH'client_seed = 'YOUR_CLIENT_SEED'nonce = 0 print('Hash check:', 'PASS' if verify_hash(server_seed, server_seed_hashed) else 'FAIL')deck = compute_shuffled_deck(server_seed, client_seed, nonce)print('Initial hand: ', ' '.join(deck[:5]))print('Replacement pool:', ' '.join(deck[5:10]))Only disclosed inputs are used. Identical inputs always produce identical output — both the initial deal and the final hand after holds.
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-video-poker
- Commit: 071d7e081d9fa1c63fa37d187ff4a1395e229c98
- Game: Video Poker (duel-video-poker)
- 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-video-poker
- Commit: 071d7e081d9fa1c63fa37d187ff4a1395e229c98
- Game: Video Poker (duel-video-poker)
- 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 5,400 bets across 115 seeds; the optimal-play RTP artifact is pinned to a specific pay table hash for tamper detection.
Commands to Reproduce
Installs TypeScript, ts-node, and cryptographic dependencies. npm test runs mocha (unit tests), then the 10M-round Pass 1 + 11M-round Pass 2 simulation, then the 27-step verification pipeline. Note: optimal-play RTP requires a separate npm run compute-optimal-rtp invocation (Tab 4) because it generates a separate artifact pinned to the pay table hash.
Pass 1: 10 streams × 1,000,000 rounds = 10M total via Fisher's combined chi-squared (single-config game, no Bonferroni). Pass 2: each of the 110 captured server seeds replayed for 100,000 nonces (11M total) for cherry-pick detection. Total runtime ~10–15 minutes. Output written to outputs/simulation-results.json and outputs/rtp-convergence.html.
Exhaustive enumeration of all 2,598,960 dealt hands × 32 hold patterns via inclusion-exclusion over pre-aggregated multiplier sums. Cross-validates against the standard 9/6 Jacks or Better pay table (target 99.5439%) before computing the Duel.com RTP. Runtime ~95 seconds. Output written to outputs/optimal-play-rtp.json with a SHA-256 hash of the pay table for tamper detection. Required by Step 26.
Audit Reproducibility Pinning
- Git Commit
- 071d7e081d9fa1c63fa37d187ff4a1395e229c98
- Node Version
- v18+ (tested on v22.x)
- Dataset
- data/video-poker-dataset-5400bets.json (5,400 bets, 115 seeds)
- Dataset Hash (SHA-256)
- 363fd4d4c072a1180ca2e7ab61d0c8dcc6f3300f315449af0d7101dd45dd0654
- Audit Date
- April 2026
- Audit ID
- PF-2026-DL08
Output Artifacts6 files generated
| File | Contents | Size |
|---|---|---|
| verification-results.json | Steps 1–27 results, per-step pass/fail with evidence | ~7KB |
| simulation-results.json | 10M Pass 1 (Fisher's combined) + 11M Pass 2 (cherry-pick detection) | ~163KB |
| optimal-play-rtp.json | C(52,5) enumeration → 99.9000%; 9/6 JoB cross-validation → 99.5439%; pay table hash | ~3KB |
| determinism-log.json | Per-bet deck verification (5,400 entries, 0 mismatches) | ~102B |
| chi-squared-results.json | Per-rank chi-squared statistics and p-values | ~1.4KB |
| rtp-convergence.html | Interactive RTP convergence chart (self-contained HTML) | ~4KB |
Repository Structure
duel-video-poker/ ├── src/ │ ├── rng.ts → HMAC-SHA256 backward Fisher-Yates 52-card shuffle │ ├── hand-evaluator.ts → Independent 10-rank hand classification │ ├── compute-optimal-rtp.ts → C(52,5) × 32-pattern strategy solver │ ├── optimal-strategy.ts → Strategy artifact loader │ ├── simulate.ts → Monte Carlo — 10M Pass 1 + 11M Pass 2 (Fisher's method) │ ├── stats.ts → Chi-squared, Fisher's, hypergeometric, lag-1, runs test │ ├── loader.ts → Dataset loader + SHA-256 hash guard │ └── types.ts → Type definitions ├── tests/ │ ├── verify.ts → 27-step verification pipeline │ ├── steps/ │ │ ├── commitment.ts → Steps 1–4: Commit-reveal integrity │ │ ├── determinism.ts → Steps 5–6: Deck recompute + client seed influence │ │ ├── payouts.ts → Steps 7–9: Payout math + multiplier + Phase C │ │ ├── dataset.ts → Steps 10–15: House edge + completeness + Phase D │ │ ├── simulation.ts → Steps 16–18: Anti-circularity + Pass 1 + Pass 2 │ │ ├── game-specific.ts → Steps 19–27: Hand class + holds + pool + deck + Phase E + optimal RTP + reconstruction │ │ ├── statistical.ts → Informational: live RTP, serial, hand distribution │ │ └── context.ts → Shared context + pass/fail helpers │ └── videoPoker/ │ └── VideoPokerTests.ts → Mocha unit tests ├── data/ │ └── video-poker-dataset-5400bets.json → 5,400 live bets (5 phases, 115 seeds) ├── outputs/ → Generated by npm test + npm run compute-optimal-rtp │ ├── verification-results.json → Steps 1–27 pass/fail │ ├── simulation-results.json → 10M Pass 1 + 11M Pass 2 (Fisher's, cherry-pick) │ ├── optimal-play-rtp.json → C(52,5) enumeration → 99.9000%; cross-validation 99.5439% │ ├── determinism-log.json → Per-bet deck recomputation log │ ├── chi-squared-results.json → Distribution test results │ └── rtp-convergence.html → Interactive RTP convergence chart ├── results/ → Reserved for run artifacts (.gitkeep) ├── evidence/ │ ├── step1–step4 *.png → Game UI, fairness page, transaction reveal, verification result │ └── client-seed-origin.png → Client seed origin evidence ├── capture/ │ ├── capture.reference.js → Browser bet capture script │ └── fill-gaps.reference.js → Capture gap-fill helper ├── videoPokerConfig.json → Duel.com pay table (10 hand ranks) + effective_edge ├── 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-Reference27 verification steps mapped
| Step | Test Name | Report Section |
|---|---|---|
| 1 | Seed Hash Integrity | S1 |
| 2 | Commitment Linkage | S1 |
| 3 | Hash Consistency Within Epoch | S1 |
| 4 | Nonce Audit | S1, S5 |
| 5 | Outcome Recomputation | S1, S2, S3 |
| 6 | Client Seed Influence | S1, S2 |
| 7 | Payout Math | S3, S4 |
| 8 | Multiplier Provenance | S3, S4 |
| 9 | Phase C Bet-Size Invariance | S3, S4 |
| 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, S3 |
| 16 | Anti-Circularity (Theoretical Deal-Only RTP) | S4 |
| 17 | Simulation Validation — Pass 1 (Fisher's method) | S2, S4 |
| 18 | Pass 2 Cherry-Pick Detection | S4 |
| 19 | Hand Classification Accuracy | S3, S5 |
| 20 | Hold Validity | S3, S5 |
| 21 | Replacement Pool Determinism | S3, S5 |
| 22 | Deck Integrity | S3, S5 |
| 23 | Card Rank Distribution | S2 |
| 24 | Card Suit Distribution | S2 |
| 25 | Phase E Hold Pattern Coverage | S3, S5 |
| 26 | Optimal-Play RTP Verification | S4 |
| 27 | Reconstructed Hands Disclosure | S3 |
reproduce-audit.sh
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 5,400 bets across 115 seeds; the optimal-play RTP artifact is pinned to a specific pay table hash for tamper detection.