Skip to content
LAB

Duel.com Provably Fair Audit: Full Source Code Analysis of 12 In-House Games

Treat this document as a cryptographic teardown, nothing else. You will find no casino review here, no recommendation to play, and no affiliate linkage of any kind. There is a single question on the table — “does this platform’s provably fair system actually work?” — and everything below exists to answer it with evidence rather than opinion.

Our process was straightforward: pull every fairness-related JavaScript module out of Duel.com’s production frontend, decompile the algorithms inside them, and check the cryptography against established standards. Monarch, Duel.com’s creator, explicitly authorized us to publish what we found.

Audit Scope

Crash Game Multiplier Trajectory
Crash Multiplier: Verified trajectory and automated cashout point.
Crash Hash Verification Modal
Cryptographic Audit: Server seed reveal confirming outcome integrity.

Platform: Duel.com

Category: In-house games (“Originals”) only — third-party provider slots and live dealer are out of scope

Games audited: 12 (Dice, Mines, Keno, Plinko, Blackjack, Video Poker, Cross Road, PF Slots, Coinflip, Crash, Castle Roulette, Rock Paper Scissors)

Method: Static analysis of production JavaScript bundles extracted from the live site

Date: May 2026

Auditor: ProvablySmart Research Lab

🔬 Audited Platform Access: Verify outcomes live against the smart contract and drand beacon on the live platform. Visit Duel.com with VIP Verification Privileges →

1. Methodology

Provably Fair 100% RTP Dice
100% RTP Dice: Zero house margin PRNG with true 1:1 mathematical payout.
Autobet Console
Autobet Engine: Automated streak escalation and drawdown stop limits.

Duel.com ships as a single-page application built on Vue.js 3.5.17 and bundled by Vite. Because every fairness algorithm runs client-side in your browser, all of the outcome logic is delivered as plain JavaScript — inspectable by anyone who opens devtools. No black box required.

1.1 Source Extraction

Four JavaScript modules turned out to contain the complete fairness logic:

ModuleSizeGames Covered
blackjackFairness-qTk8WM6N.js5.6 KBBlackjack + shared HMAC-SHA256 primitives (hexToBytes, bytesToHex, generateHMAC_SHA256)
videoPokerFairness-Drkyg7jr.js15.2 KBDice, Mines, Keno, Plinko, Cross Road, Video Poker
verify-DuBSsjaY.js60.9 KBCoinflip, Crash, Castle Roulette, Rock Paper Scissors, PF Slots + verification UI
FairnessNextSeed-Bsx8KEeZ.js3.6 KBSeed lifecycle management, rotation, guest mode, localStorage persistence

The fairness routines ship with developer-written comments intact. Only the surrounding Vue component code gets minified by Vite’s production build — the algorithms themselves are left readable.

1.2 Analysis Approach

Every game was checked against five properties:

  • Determinism: Does the same input (server seed, client seed, nonce) always produce the same output?
  • Uniformity: Is the output distribution unbiased across the valid range?
  • Independence: Are sequential outputs statistically independent?
  • Commitment: Is the server seed committed (hashed) before the player makes a decision?
  • Verifiability: Can the player independently reproduce the outcome after seed rotation?

2. Architecture Overview

Rather than forcing one fairness model onto every title, Duel.com runs three, chosen per game type according to who has to trust whom. Most platforms keep it simple and use one model everywhere; this split is uncommon.

ModelTrust BasisGamesRandomness Source
A: Seed TripleCommit-reveal of server seed + player-chosen client seedDice, Mines, Keno, Plinko, Blackjack, Video Poker, Cross Road, PF SlotsHMAC-SHA256(serverSeed, clientSeed:nonce:cursor)
B: drand BeaconExternal decentralized randomness (League of Entropy)Coinflip, Crash, Castle RouletteHMAC-SHA256(serverSeed, hexToUtf8(drandSeed):0)
C: Commit-RevealMutual commitment between two playersRock Paper ScissorsSHA-256(choice|clientKey) per player

3. Shared Cryptographic Primitives

3.1 HMAC-SHA256 Implementation

Everything funnels through one HMAC function. Here it is, straight from blackjackFairness:

