DocsGames
Blackjack
What a shuffled deck from the library proves about a blackjack hand, what it can't, and how to keep the part it can't.
A blackjack round has two histories.
One is the order of the cards. A server seed, a client seed and a nonce fix it before anyone is dealt in, and whoever holds those three values can rebuild it card for card. The other is what happened at the table: the player hit or stood, and the dealer drew until the rules said stop. No seed produces that. A person did.
@galabet/fair covers the first history and stops there. Its blackjack result is 52 card labels in dealing order, or as many as 416 for a shoe. It doesn't deal. It has no idea what a hand is and it can't count to 21. Most of this page is about the gap that leaves, because a record that verifies can sit on top of a hand that was played wrong.
Were you handed a blackjack record? The verifier tells you whether that deck came from seeds committed in advance. Whether the dealer should have taken the card it took is a different question, and the answer needs the table's rules and the list of actions as well. With those you can deal the hand out yourself. There's code for that below.
The Shuffled Deck
import { play } from '@galabet/fair';
const { result: deck, floats, cursor } = await play({
game: 'blackjack',
serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
clientSeed: 'galabet',
nonce: 42,
});
console.log(deck.length, floats.length, cursor);
console.log(deck.slice(0, 10).join(' '));
console.log(new Set(deck).size);
52 51 6
7C KC 3S AC QH 5H 6S JC KH QS
52
The middle line is the top of the deck, read left to right. 7C is dealt first, KC second. A label is rank then suit with T for ten, so TC is the ten of clubs. The last line counts distinct labels, and 52 of them means nothing is missing and nothing is doubled.
| Input | Value |
|---|---|
| Server seed | 5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d |
| Client seed | galabet |
| Nonce | 42 |
params | {} or { decks: 1 } |
Floats and Cursor
Shuffling 52 cards with Fisher-Yates takes 51 swaps. GFS spends a fresh float on every swap and a digest holds eight floats, so one deck eats seven digests, with cursors 0 through 6. That's the 6 in the output above: cursor in a record is the highest one consumed. Seven digests hold 56 floats. The last five are thrown away.
You can do all of it with HMAC-SHA256 and a loop. This version takes a deck count, because the vectors it's tested against include eight-deck shoes.
import { createHmac } from 'node:crypto';
import { readFile } from 'node:fs/promises';
function myDeck({ serverSeed, clientSeed, nonce, params }) {
const size = 52 * (params.decks ?? 1);
const floats = [];
for (let cursor = 0; floats.length < size - 1; cursor++) {
const digest = createHmac('sha256', serverSeed).update(`${clientSeed}:${nonce}:${cursor}`).digest();
for (let at = 0; at < 32; at += 4) floats.push(digest.readUInt32BE(at) / 2 ** 32);
}
const cards = Array.from({ length: size }, (_, i) => i);
for (let i = size - 1; i > 0; i--) {
const j = Math.floor(floats[size - 1 - i] * (i + 1));
[cards[i], cards[j]] = [cards[j], cards[i]];
}
return cards.map((card) => 'A23456789TJQK'[card % 13] + 'CDHS'[Math.floor((card % 52) / 13)]);
}
const mine = myDeck({
serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
clientSeed: 'galabet',
nonce: 42,
params: {},
});
console.log(mine.slice(0, 10).join(' '));
const { games } = JSON.parse(await readFile('vectors/gfs-1.0.json', 'utf8'));
const vectors = games.filter((vector) => vector.game === 'blackjack');
const matching = vectors.filter((vector) => myDeck(vector).join() === vector.result.join());
console.log(`${matching.length} of ${vectors.length} blackjack vectors match`);
7C KC 3S AC QH 5H 6S JC KH QS
224 of 224 blackjack vectors match
Half of those vectors are single decks and half are eight-deck shoes.
The loop runs from the bottom of the deck upward, and that's where a port goes wrong. Float 0 chooses the card for position 51, the last card anyone could be dealt. The top card isn't chosen at all. It's whatever is left at position 0 after the final swap. Run the loop in the other direction and you get a valid-looking deck with every card in the wrong place.
The swap partner is floor(float × (i + 1)). No modulo. A card's number is suit × 13 + rank, with suits in the order C D H S and ranks A, 2 to 9, T, J, Q, K, which makes card 0 AC and card 51 KS. The key is the seed's hex string as text, the same as every other game; the Dice page shows what decoding it first does to you.
Is each swap even? As close as 32 bits allow. 4,294,967,296 floats don't split cleanly into 52 slots, so on the first swap 48 slots have 82,595,525 floats behind them and 4 have 82,595,524.
Multiple Decks
import { play } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
for (const decks of [1, 2, 6, 8]) {
const { result, floats, cursor } = await play({
game: 'blackjack', serverSeed, clientSeed: 'galabet', nonce: 42, params: { decks },
});
const aces = result.filter((card) => card === 'AS').length;
console.log(`decks ${decks}: ${result.length} cards, ${floats.length} floats, cursor ${cursor}, AS x${aces}`);
}
decks 1: 52 cards, 51 floats, cursor 6, AS x1
decks 2: 104 cards, 103 floats, cursor 12, AS x2
decks 6: 312 cards, 311 floats, cursor 38, AS x6
decks 8: 416 cards, 415 floats, cursor 51, AS x8
Labels repeat. The result holds labels and nothing else, so the eight aces of spades in a big shoe can't be told apart, which is also true of a real one. The cost grows in a straight line: eight decks is 52 HMAC calls for a single shuffle.
decks has to travel in the record's params. Leave it out of an eight-deck record and the verifier shuffles one deck, then reports two failures: cursor mismatch: computed 6, record 51 and result does not match seeds. There's a quieter trap at one deck. {} and { decks: 1 } shuffle identically, but they are different JSON, so they give different record hashes. The test vectors write {"decks":1}. Pick one spelling and keep to it.
Every nonce shuffles the whole shoe again. The library has no cut card and no way to carry a shoe from one hand to the next. If you want a shoe that lasts several hands you deal them all from one nonce's deck, which is your design and not the library's, and then everything under Keep the deck shut applies until that shoe is finished.
A single deck has 52! orderings, which is about 225.6 bits and fewer than the 256 in a server seed. An eight-deck shoe has about 2,229 bits of distinguishable orderings, so for any one client seed and nonce almost none of them can ever come up. Every seeded shuffle has this property. Nobody can use it without breaking HMAC first.
The Action Log
Deal the public deck the way Galabet's practice table does, with positions 0 and 2 to the player and 1 and 3 to the dealer, and the dealer is holding KC AC. A natural. The hand ends before the player has touched anything, which makes nonce 42 useless for showing a choice, so this example also deals nonce 44.
import { play } from '@galabet/fair';
function score(cards) {
let total = 0;
let aces = 0;
for (const [rank] of cards) {
if (rank === 'A') aces++;
total += rank === 'A' ? 11 : 'TJQK'.includes(rank) ? 10 : Number(rank);
}
while (total > 21 && aces > 0) { total -= 10; aces--; }
return { total, soft: aces > 0 };
}
function playHand(deck, actions, hitSoft17 = false) {
const player = [deck[0], deck[2]];
const dealer = [deck[1], deck[3]];
let next = 4;
const natural = score(player).total === 21 || score(dealer).total === 21;
if (!natural) {
for (const action of actions) {
if (action !== 'hit' || score(player).total > 21) break;
player.push(deck[next++]);
}
while (score(player).total <= 21) {
const { total, soft } = score(dealer);
if (total > 17 || (total === 17 && !(soft && hitSoft17))) break;
dealer.push(deck[next++]);
}
}
const p = score(player).total;
const d = score(dealer).total;
const outcome = p > 21 ? 'player busts' : d > 21 ? 'dealer busts' : p > d ? 'player wins' : p < d ? 'dealer wins' : 'tie';
return `${player.join(' ')} = ${p} | ${dealer.join(' ')} = ${d} | ${outcome}`;
}
async function deckFor(nonce) {
const { result } = await play({
game: 'blackjack',
serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
clientSeed: 'galabet',
nonce,
});
return result;
}
console.log('42 no action ', playHand(await deckFor(42), []));
const deck = await deckFor(44);
console.log('44 stand ', playHand(deck, ['stand']));
console.log('44 hit, stand ', playHand(deck, ['hit', 'stand']));
console.log('44 hit, stand * ', playHand(deck, ['hit', 'stand'], true));
42 no action 7C 3S = 10 | KC AC = 21 | dealer wins
44 stand 6S JD = 16 | AC 2D 2H 4C = 19 | dealer wins
44 hit, stand 6S JD 2H = 18 | AC 2D 4C = 17 | player wins
44 hit, stand * 6S JD 2H = 18 | AC 2D 4C 4D = 21 | dealer wins
The last three lines are one deck. Same seeds, same nonce, same 52 labels, and a record for any of them verifies exactly as well as a record for the others. Stand on 16 and the dealer draws to 19. Take one card and the player has 18 against a dealer who stops on a soft 17. The starred line is that same hit and stand at a table where the dealer hits soft 17: one more card, 21, and the player's win is a loss.
So three things decided that hand and the seeds supplied one of them. The rest is the action list and the rules, and the rules include more than people expect. Which positions go to whom is a rule. Deal the public deck two to the player and then two to the dealer, and instead of a dealer natural you get a player 17 against a soft 14. Soft 17 is a rule. Galabet's practice table stands on all 17s and offers hit and stand only, with no split, double, insurance or stakes. Those are demo choices. They aren't in GFS and they aren't in the library, and score and playHand above are example code written for this page.
What a Verified Record Covers
import { commit, play, recordHash, verifyRecord } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const { result, cursor } = await play({ game: 'blackjack', serverSeed, clientSeed: 'galabet', nonce: 44 });
// What the operator stores when the hand is dealt. No server seed in it yet.
const stored = {
spec: 'GFS/1.0', profile: 'single-player', game: 'blackjack', params: {},
commitment, clientSeed: 'galabet', nonce: 44, cursor, result, at: 0,
};
console.log(await recordHash(stored));
// After rotation the seed is added and the record can be checked.
const revealed = { ...stored, serverSeed };
const honest = await verifyRecord(revealed);
console.log(honest.ok, honest.reasons);
const stacked = [...result];
[stacked[4], stacked[5]] = [stacked[5], stacked[4]];
const caught = await verifyRecord({ ...revealed, result: stacked });
console.log(caught.ok, caught.reasons);
console.log(await recordHash(revealed));
449df4917431633dcff3175be2837109e4c5d161c42559696c6a8745beb023ec
true []
false [ 'result does not match seeds' ]
dcd783e1c7e7d99f87b86b331cda4cb1cfd0ac22a3ff23114bd6739624dcd453
Swapping positions 4 and 5 is the cheat this record exists to catch. Those are the first two cards anyone draws after the deal, and with them the wrong way round a player who hits holds 20 where the seeds say 18. verifyRecord rebuilds the deck from the seeds and refuses the altered one.
The honest record contains a deck, and that's all. Hit and stand aren't in it. Neither is the soft 17 rule, or who won. All three nonce 44 hands in the previous section share it. A dealer that drew on a hard 18, or a log that says "hit" where the player pressed stand, passes verification untouched, because verification never sees either.
Keeping the second history is your job. GFS 1.0 defines no format for it, so this is a suggestion and nothing in the library reads it:
{
"record": "449df4917431633dcff3175be2837109e4c5d161c42559696c6a8745beb023ec",
"rules": { "deal": "player 0,2 dealer 1,3", "dealer": "S17", "allowed": ["hit", "stand"] },
"actions": [
{ "position": 4, "action": "hit", "at": 1790000001000 },
{ "action": "stand", "at": 1790000004000 }
]
}
record is the first hash the example printed, the one taken before the seed was revealed. The two hashes differ because serverSeed is part of what gets hashed, so decide which form your transcript points at; the unrevealed one exists while the hand is being played. It ties the transcript to one deck. The rules are written out, or named by a version string you publish, so that someone replaying the hand a year later deals it the way you did. Each action is appended when it happens, not assembled afterwards. If you already sign records you can sign this too: sign(canonicalJson(transcript), secretKey) uses two functions the library exports.
What does that buy? A signed transcript proves the operator said this is what happened, and lets anyone check the dealer's draws against the published rules, since from the player's last action onward the dealer has no choices. It doesn't prove the player pressed the button the log says they pressed. Nothing in 0.1.0 has the player sign their actions, and without that a dispute over "I stood" against "you hit" is one account against another.
Withholding the Deck During a Hand
A Dice record can go to the player the moment the bet settles. It tells them nothing they haven't seen. A blackjack record's result is all 52 cards, and a player who receives it at the deal knows the dealer's hole card and every card they'd draw.
Galabet's own pages do exactly that, in two places. The practice table at /games/blackjack/ calculates the whole deck in your browser from the public seed and deals from it. The demo API's POST /api/demo/bet with game: "blackjack" answers with a record that already holds all 52 cards. Both are acceptable for one reason: no chips move. The demo's published rules list blackjack with settlement: false.
The same API shows the other way, in Mines. Starting a board derives the record and keeps it on the server. Every response has result stripped out until the board is over, and only then does the record enter the player's history. A staked blackjack needs that treatment and more, because the server also has to deal one card at a time, accept only legal actions and write the transcript. This project doesn't have that engine yet. There's no server-side concealed Blackjack here, and nothing on this page should be read as if there were.
What can the player hold during the hand? The commitment, their client seed and the nonce, fixed before the first card. After the hand, the deck. After the seed rotates, the server seed, and at that point the check runs. Revealing the full deck afterwards also exposes cards that were never dealt. That costs nothing when the next hand uses the next nonce, since that's a new shuffle.
And the limit that applies to every game in the single-player profile: the operator holds both seeds and can work out the deck before the player acts. The commitment stops them changing it. It doesn't stop them knowing it.
Quick Reference
| Game names | blackjack, and hilo, which is the same function. See Hi-Lo |
| Parameter | decks, a whole number from 1 to 8, default 1 |
| Result | An array of 52 × decks labels, in dealing order |
| Floats consumed | 52 × decks − 1 |
| Record cursor | 6 for one deck, 12 for two, 51 for eight |
| Test vectors | 224 in vectors/gfs-1.0.json |
| Not included | Dealing, scoring, legal actions, payouts, a transcript format |
Errors
Plain Error objects, no codes in 0.1.0. The seed and nonce messages are the same ones listed on the Dice page. These belong to the deck:
| Message | What happened |
|---|---|
decks must be 1 to 8 | Nine or more, a fraction, or a string such as "2" |
count must be a positive integer | decks: 0 passed to play. The float count is worked out first, comes to zero, and the derivation objects before the deck function is reached |
shuffle of 52 needs 51 floats, got 40 | You called blackjack(floats) directly with too few floats |
card index out of range | cardLabel was given something outside 0 to 51 |
import { inspectRecord, play } from '@galabet/fair';
import { blackjack, cardLabel } from '@galabet/fair/games';
const seeds = {
serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
clientSeed: 'galabet',
nonce: 42,
};
const { floats } = await play({ game: 'blackjack', ...seeds });
const attempts = [
() => play({ game: 'blackjack', ...seeds, params: { decks: 9 } }),
() => play({ game: 'blackjack', ...seeds, params: { decks: 0 } }),
() => blackjack(floats.slice(0, 40)),
() => cardLabel(52),
() => inspectRecord({ spec: 'GFS/1.0', profile: 'single-player', game: 'blackjack', params: { decks: 9 }, clientSeed: 'galabet', nonce: 42 }),
];
for (const attempt of attempts) {
try {
await attempt();
} catch (error) {
console.log(error.message);
}
}
decks must be 1 to 8
count must be a positive integer
shuffle of 52 needs 51 floats, got 40
card index out of range
decks: enter a whole number from 1 to 8.
The last line is inspectRecord, which is what the verifier page runs. It checks params before it calculates anything and words the complaint for a person filling in a form.
