DEV Community

Cover image for Poker Shuffle Algorithm: 3 Bugs That Fail RNG Certification
Troniex Technologies
Troniex Technologies

Posted on

Poker Shuffle Algorithm: 3 Bugs That Fail RNG Certification

How to shuffle cards in a real-money poker server (and the 3 bugs that fail certification)

In 1999, a team at Reliable Software Technologies watched 5 cards hit the table at Planet Poker and then called every remaining card in the deck. In real time.

They didn't hack the server. They read the shuffle code the site had published to show it was fair (the full write-up is still online). It seeded a 32-bit generator with the number of milliseconds since midnight, and its shuffle loop swapped each card with any position in the deck. Sync your clock with the server's and there were only about 200,000 decks to check. A laptop could do that before the turn card came.

That's why the shuffle is the first thing a testing lab pulls apart.

Why a 5-line problem isn't one

A 52-card deck has 52! possible orders. That's about 8 × 10^67, or roughly 2^226.

A generator with a 32-bit seed can only start from 2^32 states. So it can produce about 4.3 billion decks, ever. Every other ordering is unreachable, and anyone who knows the algorithm can list the reachable ones.

We build poker platforms at Troniex, and the shuffle is where we find the most bugs in code we inherit from other teams. The same 3 keep showing up.

The 3 bugs we keep finding

1. Math.random() (and anything else with a weak seed)

V8 implements Math.random() with xorshift128+. It's fast and fine for animations. It isn't cryptographically secure, and its internal state can be recovered from a run of past outputs.

In a card game, that means a player who logs enough dealt cards can work out what's coming. Every lab we've prepared builds for treats a non-cryptographic generator as an automatic fail.

The same goes for rolling your own: seed = Date.now() gives an attacker a search space of a few thousand values if they know roughly when the hand started. That's Planet Poker again, 25 years later.

2. Sorting with a random comparator

deck.sort(() => Math.random() - 0.5); // don't
Enter fullscreen mode Exit fullscreen mode

It looks clever. It's biased.

Sort algorithms assume the comparator is consistent: if a < b now, a < b later. A random comparator breaks that, and the result depends on the sort's internals. Some orderings come up far more often than others.

We measured it. Over 200,000 shuffles on Node 22, we tracked where the ace of spades landed. A fair shuffle gives a chi-square score near 51 across the 52 positions. The random-comparator sort scored 2,030. A lab's statistical tests will catch that in minutes.

Bar chart comparing ace-of-spades position across 200,000 shuffles: Fisher-Yates with crypto.randomInt shows a flat, uniform distribution (χ² = 33.8); sort with Math.random comparator shows visible spikes and dips (χ² = 2,116), indicating statistical bias.

3. Modulo bias

const j = crypto.randomBytes(1)[0] % 52; // don't
Enter fullscreen mode Exit fullscreen mode

A byte has 256 values, and 256 isn't divisible by 52 (52 × 4 = 208, remainder 48). So values 0 to 47 come up 5 times out of 256, and values 48 to 51 only 4 times.

That's a 25% higher chance for some cards to land in some positions. Small per hand. Very visible over the millions of shuffles a lab runs.

A correct shuffle in Node.js

Fisher-Yates walks the deck from the end and swaps each card with a random card at or before it. Pair it with a cryptographically secure, unbiased random integer and every one of the 52! orderings is equally likely.

const crypto = require('crypto');

function newDeck() {
  const suits = ['s', 'h', 'd', 'c'];
  const ranks = ['2','3','4','5','6','7','8','9','T','J','Q','K','A'];
  return suits.flatMap(s => ranks.map(r => r + s));
}

function shuffle(deck) {
  const d = deck.slice();
  for (let i = d.length - 1; i > 0; i--) {
    const j = crypto.randomInt(i + 1); // uniform in [0, i], no modulo bias
    [d[i], d[j]] = [d[j], d[i]];
  }
  return d;
}

function deckHash(handId, deck) {
  return crypto.createHash('sha256').update(handId + ':' + deck.join(',')).digest('hex');
}

const handId = 'table42-hand-1001';
const deck = shuffle(newDeck());
console.log(deckHash(handId, deck)); // store this with the hand history
Enter fullscreen mode Exit fullscreen mode