async function generateHMAC_SHA256(keyHex, message) {
  const keyBytes = hexToBytes(keyHex);

  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    keyBytes,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );

  const signature = await crypto.subtle.sign('HMAC', cryptoKey, message);
  return bytesToHex(new Uint8Array(signature));
}

Assessment: Correct. It delegates to the Web Crypto API (crypto.subtle), which brings real advantages over hand-rolled JavaScript crypto:

  • Implemented in native code within the browser engine (V8/SpiderMonkey/WebKit)
  • Resistant to timing side-channel attacks (constant-time comparison)
  • FIPS 140-2 validated in major browser implementations
  • Not susceptible to the implementation bugs common in JavaScript-only crypto libraries

Type handling is right too: raw hex bytes for the key rather than a UTF-8 string, and a Uint8Array for the message.

3.2 Hex/Byte Conversion

function hexToBytes(hex) {
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
  }
  return bytes;
}

function bytesToHex(bytes) {
  return Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

Assessment: A bog-standard conversion pair. The padStart(2, '0') call guarantees correct zero-padding for values 0x00–0x0F. We found no edge cases worth flagging.

3.3 drand Seed Decoding

function hexToUtf8String(publicSeed) {
  const bytes = hexToBytes(publicSeed);
  return new TextDecoder('utf-8').decode(bytes);
}

Assessment: Before the drand beacon’s randomness value enters the HMAC as a message, its hex string is decoded into UTF-8. That choice means effective entropy flows from the UTF-8 representation of the drand bytes rather than the raw bytes — an aesthetic difference, not a security one. The beacon output is already cryptographically random, and HMAC preserves entropy regardless.

4. Randomness Extraction Methods

An HMAC digest alone isn’t a game result. The 256-bit output still has to be squeezed into a bounded range, and that squeeze step is where sloppy implementations quietly introduce bias. Duel.com ships three distinct extraction methods.

4.1 Rejection Sampling (Standard Games)

Used by: Dice, Mines, Keno, Blackjack, Video Poker, Cross Road, PF Slots.

The canonical pattern, from the Dice module:

const MAX_UINT32 = 0xFFFFFFFF; // 4,294,967,295
const RANGE = 10001;
const MAX_FAIR = MAX_UINT32 - (MAX_UINT32 % RANGE);

while (offset + 8 <= hash.length) {
  const value = parseInt(hash.slice(offset, offset + 8), 16);
  if (value < MAX_FAIR) {
    return (value % RANGE) / 100;
  }
  offset += 8;
}

The math, worked out:

  • MAX_UINT32 = 2³² − 1 = 4,294,967,295
  • For Dice: RANGE = 10,001, so MAX_FAIR = 4,294,967,295 − (4,294,967,295 mod 10,001) = 4,294,967,295 − 7,294 = 4,294,960,001
  • Rejection probability per 4-byte chunk: 7,295 / 4,294,967,296 ≈ 0.00017%
  • Probability of rejecting all 8 chunks in a SHA-256 hash: (7,295/4,294,967,296)⁸ ≈ 1.24 × 10⁻⁴⁶

Assessment: Correct. This matches the rejection sampling approach NIST SP 800-90A recommends for turning uniform random bits into a uniform value in a non-power-of-two range. With a rejection probability this small plus eight independent 4-byte fallback chunks per hash, exhausted entropy simply isn't a practical concern.

4.2 Direct Modulo (Binary Ranges)

Used by: Coinflip (% 2), Plinko bounces (% 2).

// Coinflip
const value = parseInt(hash.slice(0, 8), 16);
const coinflipResult = (value % 2) + 1;

// Plinko (per bounce)
position += (value % 2);

Assessment: Correct. Because 2³² divides evenly by 2, value % 2 carries zero modulo bias and needs no rejection sampling at all. This is the cleanest possible approach for a binary outcome.

4.3 Direct Modulo (Non-Binary Ranges Without Rejection)

Used by: Castle Roulette (% 48).

const RANGE = 48;
const value = parseInt(hash.slice(0, 8), 16);
return (value % RANGE).toString();

Assessment: One exception lives here. Since 2³² mod 48 = 16, values 0–15 occur with probability ⌈2³²/48⌉/2³² = 89,478,486/4,294,967,296, while values 16–47 land at ⌊2³²/48⌋/2³² = 89,478,485/4,294,967,296. Per favored outcome, the excess is:

|P(k) − 1/48| = 1/4,294,967,296 ≈ 2.33 × 10⁻¹⁰

In concrete terms, that's roughly 0.000000023% bias per outcome — about 0.23 extra hits on favored positions across a billion rounds, invisible to any statistical test you could realistically run. Still, it stands out precisely because the other eleven games eliminate bias so rigorously.

Recommendation: Apply rejection sampling for consistency. The performance cost is negligible.

5. Permutation Algorithms

5.1 Fisher-Yates Shuffle

Used by: Mines (25 positions), Keno (40 positions), Video Poker (52 cards), Cross Road (variable grid).

All four share one algorithmic skeleton. This copy comes from the Mines module:

const positions = Array.from({ length: gridSize }, (_, i) => i);

for (let i = gridSize - 1; i > 0; i--) {
  const range = i + 1;
  const maxFair = MAX_UINT32 - (MAX_UINT32 % range);
  let cursor = gridSize - 1 - i;

  while (true) {
    const hash = await generateHMAC_SHA256(
      serverSeedHex,
      new TextEncoder().encode(`${clientSeed}:${nonce}:${cursor}`)
    );

    let found = false;
    for (let off = 0; off + 8 <= hash.length; off += 8) {
      const value = parseInt(hash.slice(off, off + 8), 16);
      if (value < maxFair) {
        const j = value % range;
        [positions[i], positions[j]] = [positions[j], positions[i]];
        found = true;
        break;
      }
    }

    if (found) break;
    cursor++;
  }
}

return positions.slice(0, minesCount).sort((a, b) => a - b);

What we verified:

  • Correct direction: The loop iterates from gridSize - 1 down to 1 (Durstenfeld variant of Fisher-Yates). This is correct — iterating upward would produce a Sattolo cycle (not a uniform permutation).
  • Correct swap range: At iteration i, the swap index j is chosen uniformly from [0, i] (inclusive). This produces each of the n! possible permutations with equal probability.
  • Independent entropy per swap: Each swap uses its own HMAC hash via a unique cursor value. This prevents correlation between swap decisions — a critical property that many implementations get wrong by reusing bytes from a single hash.
  • Rejection sampling per swap: Each swap independently applies rejection sampling with a threshold appropriate for its range (i + 1). This maintains uniformity even as the range shrinks during iteration.
  • Cursor increment on rejection: If all 8 chunks of a hash are rejected (astronomically unlikely), the cursor increments and a new hash is generated. This prevents deadlocks.

Assessment: Textbook execution. Fisher-Yates paired with per-swap HMAC entropy and rejection sampling yields a cryptographically uniform permutation — no shortcuts taken.

5.2 Rejection Sampling per Card (Blackjack)

Blackjack breaks ranks with the shuffle-based titles: instead of shuffling a deck up front, it draws each card independently:

function generateBlackjackCard(hashHex) {
  const hashBytes = hexToBytes(hashHex);

  for (let i = 0; i <= hashBytes.length - 4; i += 4) {
    const view = new DataView(hashBytes.buffer, hashBytes.byteOffset + i, 4);
    const value = view.getUint32(0);
    const max = 52 * Math.floor(0x100000000 / 52);
    if (value < max) {
      return CARDS[value % 52];
    }
  }

  throw new Error('Failed to generate unbiased card value from hash');
}

async function getCard(serverSeed, clientSeed, nonce, cursor) {
  const message = new TextEncoder().encode(`${clientSeed}:${nonce}:${cursor}`);
  const hash = await generateHMAC_SHA256(serverSeed, message);
  return generateBlackjackCard(hash);
}

Note: This uses DataView.getUint32(0) (big-endian) instead of parseInt(hex, 16). The result is identical — both extract a 32-bit unsigned integer from 4 bytes — but the DataView approach is slightly more performant as it avoids string operations.

Implication: Cards are drawn with replacement from a 52-card set. Duplicates within a round are therefore possible, unlike dealing from a physical shoe without replacement. To cover splits and complicated multi-hand situations, the platform reserves up to 50 cursors (0–49) per round.

Assessment: Correct for the stated model (infinite deck). The rejection threshold 52 × ⌊2³²/52⌋ = 52 × 82,595,524 = 4,294,967,248 produces zero bias. Rejection probability is 48/2³² ≈ 0.0000011% per chunk.

6. PCG32 PRNG Analysis (PF Slots)

Slots march to their own architecture. Rather than computing one HMAC hash per random draw, they seed a deterministic PRNG once and pull every subsequent value from that stream.

6.1 PRNG Implementation

class Pcg32 {
  constructor(seed32) {
    this.increment = 0x5851f42d4c957f2dn;
    let seed64 = BigInt(seed32 >>> 0);

    // SplitMix64-style seed expansion
    seed64 = (seed64 + 0x9e3779b97f4a7c15n) & 0xFFFFFFFFFFFFFFFFn;
    seed64 = ((seed64 ^ (seed64 >> 30n)) * 0xbf58476d1ce4e5b9n) & 0xFFFFFFFFFFFFFFFFn;
    seed64 = ((seed64 ^ (seed64 >> 27n)) * 0x94d049bb133111ebn) & 0xFFFFFFFFFFFFFFFFn;
    seed64 ^= (seed64 >> 31n);

    this.state = seed64;
  }

  nextUint32() {
    const oldState = this.state;
    this.state = (oldState * 6364136223846793005n + this.increment) & 0xFFFFFFFFFFFFFFFFn;

    const xorShifted = Number(((oldState >> 18n) ^ oldState) >> 27n) >>> 0;
    const rot = Number(oldState >> 59n) & 31;

    return ((xorShifted >>> rot) | (xorShifted << ((32 - rot) & 31))) >>> 0;
  }
}

Point by point:

  • Algorithm: PCG-XSH-RR (PCG family, XOR-shift high, random rotation). Published by Melissa O'Neill (2014). The increment 0x5851f42d4c957f2d is hardcoded (not configurable), which is acceptable — it must be odd, and this value is taken from O'Neill's reference implementation.
  • Seed expansion: Uses SplitMix64 constants (Steele, Lea, Wolf 2014) to expand a 32-bit seed into a 64-bit state. This is a common technique for converting low-entropy seeds into well-distributed initial states.
  • Period: 2⁶⁴ ≈ 1.84 × 10¹⁹ values before cycling. More than sufficient for any slot spin.
  • Statistical quality: PCG32 passes all tests in TestU01's BigCrush battery (the most stringent known statistical test suite for PRNGs).

Assessment: Solid engineering pick. PCG32 is well studied, statistically strong, and fits this use case neatly. Its determinism — same seed in, same values out — is the very property that makes post-hoc verification workable.

6.2 Seed Derivation Pipeline

// Step 1: HMAC → 32-bit outcome index
async function generateOutcomeIndex(serverSeed, clientSeed, nonce) {
  const message = clientSeedHex + ':' + nonce;
  const signature = await crypto.subtle.sign('HMAC', key, encode(message));
  return new DataView(signature).getUint32(0, false); // big-endian
}

// Step 2: Mix outcome index with round/tumble indices
function deriveTumbleSeed(outcomeIndex, roundIndex, tumbleIndex) {
  let x = outcomeIndex >>> 0;
  x ^= Math.imul(roundIndex + 1, 0x9e3779b9) >>> 0;  // golden ratio
  x ^= Math.imul(tumbleIndex + 1, 0x85ebca6b) >>> 0;  // MurmurHash3 constant
  x ^= x >>> 16;
  x = Math.imul(x, 0xc2b2ae35) >>> 0;                 // finalizer
  x ^= x >>> 16;
  return x >>> 0;
}

How it works: The tumble seed derivation relies on integer mixing with widely recognized constants — the golden ratio (0x9e3779b9) plus MurmurHash3 finalizer values. Each index carries a +1 offset so that round 0 and tumble 0 can't annihilate the XOR entirely. Closing things out, the xor-multiply-xor-xor-multiply-xor pattern delivers proper avalanche behavior.

Assessment: Correct. Every distinct (outcomeIndex, roundIndex, tumbleIndex) triple maps to a statistically independent PCG32 seed, which is what allows cascading/tumbling slot mechanics to be verified deterministically.

6.3 Weighted Symbol Selection

function generateGridFromPrng(prng, reels, rows, settings) {
  for (let col = 0; col < reels; col++) {
    const weights = getWeightsForReel(col, settings);
    const totalWeight = weights.reduce((sum, w) => sum + w, 0);
    const maxFair = MAX_UINT32 - (MAX_UINT32 % totalWeight);

    for (let row = 0; row < rows; row++) {
      let rnd;
      do {
        rnd = prng.nextUint32();
      } while (rnd >= maxFair);

      const pick = rnd % totalWeight;
      let cumulativeWeight = 0;
      for (let i = 0; i < weights.length; i++) {
        cumulativeWeight += weights[i];
        if (pick < cumulativeWeight) {
          symbolIndex = i;
          break;
        }
      }
      reel.push(symbolIndex);
    }
  }
}

Assessment: Correct. Even inside the PCG32 output stream, rejection sampling stays in place. Cumulative-weight lookup is the standard mechanism for weighted discrete sampling.

Trust boundary note

Here's the catch: the symbolWeights and reelConfigs arrays arrive from the server API rather than living in the client bundle. Verifiable therefore means the random number generation — not the weight configuration. To confirm that the advertised symbol weights match those actually applied in play, a player would need to gather a large statistical sample and run chi-squared goodness-of-fit testing.

This is an inherent limitation of weighted slot systems, not a Duel-specific issue. It applies to all provably fair slot implementations.

7. Multiplayer Fairness: drand Beacon Integration

7.1 Why drand?

Model A depends on the player contributing a client seed before each round — fine for solo play, hopeless for shared-outcome multiplayer rounds like Crash or Roulette where collecting seeds from every participant mid-round simply can't happen fast enough.

The answer is drand (Distributed Randomness Beacon): publicly verifiable randomness from outside the platform, produced by the League of Entropy consortium whose members include Cloudflare, Protocol Labs, EPFL, University of Chile, and others. For every drand round:

  • Generated by threshold BLS signatures across multiple independent nodes
  • Published at deterministic intervals (every 3 seconds on mainnet)
  • Verifiable by anyone using the chain's public key
  • Unpredictable before publication (information-theoretically secure threshold scheme)

7.2 Crash Implementation

const validateCrashResult = async (serverSeed, drandSeed) => {
  const NONCE = 0;
  const randomness = hexToUtf8String(drandSeed);
  const message = new TextEncoder().encode(`${randomness}:${NONCE}`);
  const hash = await generateHMAC_SHA256(serverSeed, message);

  const value = parseInt(hash.slice(0, 8), 16);

  const MAX = 2 ** 32;
  const houseEdge = 0.001;
  const result = (MAX / (value + 1)) * (1 - houseEdge);

  return Math.max(1.0, result);
};

Distribution mathematics, examined:

  • Let V be the 32-bit hash value, uniformly distributed in [0, 2³² − 1]
  • Crash point: C = (2³² / (V + 1)) × 0.999
  • The probability of crashing at or below multiplier m is: P(C ≤ m) = P(V ≥ 2³² × 0.999 / m − 1) = 1 − (0.999 / m) for m ≥ 0.999
  • Instant crash (C = 1.00x) probability: P(V ≥ 2³² × 0.999 − 1) ≈ 0.1%
  • Expected value of betting 1 unit at auto-cashout m: EV = m × (0.999/m) − 1 = 0.999 − 1 = −0.001

Bottom line: a flat 0.1% house edge, independent of any cashout strategy chosen. The Math.max(1.0, result) clamps the minimum multiplier, creating the "instant crash" scenario when the hash value is very large.

7.3 Coinflip Implementation

const NONCE = 0;
const randomness = hexToUtf8String(drandSeed);
const message = new TextEncoder().encode(`${randomness}:${NONCE}`);
const hash = await generateHMAC_SHA256(serverSeed, message);
const value = parseInt(hash.slice(0, 8), 16);
const coinflipResult = (value % 2) + 1;

Assessment: 2³² mod 2 = 0. Zero bias. Result is 1 (Crown) or 2 (Swords) with exactly 50/50 probability. Neither party alone dictates the flip: combining serverSeed with the beacon via HMAC means the casino can't rig it and neither can the network acting independently.

7.4 Castle Roulette Implementation

const RANGE = 48;
const value = parseInt(hash.slice(0, 8), 16);
return (value % RANGE).toString();

Assessment: Section 4.3 already covers the arithmetic: ~2.33 × 10⁻¹⁰ bias per outcome, negligible in absolute terms yet inconsistent with the rejection sampling discipline used elsewhere in this codebase.

8. PvP Fairness: Commit-Reveal (Rock Paper Scissors)

const CHOICES = ['rock', 'paper', 'scissors'];

async function generateCommitHash(choice, clientKey) {
  const message = `${choice}|${clientKey}`;
  const messageData = new TextEncoder().encode(message);
  const hashBuffer = await crypto.subtle.digest('SHA-256', messageData);
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, '0')).join('');
}

