DocsGames

Dice

Where a Dice roll comes from, how to check one you were given, and how to run the same calculation in your own code.

The Dice mapper turns the first float of a round into a roll from 0.00 to 100.00. With the three inputs below the roll is 56.12, and it's 56.12 every time, on any machine, in any language that has HMAC-SHA256. Nothing is drawn when the player clicks. The roll was settled the moment the inputs were.

InputValue
Server seed5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d
Client seedgalabet
Nonce42

If someone handed you a roll and you only want to know whether it's genuine, you don't need this page. Paste the record into the verifier. It runs in your browser and uploads nothing. What follows is for people who want to see the working, or write it themselves.

How a Roll Is Calculated

steps.mjs
import { deriveDigest, digestToFloats, toHex } from '@galabet/fair';
import { dice } from '@galabet/fair/games';

const seeds = {
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  clientSeed: 'galabet',
  nonce: 42,
};

const digest = await deriveDigest(seeds, 0);
console.log(toHex(digest));
console.log([...digest.slice(0, 4)]);

const [float] = digestToFloats(digest);
console.log(float);
console.log(float * 10001);
console.log(dice(float));
Output
8fa8eae01ccd43120b5028acd55241e63f01343d81935389da84578897a5fe39
[ 143, 168, 234, 224 ]
0.5611712262034416
5612.27343326062
56.12

The last line is the roll. The long hex string is an HMAC-SHA256 digest. Its key is the server seed. Its message is galabet:42:0, which is the client seed, the nonce and a cursor joined by colons. Dice never needs a second digest, so for this game that trailing 0 doesn't move.

Be careful with the key. It's the 64-character hex string itself, as text, and not the 32 bytes that string decodes to. GFS does it this way on purpose, because the casinos already running this scheme key it with the string and the spec was written to describe them. Decode the seed first and you get 52.21 where you wanted 56.12. There's a runnable example of that further down.

Only the first four bytes matter to Dice: 143, 168, 234, 224. Read them as one big-endian 32-bit number, which is 2,410,212,064, divide by 2³², and you have 0.5611712262034416. A double stores any 32-bit integer exactly, so no rounding is hiding in that float. The digest has room for eight of these. Dice takes one and ignores seven.

Then multiply by 10001, floor, divide by 100. 5612.27 floors to 5612, and 5612 hundredths is 56.12. Floor, never round. A product of 5612.99 is still 56.12.

Range and Distribution

The multiplier is 10001 and not 10000 because a float never reaches 1. Multiply by 10000 and the best you can do is 99.99, so 100.00 could never come up, and a game that advertises 0 to 100 would be promising a roll it can't deliver. With 10001 there are 10,001 results and both ends are reachable.

edges.mjs
import { dice } from '@galabet/fair/games';

console.log(dice(0));
console.log(dice(0.5));
console.log(dice(0.9999));
console.log(dice(1 - 2 ** -32)); // the largest float the derivation can produce
Output
0
50
99.99
100

Is it even? Nearly, and the "nearly" is tiny. 4,294,967,296 floats don't divide cleanly into 10,001 rolls, so some rolls have 429,454 floats behind them and the rest have 429,453. 0.00 is in the first group and 100.00 is in the second. That's one part in 429,453. Nobody can bet on it.

If you'd rather see it than take arithmetic on trust, roll a few thousand nonces and count them. This project picked up that habit the hard way. An early version of the crash formula paid a hundred times too much and its unit test passed, because the test carried the same mistake. A histogram is what caught it.

histogram.mjs
import { play } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const tens = new Array(10).fill(0);

for (let nonce = 0; nonce < 20000; nonce++) {
  const { result } = await play({ game: 'dice', serverSeed, clientSeed: 'galabet', nonce });
  tens[Math.min(9, Math.floor(result / 10))]++;
}

tens.forEach((count, i) => console.log(`${String(i * 10).padStart(2)}+  ${'#'.repeat(Math.round(count / 100))} ${count}`));
Output
 0+  #################### 1980
10+  #################### 1965
20+  #################### 1968
30+  ##################### 2052
40+  ##################### 2063
50+  #################### 2012
60+  #################### 1953
70+  #################### 2011
80+  #################### 2028
90+  #################### 1968

Twenty thousand rolls, ten bins, about two thousand in each. The biggest gap from 2,000 is 63, and for bins this size chance alone gives you swings of about 40, so that's ordinary. A bin sitting at 200 would be the thing to worry about.

Using play

play does all of the above in one call.

roll.mjs
import { play } from '@galabet/fair';

const round = await play({
  game: 'dice',
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  clientSeed: 'galabet',
  nonce: 42,
});

