Fair dice you can check

Every roll in Ludo Circle comes from a secret that is locked before the first roll. After a game, paste its game code here and your browser replays every roll.

Where to find a game code

At the end of a game, open Prove the dice on the result screen and tap Verify this game (it opens this page with the code filled in) or Copy game code. The code holds the dice secret of the game and the list of rolls and moves. It holds no names, no room code and nothing about you or your phone. On this page the code stays in your browser: it is checked here and is never sent to us or anyone else.

How the dice work

Against the computer, and pass and play

  1. When a game starts, your phone draws a 32-byte secret from its secure random number generator (the one used for encryption).
  2. It shows the dice lock in the game menu before the first roll: the SHA-256 hash of the secret. The lock can be checked against the secret later, but the secret cannot be worked out from the lock.
  3. The dice seed is SHA-256 of the text ludo-local: followed by the secret.
  4. Roll number n is HMAC-SHA256(seed, chain + n + attempt). The first byte below 252 gives the face: byte mod 6, plus 1. Bytes 252 to 255 are skipped, so each face has exactly the same chance, 1 in 6.
  5. After every move the chain becomes SHA-256(chain + seat + pawn), so each roll also depends on the moves played before it.
  6. After the game the secret goes into the game code, and anyone can replay every roll.

This proves the dice were fixed before the first roll and never changed during the game: no catch-up luck for whoever is behind, and no help for the computer. The computer players never read the seed; they see a roll only after it is thrown, like you.

Rooms with friends

  1. In the lobby every player's phone makes its own secret and publishes only its lock (the host's phone does this for the computer seats).
  2. When the host starts the game, every secret is revealed, and every phone checks each one against its lock. If one does not match, the game stops before the first roll.
  3. The seed is SHA-256 of all the secrets together, in seat order, so no single player can choose it, the host included.
  4. The rolls then follow the same formula as above. Every phone computes them itself and they must agree.

The daily challenge

Everyone gets the same dice on the same day. They come from the date alone: the first 32 bits of SHA-256 of ludo-daily-YYYY-MM-DD seed a Mulberry32 generator, and each roll is the next number times 6, divided by 232, plus 1.

What a check proves, and what it does not

  • Every roll can be checked after the game.
  • Nobody can pick the dice in advance.
  • The computer gets no help and no look-ahead.
  • There is no catch-up luck for whoever is behind.

To be exact about the limits: in a room with friends, once the secrets are revealed at the start, a modified app could work out the next roll of the player on turn (the chain over the moves keeps it to that one roll). A player who refuses to reveal stops the game, and could leave and open a new room. In a game on one phone, the phone makes the secret, so the check shows that the dice were fixed before the first roll and never changed afterwards; the secret itself comes from the phone's secure random generator.

The game code

LC1-<mode>-<key>-<events>. The mode is c (against the computer), p (pass and play), o (a room with friends: the secrets in seat order, joined by dots) or d (the daily challenge: the date as YYYYMMDD). The events are the game in order: a digit 1 to 6 is a roll as the app showed it, and a letter a to p is a move, seat × 4 + pawn, counted from a.

How we test the dice

The game's test suite throws 600,000 rolls and checks with a chi-square test that every face comes up equally often. Further tests check that a game code from a real game replays roll for roll, that a game picked up again after the app was closed carries on with exactly the same dice, and that a single changed roll or move is caught. The checker on this page is tested against the same test code.

The dice code

This is the dice module of Ludo Circle, lib/online/fair.dart, exactly as it is built into the app. The checker above is this site's own script, which runs in your browser and can be read with View source.

import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';

import 'package:crypto/crypto.dart';

import '../engine/dice.dart';
import '../engine/game.dart';

/// Fair dice without a server: commit-reveal.
///
/// 1. In the lobby every player commits to a secret: they publish
///    sha256(secret) and keep the secret.
/// 2. When the game starts everybody reveals their secret. Every client checks
///    sha256(reveal) against the commit made earlier. A player cannot change
///    their secret after seeing the others, and nobody can pick the seed.
/// 3. seed = sha256(secret of seat 0 + secret of seat 1 + ...) in seat order.
/// 4. Roll n is drawn from HMAC-SHA256(seed, history + n) with rejection
///    sampling, so every face has probability exactly 1/6. The history part is
///    a hash chain over the moves made so far, so the next roll also depends on
///    what was just played. Every client replays the same rolls.
///
/// Honest limits: once the secrets are revealed a modified client could read
/// the sequence ahead; the chain over the moves keeps that to one step for
/// the player to move. A player who refuses to reveal stops the game
/// (the room is aborted), they cannot steer it.
///
/// Games on one phone (vs the computer, pass and play; since 1.3.0) use the
/// same dice with one secret made by the phone when the game starts:
/// the dice lock sha256(secret) is shown in the game menu before the first
/// roll, the seed is [localSeed] (so the lock never gives the seed away), and
/// after the game the secret is in the game code (lib/app/game_code.dart) that
/// https://mirzagames.com/ludo/fair-dice/ replays. It proves the dice were
/// fixed before the first roll and never changed during the game: no catch-up
/// luck, no help for the computer (which never reads the seed).