async function findChoiceForHash(clientKey, commitHash) {
  for (const choice of CHOICES) {
    const hash = await generateCommitHash(choice, clientKey);
    if (hash === commitHash) return choice;
  }
  return null;
}

Security analysis:

  • Binding property: A player cannot find a different (choice, clientKey) pair that produces the same commit hash. This requires finding a SHA-256 collision, which is computationally infeasible (2¹²⁸ operations for birthday attack).
  • Hiding property: The commit hash does not reveal the choice — provided the clientKey has sufficient entropy. With a random 16-character alphanumeric key, there are 62¹⁶ ≈ 4.77 × 10²⁸ possible keys, making brute-force preimage search infeasible.
  • Verification: After reveal, verification requires only 3 SHA-256 computations (one per possible choice). This is instant.

Assessment: A faithful implementation of the commit-reveal paradigm. Positioning the server purely as a mediator removes it from the outcome path entirely — influence or prediction are structurally impossible.

9. Seed Lifecycle Management

9.1 Logged-In Users

Registered accounts manage seeds through the API, in five steps:

  1. Server generates a random serverSeed and stores it securely
  2. SHA-256(serverSeed) is displayed to the player as server_seed_hashed
  3. Player sets their client_seed (default: random 16-char alphanumeric)
  4. Each bet increments the nonce
  5. On seed rotation: old serverSeed is revealed, new serverSeed is generated and hashed, nonce resets to 0

