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
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.
3. Modulo bias
const j = crypto.randomBytes(1)[0] % 52; // don't
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
3 things matter here:
-
crypto.randomIntpulls 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:
- Before the hand, the server picks a secret
serverSeedand publishessha256(serverSeed). - Each player at the table submits a
clientSeed. - The deck is shuffled with a deterministic generator built from both.
- After the hand, the server reveals
serverSeed. Anyone can check it against the published hash and rebuild the deck.
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(':')
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)