Skip to main content

Provably Fair - Implementation

Implementation

Each float is deterministically derived from a HMAC-SHA256 hash of the clientSeed, serverSeed, nonce, and cursor, producing 32 bytes of output per digest.

These bytes are split into 8 chunks of 4 bytes, each interpreted as a 32-bit unsigned integer and normalised by dividing by 2 ** 32 to produce a uniform float in the range [0,1).

The generator iterates over successive cursors (and offsets within each hash output) to produce a sequential stream of independent pseudo-random floats.

import crypto from "node:crypto";

type Seed = {
clientSeed: string;
serverSeed: string;
nonce: number;
}

function* floatGenerator(
seed: Seed,
startCursor: number = 0,
startOffset: number = 0,
): Generator<number, number> {
const { clientSeed, serverSeed, nonce } = seed;

let cursor = startCursor;
let offset = startOffset;

while (true) {
const hmac = crypto.createHmac('sha256', serverSeed);
hmac.update(`${clientSeed}:${nonce}:${cursor}`);
const buffer = hmac.digest();

for (let i = offset; i + 4 <= buffer.length; i += 4) {
const int = buffer.readUInt32BE(i);
yield int / 0x100000000;
}
cursor++;
offset = 0;
}
}

const provablyFair = (seed: Seed, floatIndex?: number) => {
// Each HMAC digest produces 8 floats (32 bytes / 4 bytes per float = 8)
const startOffset = floatIndex ? (floatIndex % 8) * 4 : 0;
const startCursor = floatIndex ? Math.floor(floatIndex / 8) : 0;

const rng = floatGenerator(seed, startCursor, startOffset);

function generateFloats(count: number) {
return Array.from({ length: count }, () => rng.next().value);
}

return {
generateFloats,
generateFloat: () => generateFloats(1)[0],
}
}

Game Events

Blackjack

Winna Blackjack utilises an unlimited number of 52-card decks when generating the game event so each game event, each card has the same probability.

const floats = provablyFair(seed, 1).generateFloats(10);

const CARDS = [
'♠A', '♠2', '♠3', '♠4', '♠5', '♠6', '♠7', '♠8', '♠9', '♠10', '♠J', '♠Q', '♠K',
'♥A', '♥2', '♥3', '♥4', '♥5', '♥6', '♥7', '♥8', '♥9', '♥10', '♥J', '♥Q', '♥K',
'♣A', '♣2', '♣3', '♣4', '♣5', '♣6', '♣7', '♣8', '♣9', '♣10', '♣J', '♣Q', '♣K',
'♦A', '♦2', '♦3', '♦4', '♦5', '♦6', '♦7', '♦8', '♦9', '♦10', '♦J', '♦Q', '♦K',
];

const result = floats.map(float => CARDS[Math.floor(float * 52)]);

Coin Climber

For Coin Climber, the RTP is used to scale the initial transition probability only. For example in medium risk mode the first multiplier is 1.4. This means the probability of a Coin successfully climbing to the first level is 0.98/1.4.

After the first level each subsequent climb is fair, so the probability of climbing to the next level is (Multiplier on current level)/(Multiplier on next level).

const PAYOUT_MAP = {
low: [1.2, 1.5, 2, 3, 5, 10, 20, 75, 250],
medium: [1.4, 2, 3, 5, 10, 25, 75, 250, 1000],
high: [1.7, 3, 5, 10, 25, 75, 250, 1000, 5000],
};

const numCoins = 3;
const risk = 'medium';
const { generateFloat } = provablyFair(seed);

const result = Array.from({ length: numCoins }, () => {
const payouts = PAYOUT_MAP[risk];

for (let i = 0; i < payouts.length; i++) {
const float = generateFloat();
const prev = i === 0 ? 0.98 : payouts[i - 1];
if (float > prev / payouts[i]) return i;
}
return payouts.length;
});

Coinflip

For each turn of Coinflip if the value of the float is less than 0.5 it is heads otherwise it is tails.

const turns = 5;

const floats = provablyFair(seed).generateFloats(turns);

const result = floats.map((float) => (float < 0.5 ? "heads" : "tails"));

Dice

Each Dice roll is generated by mapping the float to one of 10,001 discrete integers in the range [0, 10000], then dividing by 100 to produce a result between 0.00 and 100.00 inclusive.

const float = provablyFair(seed).generateFloat();

const result = Math.floor(float * 10001) / 100;

Hilo

Winna Hilo utilises an unlimited number of 52-card decks when generating the game event so for each game event, each card has the same probability.

const floats = provablyFair(seed).generateFloats(10);