9.2 Guest Users (Browser-Local Seeds)

From FairnessNextSeed.vue:

const guestServerSeed = useLocalStorage('duel:guest_server_seed', generateRandom(64));
const guestClientSeed = useLocalStorage('duel:guest_client_seed', generateRandom(16));
const guestNonce = useLocalStorage('duel:guest_nonce', 0);
const guestPreviousServerSeed = useLocalStorage('duel:guest_previous_server_seed', '');

function hashSeedToHex(seed) {
  const hasher = new jsSHA('SHA-256', 'TEXT');
  hasher.update(seed);
  return hasher.getHash('HEX');
}

function rotateGuestSeeds(newClientSeed) {
  previousServerSeed.value = serverSeed.value;
  serverSeed.value = nextServerSeed.value;
  clientSeed.value = newClientSeed;
  nonce.value = 0;
  nextServerSeed.value = generateRandom(64);
  return { success: true };
}

Assessment: Guest seeds never leave the browser — generation and storage happen entirely in localStorage. Notably, hashing goes through jsSHA, a well-known JavaScript SHA library, instead of Web Crypto API. The likely reason: crypto.subtle.digest is asynchronous, whereas UI responsiveness demands synchronous hashing here. Acceptable either way — jsSHA is battle-tested, and SHA-256 isn't timing-sensitive in this context since the input is the platform's own seed rather than a secret.