console.log(round);
Output
{ result: 56.12, cursor: 0, floats: [ 0.5611712262034416 ] }

result is the roll, as a number. cursor says how far into the digest stream the round had to read, which for Dice is always 0. floats is there so you can show your working, the way the interactive Dice game does. The call is async because Web Crypto is. It keeps no state.

Bets under one pair of seeds are told apart by the nonce. It starts at 0 and climbs by one per bet.

sequence.mjs
import { play } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';

for (let nonce = 0; nonce <= 5; nonce++) {
  const { result } = await play({ game: 'dice', serverSeed, clientSeed: 'galabet', nonce });
  console.log(nonce, result, result.toFixed(2));
}
Output
0 53.14 53.14
1 50.77 50.77
2 71.31 71.31
3 54.7 54.70
4 95.48 95.48
5 31.35 31.35

See nonce 3? 54.7, not 54.70. It's a number, and numbers don't keep trailing zeros. Use toFixed(2) when you show a roll to somebody. Store and compare the number.

client-seed.mjs
import { play } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';

for (const clientSeed of ['galabet', 'Galabet', 'my-new-seed']) {
  const { result } = await play({ game: 'dice', serverSeed, clientSeed, nonce: 42 });
  console.log(clientSeed, result);
}
Output
galabet 56.12
Galabet 34.36
my-new-seed 61.51

Case counts. So does everything else in that string. And when a player changes their client seed, give them a fresh server seed and put the nonce back to 0. Keep the old server seed and they can switch back to a client seed they've used before, then replay rolls they have already seen. It's the same reason a nonce is never reused: identical inputs, identical roll, and the player knows it before betting.

Settling Bets

Target, direction, stake, payout. None of it is in GFS and none of it is in the library. You get a roll, and what that roll is worth is your game's business. Whatever rules you choose, publish them. These are the ones Galabet's demo plays by:

RuleDemo behaviour
OverWins when roll > target
UnderWins when roll < target
Roll equals targetLoses, in both directions
Target range2.00 to 98.00, two decimals
House edge1%, declared, applied to the multiplier
Returned creditsfloor(stake × multiplier), whole credits only

Equality losing both ways is where mental arithmetic slips. Over 50.00 wins on 50.01 through 100.00. Count them. That's 5,000 rolls out of 10,001, or 49.995%, and not a half.

settle.mjs
const HOUSE_EDGE = 0.01;

function winningRolls({ target, over }) {
  const hundredths = Math.round(target * 100);
  return over ? 10000 - hundredths : hundredths;
}

function settle(roll, bet) {
  const win = bet.over ? roll > bet.target : roll < bet.target;
  const chance = winningRolls(bet) / 10001;
  const multiplier = Math.floor(((1 - HOUSE_EDGE) / chance) * 10000) / 10000;
  const returned = win ? Math.floor(bet.stake * multiplier) : 0;
  return { win, multiplier, returned, net: returned - bet.stake };
}

console.log(settle(56.12, { target: 50, over: true, stake: 25 }));
console.log(settle(56.12, { target: 50, over: false, stake: 25 }));
console.log(settle(50, { target: 50, over: true, stake: 25 }));
console.log(settle(99.2, { target: 98, over: true, stake: 25 }));
Output
{ win: true, multiplier: 1.9801, returned: 49, net: 24 }
{ win: false, multiplier: 1.9801, returned: 0, net: -25 }
{ win: false, multiplier: 1.9801, returned: 0, net: -25 }
{ win: true, multiplier: 49.5049, returned: 1237, net: 1212 }

Third line: roll 50, target 50, bet lost.

One wrinkle. settle counts rolls as if all 10,001 were equally likely, and they're off by a hair, as the section above showed. The demo server counts floats instead, which is exact. We ran both methods over every target from 2.00 to 98.00 in both directions, 19,202 cases in all. They agree on 19,132. The other 70 differ by 0.0001, so an under bet at 2.19 pays 45.21 one way and 45.2099 the other. Pick a method, write it down, and test against it.

Keep the bet with the record. 56.12 on its own can't tell a support agent whether the player won.

Records

A record is everything needed to work the round out again later. Write it when the bet is placed.

While the seed is still in use
{
  "spec": "GFS/1.0",
  "profile": "single-player",
  "game": "dice",
  "params": {},
  "commitment": "ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7",
  "clientSeed": "galabet",
  "nonce": 42,
  "cursor": 0,
  "result": 56.12,
  "at": 1790000000000
}

Two fields never change for Dice: params is {} and cursor is 0. serverSeed is missing on purpose, and appears only after the seed has been rotated out. at is Unix milliseconds. commitment is the SHA-256 of the seed string, and it only means something if the player could see it before they bet.