const CARDS = [
'♠A', '♠2', '♠3', '♠4', '♠5', '♠6', '♠7', '♠8', '♠9', '♠10', '♠J', '♠Q', '♠K',
'♥A', '♥2', '♥3', '♥4', '♥5', '♥6', '♥7', '♥8', '♥9', '♥10', '♥J', '♥Q', '♥K',
'♣A', '♣2', '♣3', '♣4', '♣5', '♣6', '♣7', '♣8', '♣9', '♣10', '♣J', '♣Q', '♣K',
'♦A', '♦2', '♦3', '♦4', '♦5', '♦6', '♦7', '♦8', '♦9', '♦10', '♦J', '♦Q', '♦K',
];

const result = floats.map(float => CARDS[Math.floor(float * 52)]);

Keno

Winna Keno utilises an algorithm equivalent to Fisher–Yates shuffling to generate the 10 hits.

const hits = 10;
const gridSize = 40;

const floats = provablyFair(seed).generateFloats(hits);

const available = Array.from({ length: gridSize }, (_, i) => i);

const result: number[] = [];
for (let i = 0; i < hits; i++) {
const index = Math.floor(floats[i] * (gridSize - i));
result.push(available.splice(index, 1)[0]);
}

Limbo

The Limbo result is generated by taking the RTP-scaled inverse of a uniform float in (0,1), then truncating to two decimal places and enforcing a minimum value of 1.00.

const float = provablyFair(seed).generateFloat();

const floatPoint = (1e8 / (float * 1e8)) * 0.99;

const crashPoint = Math.floor(floatPoint * 100) / 100;

const result = Math.max(crashPoint, 1);

Mines

Winna Mines utilises an algorithm equivalent to Fisher–Yates shuffling to generate the location for the mines on the grid.

const mines = 3;
const gridSize = 25;

const available = Array.from({ length: gridSize }, (_, i) => i);

const indexes = provablyFair(seed)
.generateFloats(mines)
.map((float, idx) => {
const index = Math.floor(float * (gridSize - idx));
return available.splice(index, 1)[0];
});

const result: (0 | 1)[] = [];
for (let i = 0; i < gridSize; ++i) {
result.push(indexes.includes(i) ? 1 : 0);
}

Plinko (Low, Medium, High)

The result of Plinko (Low, Medium and High risk) is created by first generating a float for each pin, all floats greater than or equal to 0.5 are then counted to determine the result.

const rows = 16;

const result = provablyFair(seed)
.generateFloats(rows)
.filter((float) => float >= 0.5).length;

Plinko (Extreme)

The result of Plinko Extreme risk is generated using inverse transform sampling, where a single uniform float is compared against the cumulative distribution until the corresponding outcome is selected.

const EXTREME_POSSIBILITIES = {
"8": [
0.006999288686868687, 0.048995001010101014, 0.17760228412121212,
0.17760228412121212, 0.17760228412121212, 0.17760228412121212,
0.17760228412121212, 0.048995001010101014, 0.006999288686868687,
],
"9": [
0.005443890101010102, 0.03062180262626263, 0.1546447690909091,
0.1546447690909091, 0.1546447690909091, 0.1546447690909091,
0.1546447690909091, 0.1546447690909091, 0.03062180262626263,
0.005443890101010102,
],
"10": [
0.003063658181818182, 0.02041977494949495, 0.13614759053391054,
0.13614759053391054, 0.13614759053391054, 0.13614759053391054,
0.13614759053391054, 0.13614759053391054, 0.13614759053391054,
0.02041977494949495, 0.003063658181818182,
],
"11": [
0.00196, 0.013613615555555557, 0.12110659611111112, 0.12110659611111112,
0.12110659611111112, 0.12110659611111112, 0.12110659611111112,
0.12110659611111112, 0.12110659611111112, 0.12110659611111112,
0.013613615555555557, 0.00196,
],
"12": [
0.0013611111111111111, 0.008169171111111111, 0.10899327061728395,
0.10899327061728395, 0.10899327061728395, 0.10899327061728395,
0.10899327061728395, 0.10899327061728395, 0.10899327061728395,
0.10899327061728395, 0.10899327061728395, 0.008169171111111111,
0.0013611111111111111,
],
"13": [
0.0008166666666666667, 0.006124376363636364, 0.0986117913939394,
0.0986117913939394, 0.0986117913939394, 0.0986117913939394,
0.0986117913939394, 0.0986117913939394, 0.0986117913939394,
0.0986117913939394, 0.0986117913939394, 0.0986117913939394,
0.006124376363636364, 0.0008166666666666667,
],
"14": [
0.0004899505050505051, 0.004084580606060606, 0.09007735797979798,
0.09007735797979798, 0.09007735797979798, 0.09007735797979798,
0.09007735797979798, 0.09007735797979798, 0.09007735797979798,
0.09007735797979798, 0.09007735797979798, 0.09007735797979798,
0.09007735797979798, 0.004084580606060606, 0.0004899505050505051,
],
"15": [
0.0003266666666666667, 0.002882199797979798, 0.08279852225589225,
0.08279852225589225, 0.08279852225589225, 0.08279852225589225,
0.08279852225589225, 0.08279852225589225, 0.08279852225589225,
0.08279852225589225, 0.08279852225589225, 0.08279852225589225,
0.08279852225589225, 0.08279852225589225, 0.002882199797979798,
0.0003266666666666667,
],
"16": [
0.00012249010101010103, 0.0024497525252525254, 0.0765273472882673,
0.0765273472882673, 0.0765273472882673, 0.0765273472882673,
0.0765273472882673, 0.0765273472882673, 0.0765273472882673,
0.0765273472882673, 0.0765273472882673, 0.0765273472882673,
0.0765273472882673, 0.0765273472882673, 0.0765273472882673,
0.0024497525252525254, 0.00012249010101010103,
],
};