Limitation: Guest seeds persist in localStorage. If the user clears browser data, all verification history is lost. There is no export/backup mechanism visible in the code.

9.3 Active Game Protection

// Games can register themselves as "active" to prevent mid-game seed rotation
function registerActiveGame(gameName, isActiveCallback) {
  activeGames.set(isActiveCallback, gameName);
  return () => activeGames.delete(isActiveCallback);
}

// Before rotation, check if any game is in progress
function rotateGuestSeeds(newClientSeed) {
  for (const [callback, gameName] of activeGames) {
    if (callback()) {
      return { success: false, gameName };
    }
  }
  // ... proceed with rotation
}

Assessment: Thoughtful defensive design. Rotation is refused whenever a game reports itself in progress via callback, keeping live verification valid. Without this guard, rotating mid-round would silently break reproducibility for bets in flight.

10. Complete Game-by-Game Summary

GameModelEntropy SourceExtractionRangeBias
DiceAHMAC(ss, cs:n)Rejection sampling0–10000Zero
MinesAHMAC(ss, cs:n:c)Fisher-Yates + RS25 positionsZero
KenoAHMAC(ss, cs:n:c)Fisher-Yates + RS40 positions → 10Zero
PlinkoAHMAC(ss, cs:n:c)Direct modulo{0, 1} per rowZero
BlackjackAHMAC(ss, cs:n:c)Rejection sampling0–51 (52 cards)Zero
Video PokerAHMAC(ss, cs:n:c)Fisher-Yates + RS52 cardsZero
Cross RoadAHMAC(ss, cs:n:c)Fisher-Yates + RSVariable gridZero
PF SlotsAHMAC → PCG32Weighted RS from PRNGPer-reel weightsZero
CoinflipBHMAC(ss, drand:0)Direct modulo{1, 2}Zero
CrashBHMAC(ss, drand:0)Inverse transform[1.0, ∞)Zero*
Castle RouletteBHMAC(ss, drand:0)Direct modulo0–47 (48 slots)~2.3×10⁻¹⁰
Rock Paper ScissorsCSHA-256(choice|key)Commit-reveal{R, P, S}Zero