3 things matter here:

  • crypto.randomInt pulls from the OS CSPRNG and uses rejection sampling, so there's no modulo bias.
  • Shuffle once per hand, on the server. Cards never leave the server until they're dealt to their owner or shown at showdown. No pre-shuffled decks sitting in a queue.
  • Store a hash of the deck order with the hand history. When a player disputes a hand, you can prove the deck wasn't changed mid-hand.

We ran the same 200,000-shuffle test on this code. Chi-square came out at 59.8, right where a fair shuffle should sit.

Provably fair mode for crypto tables

Crypto players often want to check the shuffle themselves. It's standard on crypto casino platforms, and poker tables are catching up. The usual pattern is commit-reveal:

  1. Before the hand, the server picks a secret serverSeed and publishes sha256(serverSeed).
  2. Each player at the table submits a clientSeed.
  3. The deck is shuffled with a deterministic generator built from both.
  4. After the hand, the server reveals serverSeed. Anyone can check it against the published hash and rebuild the deck.

Horizontal timeline showing four steps of a provably fair poker hand: server publishes a hash of its seed, players submit their seeds, the deck is shuffled from both, then the server reveals its seed so anyone can verify the result.

With several players, you need a fixed rule for combining their seeds, or you'll get disputes about ordering. Ours: close seed submission at a set time before the deal, sort the seeds, and join them with :. Publish that rule next to the verifier.

const crypto = require('crypto');

function hmacStream(serverSeed, clientSeed) {
  let counter = 0, buf = Buffer.alloc(0);
  return function nextUint32() {
    if (buf.length < 4) {
      const block = crypto.createHmac('sha256', serverSeed)
        .update(clientSeed + ':' + counter++).digest();
      buf = Buffer.concat([buf, block]);
    }
    const v = buf.readUInt32BE(0);
    buf = buf.subarray(4);
    return v;
  };
}

function randomBelow(max, next) {
  const limit = Math.floor(0x100000000 / max) * max; // rejection sampling
  let x;
  do { x = next(); } while (x >= limit);
  return x % max;
}

function provablyFairShuffle(deck, serverSeed, clientSeed) {
  const next = hmacStream(serverSeed, clientSeed);
  const d = deck.slice();
  for (let i = d.length - 1; i > 0; i--) {
    const j = randomBelow(i + 1, next);
    [d[i], d[j]] = [d[j], d[i]];
  }
  return d;
}

// clientSeed for a table: [...playerSeeds].sort().join(':')
Enter fullscreen mode Exit fullscreen mode

serverSeed must come from crypto.randomBytes(32) and must never be reused. Reuse one and every past hand with that seed becomes readable.

The % in randomBelow is safe because of the rejection loop above it: any value that would cause bias gets thrown away and redrawn.

Provably fair isn't a licence

This is the question that comes up every time, so it gets its own section.

Provably fair lets a player verify a single hand after it's played. A licence needs the RNG itself tested by an accredited lab against a standard like GLI-19, the interactive gaming systems standard from Gaming Laboratories International. Those are different jobs.

For a licensed real-money table, you need the certified path. Provably fair is an extra layer on top, mostly for crypto audiences who expect it.

What a lab will ask for

When we prepare a build for certification, the pack usually covers:

  • RNG design: entropy source, generator, seeding and reseeding
  • Shuffle spec: the algorithm above, written up
  • Game rules for every variant and tournament format (Texas Hold'em, Omaha, and so on)
  • Hand-history and audit-log format
  • A test build the lab can run millions of shuffles against

That last one is where the bugs above surface. The lab runs your shuffle millions of times and runs statistical tests on where the cards end up. A 25% skew or a chi-square of 2,030 won't hide.

Get the shuffle right before anyone else looks at it

The fix is small: crypto.randomInt, Fisher-Yates, once per hand, on the server, with a hash in the log. Getting it wrong costs you a failed certification round, or a Planet Poker headline.

If you're scoping a build, ask any vendor how they shuffle before you ask anything else. We cover the rest of the stack (side pots, wallets, anti-collusion) on our page about real-money poker platform development.

Top comments (0)