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;
}

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 PAYOUTS = {
"10": {
low: [
1.484848484848485, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0, 1.187878787878788, 1.187878787878788,
1.187878787878788, 1.187878787878788, 0,
],
medium: [
0, 1.880808080808081, 0, 1.484848484848485, 0, 1.97979797979798, 0,
1.484848484848485, 0, 2.96969696969697,
],
high: [0, 0, 0, 0, 0, 0, 0, 0, 0, 9.8],
},
"20": {
low: [
1.484848484848485, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0, 1.187878787878788, 1.187878787878788,
1.187878787878788, 1.187878787878788, 0, 1.484848484848485,
1.187878787878788, 1.187878787878788, 1.187878787878788, 0,
1.187878787878788, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0,
],
medium: [
1.484848484848485, 0, 1.97979797979798, 0, 1.97979797979798, 0,
1.97979797979798, 0, 1.484848484848485, 0, 2.96969696969697, 0,
1.781818181818182, 0, 1.97979797979798, 0, 1.97979797979798, 0,
1.97979797979798, 0,
],
high: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19.6],
},
"30": {
low: [
1.484848484848485, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0, 1.187878787878788, 1.187878787878788,
1.187878787878788, 1.187878787878788, 0, 1.484848484848485,
1.187878787878788, 1.187878787878788, 1.187878787878788, 0,
1.187878787878788, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0, 1.484848484848485, 1.187878787878788,
1.187878787878788, 1.187878787878788, 0, 1.187878787878788,
1.187878787878788, 1.187878787878788, 1.187878787878788, 0,
],
medium: [
1.484848484848485, 0, 1.484848484848485, 0, 1.97979797979798, 0,
1.484848484848485, 0, 1.97979797979798, 0, 1.97979797979798, 0,
1.484848484848485, 0, 2.96969696969697, 0, 1.484848484848485, 0,
1.97979797979798, 0, 1.97979797979798, 0, 1.682828282828283, 0,
3.95959595959596, 0, 1.484848484848485, 0, 1.97979797979798, 0,
],
high: [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 29.400000000000002,
],
},
"40": {
low: [
1.484848484848485, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0, 1.187878787878788, 1.187878787878788,
1.187878787878788, 1.187878787878788, 0, 1.484848484848485,
1.187878787878788, 1.187878787878788, 1.187878787878788, 0,
1.187878787878788, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0, 1.484848484848485, 1.187878787878788,
1.187878787878788, 1.187878787878788, 0, 1.187878787878788,
1.187878787878788, 1.187878787878788, 1.187878787878788, 0,
1.484848484848485, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0, 1.187878787878788, 1.187878787878788,
1.187878787878788, 1.187878787878788, 0,
],
medium: [
1.97979797979798, 0, 2.96969696969697, 0, 1.97979797979798, 0,
1.484848484848485, 0, 2.96969696969697, 0, 1.484848484848485, 0,
1.484848484848485, 0, 1.97979797979798, 0, 1.484848484848485, 0,
2.96969696969697, 0, 1.484848484848485, 0, 1.97979797979798, 0,
1.97979797979798, 0, 1.583838383838384, 0, 1.97979797979798, 0,
1.484848484848485, 0, 2.96969696969697, 0, 1.484848484848485, 0,
1.97979797979798, 0, 1.484848484848485, 0,
],
high: [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 39.2,
],
},
"50": {
low: [
1.484848484848485, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0, 1.187878787878788, 1.187878787878788,
1.187878787878788, 1.187878787878788, 0, 1.484848484848485,
1.187878787878788, 1.187878787878788, 1.187878787878788, 0,
1.187878787878788, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0, 1.484848484848485, 1.187878787878788,
1.187878787878788, 1.187878787878788, 0, 1.187878787878788,
1.187878787878788, 1.187878787878788, 1.187878787878788, 0,
1.484848484848485, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0, 1.187878787878788, 1.187878787878788,
1.187878787878788, 1.187878787878788, 0, 1.484848484848485,
1.187878787878788, 1.187878787878788, 1.187878787878788, 0,
1.187878787878788, 1.187878787878788, 1.187878787878788,
1.187878787878788, 0,
],
medium: [
1.97979797979798, 0, 1.484848484848485, 0, 1.97979797979798, 0,
1.484848484848485, 0, 2.96969696969697, 0, 1.484848484848485, 0,
1.484848484848485, 0, 1.97979797979798, 0, 1.484848484848485, 0,
2.96969696969697, 0, 1.484848484848485, 0, 1.97979797979798, 0,
1.484848484848485, 0, 1.97979797979798, 0, 1.97979797979798, 0,
1.484848484848485, 0, 2.96969696969697, 0, 1.484848484848485, 0,
1.97979797979798, 0, 1.484848484848485, 0, 1.484848484848485, 0,
4.94949494949495, 0, 1.484848484848485, 0, 1.97979797979798, 0,
1.484848484848485, 0,
],
high: [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
49.00000000000001,
],
},
};

const segments = 30;
const risk = "medium";

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

const result = PAYOUTS[segments][risk][Math.floor(float * segments)];

Did this answer your question?