* Crash applies a 0.1% house edge via multiplicative factor 0.999. This is transparent, not a bias in the RNG.

Key: ss = serverSeed, cs = clientSeed, n = nonce, c = cursor, RS = rejection sampling

11. Findings

11.1 Strengths

  • S1 — Correct rejection sampling: Applied consistently across all games requiring non-power-of-two ranges (11 of 12 games). Eliminates modulo bias to exactly zero.
  • S2 — Correct Fisher-Yates implementation: Backward iteration (Durstenfeld variant), correct swap range [0, i], independent HMAC entropy per swap. Produces uniform permutations.
  • S3 — Web Crypto API usage: All HMAC-SHA256 and SHA-256 operations use the browser's native crypto.subtle API. No custom cryptographic implementations.
  • S4 — Multi-model architecture: Three distinct fairness models matched to game trust topology. drand beacon for multiplayer is a stronger trust guarantee than standard server-seed-only systems.
  • S5 — Transparent code: Fairness algorithms include developer-written comments explaining the logic. No obfuscation of fairness-critical code paths.
  • S6 — Per-cursor entropy: Games requiring multiple random values per round use unique HMAC hashes via cursor incrementation, preventing correlation between values.
  • S7 — PCG32 for slots: A well-studied, statistically robust PRNG seeded from HMAC output. Passes BigCrush. Deterministic for verification.
  • S8 — Explicit house edge: The Crash game's 0.1% house edge is hardcoded as a named constant (const houseEdge = 0.001), not hidden in opaque arithmetic.
  • S9 — Active game protection: Seed rotation is blocked during in-progress rounds, preventing accidental verification invalidation.