Write result as a bare JSON number. 54.7. Not 54.70, and not "54.70". Record hashes are taken over canonical JSON, which prints a number in its shortest form, so a port that hashes 54.70 gets a different record hash from the reference for the same round.

play ignores parameters it doesn't use, so params: { houseEdge: 0.01 } still rolls 56.12. Don't. The extra field changes the record hash, and inspectRecord refuses the record outright with params.houseEdge: not supported for dice.

Verifying a Record

verify.mjs
import { commit, play, verifyRecord } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const { result, cursor } = await play({ game: 'dice', serverSeed, clientSeed: 'galabet', nonce: 42 });

const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
  serverSeed, commitment, clientSeed: 'galabet', nonce: 42, cursor, result, at: 0,
};

const good = await verifyRecord(record);
console.log(good.ok, good.computed, good.reasons);

const changed = await verifyRecord({ ...record, result: 12.34 });
console.log(changed.ok, changed.computed, changed.reasons);

const { serverSeed: hidden, ...unrevealed } = record;
const early = await verifyRecord(unrevealed);
console.log(early.ok, early.computed, early.reasons);
Output
true 56.12 []
false 56.12 [ 'result does not match seeds' ]
false null [ 'server seed not revealed yet; verify after rotation' ]

Once the seed is revealed you add it to the record and verifyRecord does three things: checks the seed against the commitment, recalculates the roll, compares the cursor. ok is true only when reasons is empty. That last case isn't a bad round. There's no serverSeed in it yet, so there is nothing to check, and the function says so.

verifyRecord trusts the shape of what you hand it. For a record a stranger pasted into a form, use inspectRecord. It validates first, caps the input at 64 KB, and reports each check on its own line. The verifier page is this function with a form around it.

inspect.mjs
import { commit, inspectRecord } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);

const report = await inspectRecord({
  spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
  serverSeed, commitment, clientSeed: 'galabet', nonce: 42, cursor: 0, result: 12.34, at: 0,
});

console.log(report.status);
for (const check of report.checks) console.log(`${check.name}: ${check.state}`);
console.log(report.difference);
Output
mismatch
Commitment: matches
Cursor: matches
Signature: not-provided
Outcome: mismatch
result: recorded 12.34, calculated 56.12.

Complete Round Example

One file, session held in memory, nothing else running.

round.mjs
import { commit, createClientSeed, createServerSeed, play, verifyRecord } from '@galabet/fair';

// Before the first bet. The player sees the commitment, never the seed.
const serverSeed = await createServerSeed();
const { commitment } = await commit(serverSeed);
const session = { serverSeed, commitment, clientSeed: await createClientSeed(), nonce: 0 };

// Each bet takes the next nonce and stores a record without the seed.
async function bet() {
  const nonce = session.nonce++;
  const { result, cursor } = await play({
    game: 'dice',
    serverSeed: session.serverSeed,
    clientSeed: session.clientSeed,
    nonce,
  });
  return {
    spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
    commitment: session.commitment, clientSeed: session.clientSeed,
    nonce, cursor, result, at: Date.now(),
  };
}
const records = [await bet(), await bet(), await bet()];

// Rotation. The old seed becomes public and a new one takes over.
const revealed = session.serverSeed;
session.serverSeed = await createServerSeed();
session.commitment = (await commit(session.serverSeed)).commitment;
session.nonce = 0;

// Anyone holding a record and the revealed seed can check it.
for (const record of records) {
  const { ok } = await verifyRecord({ ...record, serverSeed: revealed });
  console.log(`nonce ${record.nonce}: ${ok}`);
}
Output
nonce 0: true
nonce 1: true
nonce 2: true

The rolls are random here, so the example prints only what doesn't change between runs.

That's honest, but it isn't production. session.nonce++ is safe in one process and unsafe in two, because two bets can read the same value before either writes it back. Galabet's demo API hands that job to a Redis INCR. You'll also want to store when each commitment was published, since a commitment the player couldn't have seen proves nothing. And hang on to revealed seeds for as long as you keep the records they belong to.

Implementing Without the Library

You need HMAC-SHA256 and integer arithmetic. That's the entire dependency list.

dice-node.mjs
import { createHmac } from 'node:crypto';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';

const digest = createHmac('sha256', serverSeed).update('galabet:42:0').digest();
const value = digest.readUInt32BE(0);

console.log(value);
console.log(Math.floor((value / 2 ** 32) * 10001) / 100);
Output
2410212064
56.12
dice.py
import hashlib
import hmac