String bytesToHex(List<int> b) => [for (final x in b) x.toRadixString(16).padLeft(2, '0')].join();

Uint8List hexToBytes(String h) {
  final out = Uint8List(h.length ~/ 2);
  for (var i = 0; i < out.length; i++) {
    out[i] = int.parse(h.substring(i * 2, i * 2 + 2), radix: 16);
  }
  return out;
}

bool isHex64(String s) => RegExp(r'^[0-9a-f]{64}$').hasMatch(s);

String sha256Hex(List<int> bytes) => sha256.convert(bytes).toString();

/// A player's secret and the commitment published for it.
class Commitment {
  Commitment(this.secretHex) : commitHex = commitOf(secretHex);
  final String secretHex;
  final String commitHex;

  factory Commitment.generate([Random? rng]) {
    final r = rng ?? Random.secure();
    return Commitment(bytesToHex([for (var i = 0; i < 32; i++) r.nextInt(256)]));
  }

  static String commitOf(String secretHex) => sha256Hex(hexToBytes(secretHex));
}

/// Does [revealHex] open the commitment [commitHex]?
bool verifyReveal(String commitHex, String revealHex) =>
    isHex64(revealHex) && isHex64(commitHex) && Commitment.commitOf(revealHex) == commitHex;

/// The room seed from the revealed secrets in seat order.
String deriveSeed(List<String> secretsInSeatOrder) {
  final all = <int>[];
  for (final s in secretsInSeatOrder) {
    all.addAll(hexToBytes(s));
  }
  return sha256Hex(all);
}

/// The dice seed of a game on one phone: sha256("ludo-local:" + secret bytes).
String localSeed(String secretHex) => sha256Hex([...utf8.encode('ludo-local:'), ...hexToBytes(secretHex)]);

/// Dice that every client can compute and check: see the library comment.
class FairDice implements Dice {
  FairDice(this.seedHex)
      : _key = hexToBytes(seedHex),
        _chain = Uint8List(0);
  final String seedHex;
  final Uint8List _key;
  Uint8List _chain;

  /// How many rolls have been drawn.
  int count = 0;

  @override
  int roll() => rollAt(count++);

  /// The roll with index [n] for the current history (does not advance).
  int rollAt(int n) {
    for (var attempt = 0;; attempt++) {
      final msg = <int>[..._chain, (n >> 24) & 255, (n >> 16) & 255, (n >> 8) & 255, n & 255, attempt & 255, (attempt >> 8) & 255];
      final digest = Hmac(sha256, _key).convert(msg).bytes;
      for (final b in digest) {
        // 252 = 6 * 42: bytes 252..255 are rejected so the result is unbiased.
        if (b < 252) return b % 6 + 1;
      }
    }
  }

  /// Binds the following rolls to the move just made.
  void noteMove(int seat, int token) {
    _chain = Uint8List.fromList(sha256.convert([..._chain, seat, token]).bytes);
  }

  /// The dice of a game already under way: [log] (a [LudoGame.log] of [config])
  /// is replayed so the roll count and the move chain are where the game is.
  /// The seat noted for a move is the seat of [LudoGame.actor] (the pawns' owner).
  static FairDice atLog(String seedHex, GameConfig config, List<int> log) {
    final d = FairDice(seedHex);
    final g = LudoGame(config, recordLog: false);
    for (final a in log) {
      if (g.isOver) break;
      if (a >= 10) {
        final seat = g.seatOf(g.actor);
        g.applyMove(a - 10);
        d.noteMove(seat, a - 10);
      } else {
        g.applyRoll(a);
        d.count++;
      }
    }
    return d;
  }

  /// A copy at the same position (for tests and look-ahead checks).
  FairDice copy() => FairDice(seedHex)
    ..count = count
    .._chain = Uint8List.fromList(_chain);
}

Ludo Circle · Ludo Circle privacy policy