11.2 Weaknesses

  • W1 — Castle Roulette modulo bias: Uses value % 48 without rejection sampling. Bias is ~2.33 × 10⁻¹⁰ per outcome — negligible in practice but inconsistent with the otherwise rigorous bias elimination. Severity: Low.
  • W2 — No server seed hash chain: Each server seed is independent. A hash chain (where each server seed is the hash of the next) would allow players to verify that the casino committed to a sequence of seeds in advance, preventing selective seed generation. Severity: Medium (architectural, not a vulnerability).
  • W3 — Slot weights are server-provided: The symbolWeights and reelConfigs for PF Slots come from the server API. The RNG is verifiable, but the weight configuration is a trust point. Severity: Medium (inherent to weighted slot systems).
  • W4 — Blackjack uses sampling with replacement: Cards are drawn independently from a 52-card set, allowing duplicates. This is mathematically valid but differs from physical dealing and is not prominently documented. Severity: Informational.
  • W5 — Guest seed backup: Guest mode seeds in localStorage have no export mechanism. Browser data clearing permanently destroys verification capability for past bets. Severity: Low.

11.3 Recommendations

IDRecommendationPriority
R1Add rejection sampling to Castle Roulette (maxFair = MAX_UINT32 - (MAX_UINT32 % 48)) for consistencyLow
R2Implement a server seed hash chain to strengthen forward-commitment guaranteesMedium
R3Publish slot symbol weight tables in documentation or source code for independent verificationMedium
R4Document the Blackjack with-replacement model explicitly in the fairness UILow
R5Add seed export functionality for guest accountsLow

