Fairness & provable randomness
Last updated: 2026-04-19
This page explains how Poker Club ties shuffles and hand history to verifiable randomness: table seeds, private server seeds, commitments, optional rotation, and an append-only history chain.
How it works
Each table mixes two ingredients: a table seed you can see (your side of the randomness) and a private server seed you cannot see until it is revealed. Before a private seed is revealed, you only see its cryptographic hash — like a sealed envelope. We also publish a hash of the next private seed so everyone knows the next envelope is already fixed.
Every new hand uses a fresh nonce so the stream of random numbers moves forward in a predictable, checkable way. When someone asks to rotate the private seed, we do not interrupt the current hand: the rotation is scheduled and applies at the end of the round, so the hand you are playing still matches the commitments you already saw.
Hand history is stored in a blockchain-style chain: each record includes the hash of the previous record. If anyone tried to rewrite an old hand, the chain would no longer match — that is how we keep an auditable timeline tied to the same seeds and outcomes.
At a glance
┌───────────────────────────────────────────────────────────────────┐ │ Table seed (public) Private seed (secret until reveal) │ │ │ │ │ │ │ HMAC stream + nonce │ You see: SHA-256 hash │ │ └────────────┬───────────────────┘ until rotation │ │ ▼ │ │ Shuffled deck → deal order │ │ │ │ │ ▼ │ │ Hand history chain: hash[n] = SHA256( hash[n-1] : bodyHash ) │ └───────────────────────────────────────────────────────────────────┘
Technical details
Random floats are generated with HMAC-SHA256: each draw uses the private seed as the HMAC key and a message of the form clientSeed:nonce:cursor, where cursor increases for every random value consumed. The digest is turned into a number in [0, 1) using the first 13 hex characters (52 bits) for stable floating-point precision.
The deck is shuffled with Fisher–Yates using that stream. Dealing consumes cards from the end of the deck array (last position is dealt first), then community cards and burns follow in game order.
The nonce increments when a new round starts so each hand gets an independent draw sequence while the seeds stay the same until you rotate the private seed.
History rows are chained per table: each stored round has a body hash of the canonical JSON payload, a previous hash, and a row hash equal to SHA256(prevHash + ':' + bodyHash). The first row chains from a fixed genesis string.
Verify a shuffle yourself
Use the form to run the same verification in your browser, or paste the script into an online Node.js runner. Set clientSeed (table seed), serverSeed (revealed private seed for that period), and nonce from the hand. Adjust seats and dealer to match the table.
Free online Node.js sandboxes you can use:
Free online Node.js sandboxes you can use:
Same algorithm (Node.js)
const crypto = require("crypto");
/** @typedef {{ suit: string; rank: string }} Card */
const PRNG_ALGORITHM = "sha256";
const PRNG_HASH_FORMAT = "hex";
/** @param {string} hex */
function hexToFloat(hex) {
const slice = hex.slice(0, 13);
const int = parseInt(slice, 16);
return int / Math.pow(2, 52);
}
class ProvablyFairPRNG {
/**
* @param {string} serverSeed
* @param {string} clientSeed
* @param {number} nonce
* @param {number} [cursor]
*/
constructor(serverSeed, clientSeed, nonce, cursor = 0) {
this.serverSeed = serverSeed;
this.clientSeed = clientSeed;
this.nonce = nonce;
this.cursor = cursor;
}
nextFloat() {
const hmac = crypto.createHmac(PRNG_ALGORITHM, this.serverSeed);
hmac.update(this.clientSeed + ":" + this.nonce + ":" + this.cursor);
const hash = hmac.digest(PRNG_HASH_FORMAT);
this.cursor++;
return hexToFloat(hash);
}
/** @param {number} max */
nextInt(max) {
return Math.floor(this.nextFloat() * max);
}
}
/** @returns {Card[]} */
function buildDeck() {
const suits = ["HEARTS", "DIAMONDS", "CLUBS", "SPADES"];
const ranks = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
/** @type {Card[]} */
const deck = [];
for (const suit of suits) {
for (const rank of ranks) {
deck.push({ suit, rank });
}
}
return deck;
}
/**
* @param {ProvablyFairPRNG} prng
* @param {Card[]} deck
*/
function shuffle(prng, deck) {
const shuffled = [...deck];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = prng.nextInt(i + 1);
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
const suitSymbols: Record<string, string> = {
HEARTS: "♥",
DIAMONDS: "♦",
CLUBS: "♣",
SPADES: "♠",
};
/** @param {Card} c */
function label(c) {
return c.rank + suitSymbols[c.suit];
}
const clientSeed = "YOUR_TABLE_SEED_HEX";
const serverSeed = "YOUR_REVEALED_PRIVATE_SEED_HEX";
const nonce = 1;
/** Seats at the table (clockwise). Default: two seats; first seat is the dealer button for display. */
const playerSeats = /** @type {number[]} */ ([1, 2]);
const dealerSeat = 1;
const prng = new ProvablyFairPRNG(serverSeed, clientSeed, nonce, 0);
const deck = shuffle(prng, buildDeck());
// Engine deals with deck.pop(): shuffled[51] is the first card off the deck.
/** @type {Card[]} */
const stack = deck.slice();
function dealOne() {
const c = stack.pop();
if (!c) {
throw new Error("deck_exhausted");
}
return c;
}
console.log("=== Shuffled deck (index 0 = top of pack, index 51 = bottom; pop takes bottom first) ===");
console.log(deck.map((c, i) => i + ":" + label(c)).join(", "));
console.log("");
console.log("=== Same deck: first card dealt -> last index (deal order) ===");
console.log([...deck].reverse().map(label).join(", "));
/** Two hole-card rounds: cycle playerSeats (card1 seat1, card2 seat2, card3 seat1, card4 seat2 for [1,2]). */
const holeCardTargets = playerSeats.concat(playerSeats);
/** @type {Record<number, Card[]>} */
const holeBySeat = {};
for (const s of playerSeats) {
holeBySeat[s] = [];
}
console.log("");
console.log("=== Hole cards (dealer seat " + dealerSeat + ", " + playerSeats.length + " players) ===");
for (let i = 0; i < holeCardTargets.length; i++) {
const seat = holeCardTargets[i];
const c = dealOne();
holeBySeat[seat].push(c);
console.log("Step " + (i + 1) + ": " + label(c) + " -> seat " + seat);
}
/** @type {Card[]} */
const burned = [];
console.log("");
console.log("=== Flop: burn 1, then 3 community cards ===");
const burnFlop = dealOne();
burned.push(burnFlop);
console.log("Burn: " + label(burnFlop));
const flop = [dealOne(), dealOne(), dealOne()];
console.log("Flop 1: " + label(flop[0]));
console.log("Flop 2: " + label(flop[1]));
console.log("Flop 3: " + label(flop[2]));
console.log("");
console.log("=== Turn: burn 1, then 1 community ===");
const burnTurn = dealOne();
burned.push(burnTurn);
console.log("Burn: " + label(burnTurn));
const turnCard = dealOne();
console.log("Turn: " + label(turnCard));
console.log("");
console.log("=== River: burn 1, then 1 community ===");
const burnRiver = dealOne();
burned.push(burnRiver);
console.log("Burn: " + label(burnRiver));
const riverCard = dealOne();
console.log("River: " + label(riverCard));
console.log("");
console.log("=== Summary ===");
for (const s of playerSeats) {
console.log("Seat " + s + " hole: " + holeBySeat[s].map(label).join(", "));
}
const board = flop.concat([turnCard, riverCard]);
console.log("Board: " + board.map(label).join(", "));
console.log("Burned: " + burned.map(label).join(", "));