const float = provablyFair(seed).generateFloat();

const rows = 10;
const probabilities = EXTREME_POSSIBILITIES[rows];

let cumulativeProbability = 0;
const result = (() => {
for (let i = 0; i < probabilities.length; i++) {
cumulativeProbability += probabilities[i];
if (float < cumulativeProbability) return i;
}
return probabilities.length - 1;
})();

Roulette

Winna Roulette consists of 37 possible results (0 - 36), we generate the result by mapping the float to one of the 37 results.

const float = provablyFair(seed).generateFloat();

const result = Math.floor(float * 37);

Pepe Tower

For each level of Pepe Tower, the result is generated by utilising an algorithm equivalent to Fisher–Yates shuffling to generate the location for the green heart/s on the level.

const LEVEL_MAP = {
easy: { count: 3, size: 4 },
medium: { count: 2, size: 3 },
hard: { count: 1, size: 2 },
expert: { count: 1, size: 3 },
master: { count: 1, size: 4 },
};

const risk = "medium";
const { count, size } = LEVEL_MAP[risk];
const { generateFloats } = provablyFair(seed);

const result: Array<(0 | 1)[]> = [];
for (let i = 0; i < 9; ++i) {
const available = Array.from({ length: size }, (_, i) => i);

const indexes = generateFloats(count).map((float, idx) => {
const index = Math.floor(float * (size - idx));
return available.splice(index, 1)[0];
});

const level: (0 | 1)[] = [];
for (let i = 0; i < size; ++i) {
level.push(indexes.includes(i) ? 1 : 0);
}
result[i] = level;
}

Slide

The Slide game result is generated by taking the first 32 bits of the HMAC-SHA256 hash of the server/client seed combination using the SLIDE_HASH salt, then converting that value into a multiplier using an inverse distribution formula with the house edge applied.

The server seed for Slide is derived from a 10 million-length hash chain, with the seed for the current round being the SHA-256 hash of the seed for the next round.

The client seed for Slide is the hash of Bitcoin block 950020. To prove that we did not choose a hash with favourable or unfavourable results, the below seeding event was conducted to select the hash of a then-unmined Bitcoin block.

Slide seeding event:

const int = crypto
.createHmac('sha256', seed)
.update(SLIDE_HASH)
.digest()
.readUInt32BE(0);

const result = Math.max(1, (0x100000000 / (int + 1)) * 0.965);

Twenty One

The result of Winna Twenty One is generated using a single 52-card deck, which is shuffled by utilising an algorithm equivalent to Fisher–Yates shuffling.

const CARDS = [
'♠A', '♠2', '♠3', '♠4', '♠5', '♠6', '♠7', '♠8', '♠9', '♠10', '♠J', '♠Q', '♠K',
'♥A', '♥2', '♥3', '♥4', '♥5', '♥6', '♥7', '♥8', '♥9', '♥10', '♥J', '♥Q', '♥K',
'♦A', '♦2', '♦3', '♦4', '♦5', '♦6', '♦7', '♦8', '♦9', '♦10', '♦J', '♦Q', '♦K',
'♣A', '♣2', '♣3', '♣4', '♣5', '♣6', '♣7', '♣8', '♣9', '♣10', '♣J', '♣Q', '♣K',
];

const numCards = 7;
const floats = provablyFair(seed).generateFloats(numCards);

const result = [];
const available = Array.from({ length: 52 }, (_, i) => i);
for (let i = 0; i < numCards; i++) {
const index = Math.floor(floats[i] * (52 - i));
const card = CARDS[available.splice(index, 1)[0]]
result.push(card);
}

Wheel

Winna Wheel results are generated by mapping the float to one of the possible segments.

const segments = 30; const float = provablyFair(seed).generateFloat(); const segment = Math.floor(float * segments);

Did this answer your question?