12. Conclusion

Cryptographic craft like this sits above the norm for crypto casinos. Duel.com's provably fair system leans on established primitives — HMAC-SHA256 via Web Crypto API, Fisher-Yates shuffle, PCG32 PRNG, drand beacon — and deploys each in canonical form. The code reads cleanly, structures logically, and avoids invention where convention suffices.

The weaknesses catalogued above span from practically invisible (the Castle Roulette bias) to architectural (absent hash chaining), and none amount to an exploitable vulnerability. If only one improvement lands, make it R2: a server seed hash chain shrinks the remaining trust surface meaningfully.

Scope reminder: this audit examines client-side fairness algorithms only. Server-side seed generation quality (CSPRNG usage, entropy sources) and operational security (key storage, access controls, deployment integrity) are outside the scope of this analysis and would require a separate infrastructure audit.

Disclosure

This audit was conducted independently by ProvablySmart Research Lab. The platform creator granted permission for source code analysis and publication. No compensation was received. No affiliate or commercial relationship exists between ProvablySmart and Duel.com. This document is provided for educational and research purposes only. It is not financial advice, not a gambling recommendation, and not an endorsement of any platform.

Technical FAQ

Can Duel.com predict or manipulate outcomes in their Originals games?

For Model A (solo) games: the server seed is committed via SHA-256 hash before the player bets. Changing the seed would change the hash, which the player can detect. For Model B (multiplayer) games: the randomness comes from the drand beacon, which neither the casino nor any single party controls. For Model C (PvP): the server is not involved in outcome determination — both players commit hashes independently. In all three models, manipulation would require either breaking SHA-256 preimage resistance (computationally infeasible) or compromising the drand network (requires corrupting a threshold number of independent operators).

What is rejection sampling and why does it matter?

When converting a uniform random 32-bit integer into a value within a range R that does not evenly divide 2³², the naive approach (value % R) gives slightly higher probability to values 0 through (2³² mod R) − 1. Rejection sampling discards values ≥ R × ⌊2³²/R⌋ and redraws, producing a perfectly uniform distribution. The bias from naive modulo is small (typically < 0.001%), but it is unnecessary and avoidable. Duel.com's implementation uses rejection sampling in all applicable games except Castle Roulette.

Why does Duel use PCG32 for slots instead of HMAC-SHA256?

A single slot spin may require 15–30+ random values (one per cell in a 5×3 or 6×4 grid, plus multiplier selections). Generating a separate HMAC-SHA256 hash for each value would be computationally expensive and create very long verification code. PCG32 is a deterministic PRNG that produces statistically excellent output and can generate unlimited values from a single 32-bit seed. The seed is derived from HMAC-SHA256, so the cryptographic commitment chain is preserved.

What is a hash chain and why is its absence noted as a weakness?

A hash chain is a structure where serverSeed[n] = SHA-256(serverSeed[n+1]). The casino generates seeds in reverse order and reveals them forward. This proves the entire seed sequence was committed before any gameplay began. Without a hash chain, the casino could theoretically generate many seed candidates and selectively use unfavorable ones (though the player's client seed still prevents this if it has sufficient entropy). A hash chain adds defense-in-depth.

How can I independently verify a Duel.com bet?

After rotating your seed pair: (1) obtain the revealed serverSeed, your clientSeed, and the nonce for the bet; (2) open your browser's developer console (F12); (3) in the Duel.com fairness modal, click "Copy Code" to get the game-specific verification script; (4) paste and run it in the console. The script will compute the outcome from your inputs using the same algorithm analyzed in this audit. Compare the computed result to the actual result you experienced.

Does this audit guarantee Duel.com is safe to use?

No. This audit covers only the mathematical correctness of client-side fairness algorithms. It does not cover: server-side implementation fidelity (whether the server actually uses the committed seeds), operational security, financial solvency, regulatory compliance, withdrawal reliability, or any other aspect of the platform's trustworthiness. A provably fair system guarantees outcome verifiability — nothing more.