DonutDozen

Provably Fair

You don't have to trust us. Every result is calculated from seeds that neither side can change once a bet is placed, and you can recalculate any round yourself.

1

We commit

Before you bet, we generate a secret server seed and show you its SHA-256 hash. That locks it in: a different seed would have a different hash.

2

You add randomness

Your client seed goes into every result, and you can change it anytime. The nonce goes up by one per bet, so no two bets match.

3

You verify

When you rotate your seed, we reveal the old server seed. Check that it matches the hash, then recalculate your bets below.

Verify a round

This runs in your browser, using the same code the server uses to settle bets.

Fill in the details and press Verify.

The algorithm

Random numbers come from HMAC_SHA256(serverSeed, clientSeed:nonce:cursor). The cursor supplies more numbers to games that need them.

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

// We show you sha256(serverSeed) before you bet, and reveal serverSeed when you rotate.
const commitment = createHash("sha256").update(serverSeed).digest("hex");

function floats(serverSeed, clientSeed, nonce, count) {
  const out = [];
  for (let cursor = 0; out.length < count; cursor++) {
    const bytes = createHmac("sha256", serverSeed)
      .update(`${clientSeed}:${nonce}:${cursor}`)
      .digest();
    // Each 32-byte block gives 8 floats, at 4 bytes per float.
    for (let i = 0; i < 32 && out.length < count; i += 4) {
      out.push(
        bytes[i] / 256 +
        bytes[i + 1] / 256 ** 2 +
        bytes[i + 2] / 256 ** 3 +
        bytes[i + 3] / 256 ** 4
      );
    }
  }
  return out;
}

How each game uses them

// Dice: 0.00 to 100.00
const roll = Math.floor(floats(...seeds, 1)[0] * 10001) / 100;

// Limbo: floored to 2 decimals, never below 1.00x or above 1,000,000x
const limbo = Math.floor(((1 - edge) / floats(...seeds, 1)[0]) * 100) / 100;

// Coinflip
const side = floats(...seeds, 1)[0] < 0.5 ? "heads" : "tails";

// Mines: draw without replacement from the 25 tiles, one float per mine
const tiles = [...Array(25).keys()];
const mines = floats(...seeds, mineCount).map(
  (float, drawn) => tiles.splice(Math.floor(float * (25 - drawn)), 1)[0]
);

// Plinko: one float per row, below 0.5 goes left. Rights counted gives the bucket
const bucket = floats(...seeds, rows).filter((float) => float >= 0.5).length;

// Roulette: one of the 37 pockets
const number = Math.floor(floats(...seeds, 1)[0] * 37);

// Paper Game: two floats per draw, player then house, until they differ
const draw = (float) => Math.floor(float * topNumber) + 1;

// Blackjack: draw without replacement from a 6-deck shoe of 312 cards, one float per card.
// Dealt in order: player, dealer, player, dealer, then each card as it's needed
const shoe = [...Array(312).keys()].map((index) => index % 52);
const cards = floats(...seeds, 80).map(
  (float, drawn) => shoe.splice(Math.floor(float * (312 - drawn)), 1)[0]
);

// Crash: from the round's hash. Hashing it gives the previous round's hash,
// all the way back to the terminal hash we published before any round
const value = parseInt(hash.slice(0, 13), 16);
const point = Math.floor(((1 - edge) * 2 ** 52 * 100) / (2 ** 52 - value)) / 100;

// PvP Coinflip: the seed is committed when the lobby opens. The reveal value is the
// joining player's client seed, which nobody knew then
const side = floats(serverSeed, revealValue, 0, 1)[0] < 0.5 ? "heads" : "tails";

// Jackpot: the seed is committed when the round opens. The reveal value is every entry's
// client seed, joined with "|". The ticket lands in one entry's share of the pot
const ticket = Math.floor(floats(serverSeed, revealValue, 0, 1)[0] * pot);