server_seed = "5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"

# The key is the hex string itself. Do not call bytes.fromhex() on it.
digest = hmac.new(server_seed.encode(), b"galabet:42:0", hashlib.sha256).digest()
value = int.from_bytes(digest[:4], "big")

print(value)
print((value * 10001 // 2**32) / 100)
Output
2410212064
56.12

The Python stays in integers until the final division. value * 10001 // 2**32 is the floor with no floating point anywhere near it, and if your language has big integers, that's the form to copy.

Common Porting Error: Decoded Seed

wrong-key.py
import hashlib
import hmac

server_seed = "5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"

digest = hmac.new(bytes.fromhex(server_seed), b"galabet:42:0", hashlib.sha256).digest()
print((int.from_bytes(digest[:4], "big") * 10001 // 2**32) / 100)
Output
52.21

Same seed, same message, one call to bytes.fromhex that shouldn't be there.

Test Vectors

One round matching proves little. vectors/gfs-1.0.json in the repository has 112 Dice rounds with their expected rolls, including an all-zero seed, an all-f seed, a client seed one character long, another with spaces in it, and nonces up to 65,535. Run yours over the lot.

dice-vectors.mjs
import { readFile } from 'node:fs/promises';
import { createHmac } from 'node:crypto';

function myDice({ serverSeed, clientSeed, nonce }) {
  const digest = createHmac('sha256', serverSeed).update(`${clientSeed}:${nonce}:0`).digest();
  return Math.floor((digest.readUInt32BE(0) / 2 ** 32) * 10001) / 100;
}

const { games } = JSON.parse(await readFile('vectors/gfs-1.0.json', 'utf8'));
const vectors = games.filter((vector) => vector.game === 'dice');
const failed = vectors.filter((vector) => myDice(vector) !== vector.result);

console.log(`${vectors.length - failed.length} of ${vectors.length} Dice vectors match`);
Output
112 of 112 Dice vectors match

Errors

Plain Error objects with a message. There are no error codes in 0.1.0, and that's a gap, because matching on message text breaks the day a message is reworded. Until codes exist, validate before you call. assertServerSeed, assertClientSeed and isGameName are exported for it.

MessageWhat happened
server seed must be 64 lowercase hex charactersWrong length, a stray character, or capital A to F. Lowercase it if it came from a form
client seed must be 1 to 64 charactersEmpty, or too long
client seed must not contain ":"The colon separates the parts of the HMAC message, so a seed can't carry one
nonce must be a non-negative integerNegative, fractional, or a string such as "42"
unknown game "…"Not one of the nine seed-based names
errors.mjs
import { assertClientSeed, isGameName, play } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';

try {
  await play({ game: 'dice', serverSeed, clientSeed: 'galabet', nonce: '42' });
} catch (error) {
  console.log(error.message);
}

try {
  assertClientSeed('lucky:seven');
} catch (error) {
  console.log(error.message);
}

console.log(isGameName('dice'), isGameName('craps'));
Output
nonce must be a non-negative integer
client seed must not contain ":" (reserved as the HMAC message separator)
true false

FAQ

Can the Casino Choose My Roll?

Not once the commitment is published. Swapping the server seed afterwards would break the hash the player is already holding. What the casino can do is know the upcoming rolls, since it holds both seeds. GFS 1.0 doesn't fix that. It's written down as a limit of the scheme, and the planned beacon extension is aimed at it.

Does a Verified Roll Mean the Site Is Honest?

It means this roll came from seeds committed beforehand. It says nothing about payouts, withdrawals, or whether the advertised return is real.

My Implementation Returns a Different Roll

  1. 52.21 for the public inputs? You decoded the seed. The key is the hex string as text.
  2. The message is clientSeed:nonce:cursor, cursor included. For Dice it ends in :0.
  3. The nonce is plain decimal digits. 42, not 042, not 42.0.
  4. Big-endian. The first byte is the most significant.
  5. Floor at the end. Rounding is wrong about half the time, which makes it look like it's working.

Two Bets Returned the Same Roll

Same server seed, same client seed, same nonce. Either the nonce isn't advancing or two requests took it at the same moment.

A Client Seed with Emoji Is Rejected as Too Long

The 64 limit counts UTF-16 code units, which is what JavaScript's length returns, and most emoji are two. We found this by running it. The written summary of the spec says "1 to 64 characters" and doesn't say which kind. A port has to count the same way, or it'll accept seeds the reference refuses.

Can a Roll Be Exactly 0.00 or 100.00?

Both happen, about once in 10,001 rolls each. With targets limited to 2.00 through 98.00, neither end decides a bet any differently from its neighbours.