Skip to content
Ludo Vest
Fair play

How provably fair dice work, and how to verify a Ludo Vest match yourself

Every Ludo Vest match commits to its dice before the first roll and reveals the seed at the end. Here is the whole scheme, why the rejection-sampling detail matters, and the exact steps to check a match on your own machine.

Muhammad Suleman9 min read
A four-player Ludo Vest board mid-match, with the dice button and turn timer visible.

Open the reviews for any large Ludo game and the same complaint is sitting at the top, written in a dozen languages: the dice change when you start winning. It is usually not true. It is also impossible to disprove, which is the real problem. Every studio replies "our random number generator is fair", and the player has no way to check, so the argument never ends.

Ludo Vest ends it a different way. Instead of asking to be trusted, the server commits to the entire dice sequence before the match starts, and hands you the proof afterwards. If we ever changed a roll, you could catch us — with a laptop and about fifteen lines of code.

The scheme in four steps

The whole thing rests on one property of a hash function: it is easy to go from a secret to its hash, and infeasible to go backwards or to find a second secret with the same hash. That lets us publish something that pins down a value without revealing it.

  1. At match start the server draws a fresh 32-byte random seed and publishes only SHA-256(seed). That hash is the commitment, and every player has it before a single token moves.
  2. Each roll is derived, not drawn: roll number n is HMAC-SHA256(seed, "<matchId>:<n>"), reduced to a face from 1 to 6. Nothing about the board, the players, the stakes or anyone's wallet is an input.
  3. At match end the server reveals the seed alongside the full list of rolls. Until then the seed is never exposed — the match endpoint refuses to return it while the match is still active.
  4. Afterwards you check it. Hash the revealed seed and compare it with the commitment you were given at the start; then recompute each roll from the seed and compare with what you were dealt.

The detail almost everybody gets wrong

You have a random byte, 0 to 255, and you want a number from 1 to 6. The obvious move is byte % 6 + 1. It is also slightly wrong, and this is the kind of bias no player would ever notice by feel.

256 is not divisible by 6. It is 42 sixes plus a remainder of 4. So the values 0, 1, 2 and 3 each appear 43 times in the range, while 4 and 5 appear 42 times. Map those through and faces 1 to 4 come out about 0.4% more often than 5 and 6 — every roll, forever, in the same direction.

// 252 is the largest multiple of 6 below 256, so bytes 252-255 are the
// leftovers that make the naive version unfair. Throw them away and take
// the next byte of the digest instead.
for (const byte of digest) {
  if (byte < 252) return (byte % 6) + 1;
}
Rejection sampling: discard the bytes that would skew the result.

That is the entire fix, and it makes all six faces exactly equally likely rather than nearly so. The distribution is asserted over 60,000 rolls in the test suite, so a future refactor cannot quietly reintroduce the skew.

Verifying a match, step by step

When a match finishes, its record becomes readable: GET /api/matches/:matchId returns the dice seed, the rolls and the event log once status is no longer active. You need three things from it — the seed, the commitment and the match id — plus the roll list.

1. Check the commitment

# The seed is hex; hash the raw bytes, not the hex string.
echo -n "<seed-hex>" | xxd -r -p | shasum -a 256
Does the revealed seed hash to the number published before the first roll?

If that output does not equal the commitment you were shown at match start, stop. The seed being revealed is not the seed that was committed to, and nothing else matters.

2. Recompute the rolls

import { createHash, createHmac } from 'node:crypto';

function valueAt(seed, matchId, index) {
  const digest = createHmac('sha256', seed)
    .update(`${matchId}:${index}`)
    .digest();
  for (const byte of digest) if (byte < 252) return (byte % 6) + 1;
  throw new Error('astronomically unlikely');
}

export function verify(seedHex, commitment, matchId, rolls) {
  const seed = Buffer.from(seedHex, 'hex');
  const committed =
    createHash('sha256').update(seed).digest('hex') === commitment;
  const consistent = rolls.every(
    (value, i) => valueAt(seed, matchId, i) === value,
  );
  return committed && consistent;
}
The verification path, in plain Node. No dependencies.

Both halves have to pass. The first says the seed is the one we promised; the second says the rolls really came from it. Together they say the sequence you played was decided before the match began.

3. Or let the server check it for you

There is a public endpoint that does exactly the above, so you can sanity-check your own implementation against ours. It takes no authentication, because there is nothing private about verifying a finished match.

POST /api/fairness/verify
{ "seed": "...", "commitment": "...", "matchId": "...", "rolls": [4, 6, 1, ...] }

-> { "valid": true }

What this does not fix

Honesty about the guarantee matters as much as the guarantee. Three things this scheme does not do:

  • It does not make the dice feel fair. Real randomness is streaky. Four sixes against you in a row is a completely ordinary event, and it will keep happening. Fair dice are, if anything, more infuriating than rigged ones, because nothing is smoothing them out on your behalf.
  • It does not verify a match while it is running. The seed cannot be revealed mid-match without telling everyone what they are about to roll. Verification is always after the fact.
  • It does not prove anything about the rest of the game. It proves the dice sequence. Move legality, payouts and rewards are separate promises, kept by the server validating every action rather than by cryptography.

The part that convinced us it was right

Undo is the clearest test. In most games of this genre, paying to rewind a roll feels like paying for a second draw from the bag — and if the bag were being refilled on the fly, that is exactly what it would be.

Here it cannot be. The sequence was fixed before the first roll, so an undo re-draws the next value from a list that already existed. What you are buying is a different move, not a different die. Once the commitment scheme is in place, that stops being a promise in the store description and becomes a fact about the arithmetic.

Which is the whole point. The Fair Play page has the shorter version if you want to send it to someone in your club.

Check it yourself

Free on Android, no account needed, and every match verifiable after it ends.

Get it onGoogle PlayDownload on theApp StoreComing soon