contents05 The provably fair spin
05, fairness & security
The provably fair spin
Every game is committed before the first click, mixed with seeds from your own browser, and revealed afterwards so you can re-derive and check it yourself.
on this page
A game of roulette is only fair if nobody can pick where the bullet is, and that includes us. RHR uses a commit and reveal scheme with a seed from every player mixed in. Before the first click the server publishes a sealed commitment. After the bang it reveals what was inside, and your browser checks that the two match and that the game really followed from them.
The five steps
- Commit. When the countdown starts, the server picks a secret server seed, 32 random bytes, and publishes its commit: a SHA-256 fingerprint that locks the seed in without revealing it. The fingerprint includes the game number, so it cannot be reused for another game.
- Add your seed. In the last 5 seconds of the countdown, your browser sends its own client seed, 16 to 32 random bytes made on your device. Every seated player does the same. The server does not broadcast client seeds before the game is over.
- Lock and mix. The window closes and the seat list locks. The server seed and every client seed are mixed into one key, and the game plays out from that key.
- Reveal. When the game ends, the server reveals the server seed and all the client seeds to the players who were dealt in. Spectators never get them. A seed re-derives the hidden state of the game, so it is only for people who played it.
- Check. Your browser compares the revealed seed with the commit it saw before the game, and with nothing else.
A sealed envelope, in plain words
Picture the commit as a sealed envelope that the server hands to the table before anyone clicks. Inside is a secret number. Then each player's browser tosses a random number of its own into the middle of the table. Only after the game does the server open the envelope.
The envelope was sealed before any of the tossed numbers existed, so the server could not have chosen its secret to suit them. The tossed numbers are random, so the server could not have guessed them. Neither side can steer the result alone.
Step 5 is deliberately stubborn. Your browser remembers the commit from before the game and checks against that. A commit that arrived together with the reveal would prove nothing, because a cheating server could simply send one that matches.
The recipe
serverSeed 32 random bytes from a CSPRNG, one per game
commit = SHA256( utf8("rhr/commit/v1") || serverSeed || u64be(gameNo) )
clientSeed 16 to 32 random bytes from your browser (empty if none arrives)
mix = SHA256( utf8("rhr/mix/v1") || serverSeed || u64be(gameNo) || u8(k)
|| for each dealt-in seat s, ascending:
u8(s) || u16be(len(clientSeed_s)) || clientSeed_s )
stream = HMAC-SHA256( key = mix, msg = utf8(label) || u32be(counter) )
uniform(label, n) an unbiased whole number in [0, n) read from the stream
startSeat(round) = alive[ uniform("rhr/start/<round>", alive.length) ]
bullet(round) = uniform("rhr/bullet/<round>", chambers)|| joins bytes. u8, u16be, u32be and u64be are fixed-width big-endian integers. k is the number of players dealt in, and gameNo ties a commit to one specific game. A seat that sent no seed still counts in the mix, with a length of zero.
How uniform picks a number
Read the stream block for counter 0. It is 32 bytes. Cut it into four 8-byte chunks and read each as a big-endian 64-bit number x. Accept the first x that is below limit = floor(2^64 / n) * n, and return x mod n. Throwing away the few values at the very top removes the small bias that a plain remainder would add, so every outcome is exactly equally likely. If all four chunks are thrown away, which is vanishingly rare, move on to counter 1.
What the randomness decides
For every round the mix decides two things and nothing else: which alive seat holds the gun first, and which chamber holds the bullet.
- The cylinder has exactly
nlive chambers, wherenis the number of players still alive in the round. Extra slots are plugged with gum (cartoon only). - The bullet sits in one chamber, picked uniformly by
bullet(round), and it is fixed before the first click. - Pull number
jin a round fires chamberj. It is a bang ifjequals the bullet's chamber and a click otherwise.
A real example
Here is one made-up game with real arithmetic. The seeds are invented for these docs. The hashes are not: every value below was computed twice, once with @noble/hashes and once with Node's built-in crypto, and the script further down reproduces them. It is a four-player game at seats 0, 2, 3 and 5, called Ada, Bo, Cy and Dee.
gameNo 48213
serverSeed d7e47f6f05c1cebdeb619e39bbab6e106f93bbb87d0dcca7353cff2f9fb0e317
clientSeed 0 6743704c1f2aa8a19e02f7a4ee0e99ab (Ada)
clientSeed 2 cdadbc5070a582539a18e9cafc21ed22 (Bo)
clientSeed 3 a9f2640aa370453b4d27717bf6ce5d35 (Cy)
clientSeed 5 2f197ec9aefbfe8693896c9a28ca3cb7 (Dee)The commit every player saw before the game is the hash of the server seed and the game number (000000000000bc55 is 48213 as eight bytes):
commit = SHA256( "rhr/commit/v1" || serverSeed || 000000000000bc55 )
= 0a49d7e28602f9bb6694706ea461385fe0be12f75d4b4b915b6cf1496b9832e3After the window closes, the four client seeds go into the mix in seat order, each with its seat number and its length:
mix = SHA256(
"rhr/mix/v1"
d7e47f6f05c1cebdeb619e39bbab6e106f93bbb87d0dcca7353cff2f9fb0e317 server seed
000000000000bc55 game number
04 players dealt in
00 0010 6743704c1f2aa8a19e02f7a4ee0e99ab seat 0, 16 bytes
02 0010 cdadbc5070a582539a18e9cafc21ed22 seat 2, 16 bytes
03 0010 a9f2640aa370453b4d27717bf6ce5d35 seat 3, 16 bytes
05 0010 2f197ec9aefbfe8693896c9a28ca3cb7 seat 5, 16 bytes
)
= dbd45b9c5c930bf8e0b309565049f5ff247746f443e97e04ca23bd61e922b201Round 0: who starts, and where is the bullet?
Four players are alive, so the cylinder has four live chambers and n = 4. The start seat uses the label rhr/start/0:
HMAC-SHA256( key = mix, msg = "rhr/start/0" || 00000000 )
= 871f441b2efbc685157d4a9ad4b1c3f6f51bd555ca410ec301e8df2860a2c8ce
first 8 bytes 871f441b2efbc685 = 9736575802941359749
limit floor(2^64 / 4) * 4 = 2^64, so nothing is thrown away
9736575802941359749 mod 4 = 1 -> alive[1] = seat 2, so Bo holds the gun firstThe bullet uses the label rhr/bullet/0:
first 8 bytes 2b1b71859512841f = 3106201186547696671
3106201186547696671 mod 4 = 3 -> the bullet is in chamber 3, the last of the fourPull 0 fires chamber 0, pull 1 fires chamber 1, and so on, so the bang comes on pull 3. Who makes pull 3 depends on how many clicks each player takes. The whole game, round by round, is in Classic and Last Hood Standing.
Check it yourself
This script needs only Node 18 or newer and no packages. Save it as verify.mjs and run node verify.mjs. It recomputes the commit, the mix, and round 0's start seat and bullet from the inputs above.
import { createHash, createHmac } from 'node:crypto';
const serverSeed = 'd7e47f6f05c1cebdeb619e39bbab6e106f93bbb87d0dcca7353cff2f9fb0e317';
const gameNo = 48213n;
const seats = [
{ seat: 0, seed: '6743704c1f2aa8a19e02f7a4ee0e99ab' },
{ seat: 2, seed: 'cdadbc5070a582539a18e9cafc21ed22' },
{ seat: 3, seed: 'a9f2640aa370453b4d27717bf6ce5d35' },
{ seat: 5, seed: '2f197ec9aefbfe8693896c9a28ca3cb7' },
]; // ascending seat order
const text = (s) => Buffer.from(s, 'utf8');
const hex = (s) => Buffer.from(s, 'hex');
const u8 = (n) => Buffer.from([n]);
const u16 = (n) => { const b = Buffer.alloc(2); b.writeUInt16BE(n); return b; };
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n); return b; };
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(n); return b; };
const sha256 = (...parts) => createHash('sha256').update(Buffer.concat(parts)).digest();
// 1. The commitment you saw before the game.
const commit = sha256(text('rhr/commit/v1'), hex(serverSeed), u64(gameNo));
// 2. Everyone's randomness, mixed into one key.
const mix = sha256(
text('rhr/mix/v1'), hex(serverSeed), u64(gameNo), u8(seats.length),
...seats.flatMap(({ seat, seed }) => [u8(seat), u16(seed.length / 2), hex(seed)]),
);
// 3. An unbiased number in [0, n) from the keyed stream.
function uniform(label, n) {
const limit = (2n ** 64n / BigInt(n)) * BigInt(n);
for (let counter = 0; ; counter++) {
const block = createHmac('sha256', mix).update(Buffer.concat([text(label), u32(counter)])).digest();
for (let i = 0; i < 32; i += 8) {
const x = block.readBigUInt64BE(i);
if (x < limit) return { x, value: Number(x % BigInt(n)) };
}
}
}
console.log('commit', commit.toString('hex'));
console.log('mix ', mix.toString('hex'));
const start = uniform('rhr/start/0', seats.length);
const bullet = uniform('rhr/bullet/0', seats.length);
console.log('start x =', start.x.toString(), '-> index', start.value, '-> seat', seats[start.value].seat);
console.log('bullet x =', bullet.x.toString(), '-> chamber', bullet.value);It prints:
commit 0a49d7e28602f9bb6694706ea461385fe0be12f75d4b4b915b6cf1496b9832e3
mix dbd45b9c5c930bf8e0b309565049f5ff247746f443e97e04ca23bd61e922b201
start x = 9736575802941359749 -> index 1 -> seat 2
bullet x = 3106201186547696671 -> chamber 3The checks are exact. If your numbers differ from these, the inputs differ.
Verify a real game in the app
- Open a recent game in your history on your profile page and choose verify, or paste a game record into the stand-alone verifier.
- The verifier re-derives the commit, the mix and every round's start seat and bullet. It replays the recorded clicks and passes, then compares the result with what you were paid.
- It checks the commit your browser saw before the game, not one handed back with the reveal.
- Your last 500 games stay verifiable. Games older than that stop being verifiable.
- The sample game above is also available as a ready-to-paste record, with its full walkthrough, in Classic and Last Hood Standing.
- Verifying 25 of your own games earns the Verified achievement.
What this proves, and what it does not
It proves that the server could not change its seed after seeing yours, that each round's start seat and bullet follow from the seeds by the recipe above, and that the recorded clicks and passes replay to the recorded result.
It does not prove everything, and you should know exactly where the edges are:
- The server can abort a game. If it hits a fault, it can abort. That refunds everyone and costs nobody any money. It also cancels whatever result was coming, and a server that knew the outcome in advance and chose its aborts could, in principle, use that. It is part of the same collusion assumption below.
- Collusion between the server and a player is a trust assumption. Once the seed window closes, the server holds every input, so it knows how the game will go before the first click. If it colluded with one player and told them, nothing in this protocol would catch it. You are trusting that it does not. There are no house bots at real-money tables, so the house is never secretly a player either.
- Per-game commitments are not anchored on-chain. The server publishes them and your browser checks them. Only deposits, withdrawals, settlement checkpoints and jackpot payouts touch the chain. So nobody can look up on-chain what the server committed to, and a commit is only as good as your own copy of it. Keep your receipts.
- A missing seed costs you your say. If your seed does not arrive in time, your seat counts with an empty one. The game still runs and can still be verified, but you gave up your own contribution to the randomness.
- The verifier shares code with the server. Both run the same rules engine, so a bug in that engine would be shared. The recipe above is short on purpose. Re-implement it, or run the script, and you get a check that shares no code with us.
- Fair is not the same as safe. A fair spin does not make the contracts audited, and it does not make winning likely. See Custody and your money.