skip to content
rhr
demo
contents05 The provably fair spin
rhr — documentation

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 n live chambers, where n is 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 j in a round fires chamber j. It is a bang if j equals 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 )
       = 0a49d7e28602f9bb6694706ea461385fe0be12f75d4b4b915b6cf1496b9832e3

After 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
)
= dbd45b9c5c930bf8e0b309565049f5ff247746f443e97e04ca23bd61e922b201

Round 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 first

The 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 four

Pull 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.

verify.mjs · js
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:

output
commit 0a49d7e28602f9bb6694706ea461385fe0be12f75d4b4b915b6cf1496b9832e3
mix    dbd45b9c5c930bf8e0b309565049f5ff247746f443e97e04ca23bd61e922b201
start  x = 9736575802941359749 -> index 1 -> seat 2
bullet x = 3106201186547696671 -> chamber 3

The 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.