DocsGames
Plinko
How a Plinko path is read from the seeds, why the row count decides the cursor in the record, and how often each bucket comes up.
Eight rows of pegs means eight decisions, and the library's result is the list of them: [1, 0, 0, 1, 0, 1, 1, 1]. A 0 is a bounce to the left. A 1 is a bounce to the right. The ball lands in bucket 5 because the list has five 1s in it, and buckets are counted from the left edge starting at 0.
That's how to read any Plinko record you've been handed. Count the 1s in path and you should get bucket. Whether the path really came from the seeds is a separate question, and the verifier answers it in your browser. If that's all you wanted, you're done.
| Input | Value |
|---|---|
| Server seed | 5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d |
| Client seed | galabet |
| Nonce | 42 |
How the Path Is Calculated
import { play } from '@galabet/fair';
const rows = 8;
const { result, floats } = await play({
game: 'plinko',
serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
clientSeed: 'galabet',
nonce: 42,
params: { rows },
});
let rights = 0;
result.path.forEach((turn, row) => {
const pegs = Array.from({ length: row + 1 }, (_, peg) => (peg === rights ? 'o' : '.'));
const margin = ' '.repeat(rows - row);
console.log(`${margin}${pegs.join(' ')}${margin} ${floats[row].toFixed(4)} ${turn ? 'right' : 'left'}`);
rights += turn;
});
console.log(Array.from({ length: rows + 1 }, (_, bucket) => (bucket === result.bucket ? bucket : '_')).join(' '));
o 0.5612 right
. o 0.1125 left
. o . 0.0442 left
. o . . 0.8333 right
. . o . . 0.2461 left
. . o . . . 0.5062 right
. . . o . . . 0.8536 right
. . . . o . . . 0.5924 right
_ _ _ _ _ 5 _ _ _
Each row reads the next float in the stream. Under 0.5 the ball goes left, otherwise it goes right. The o is the peg the ball hits, and its place in the row is the number of rights so far, so by the bottom the count of rights is the bucket and there's nothing left to work out.
| Fact | Value |
|---|---|
| Result | { path, bucket } |
path | one entry per row, 0 for left and 1 for right |
bucket | the sum of path, from 0 to rows |
rows | an integer from 8 to 16, and 16 when you leave it out |
| Floats read | one per row |
| Cursor | 0 at 8 rows, 1 at 9 to 16 |
Boundary: A Float of Exactly 0.5
A float of exactly 0.5 goes right. The test is float < 0.5, and the float one step below a half is the last one that goes left.
import { plinko } from '@galabet/fair/games';
console.log(plinko(new Array(8).fill(0.5), 8).bucket);
console.log(plinko(new Array(8).fill(0.5 - 2 ** -32), 8).bucket);
8
0
Rows and Cursor
An HMAC-SHA256 digest is 32 bytes and a float takes four of them. Eight rows fit in one digest. Nine don't.
import { play } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
for (let rows = 8; rows <= 16; rows++) {
const { result, cursor } = await play({ game: 'plinko', serverSeed, clientSeed: 'galabet', nonce: 42, params: { rows } });
console.log(`rows ${String(rows).padStart(2)} cursor ${cursor} bucket ${result.bucket} ${result.path.join('')}`);
}
rows 8 cursor 0 bucket 5 10010111
rows 9 cursor 1 bucket 5 100101110
rows 10 cursor 1 bucket 5 1001011100
rows 11 cursor 1 bucket 6 10010111001
rows 12 cursor 1 bucket 7 100101110011
rows 13 cursor 1 bucket 8 1001011100111
rows 14 cursor 1 bucket 8 10010111001110
rows 15 cursor 1 bucket 8 100101110011100
rows 16 cursor 1 bucket 8 1001011100111000
The ninth float lives in a second digest. Its HMAC message is galabet:42:1 where the first one was galabet:42:0, and that trailing number is the cursor. A record stores the highest cursor its round had to read, so a Plinko record says 0 at 8 rows and 1 at every other row count. Sixteen floats is two digests exactly, which means Plinko never reaches cursor 2.
Now read the paths from top to bottom. Each is the line above it with one more turn on the end. rows isn't part of the HMAC message. It only says how many floats to take, so the 8-row path is the first half of the 16-row path for the same seeds and nonce. That would matter if a nonce could be played twice at different row counts. It can't, for the same reason it can't be played twice at all.
So what breaks? Two things. A cursor: 0 hard-coded into the record writer, which is correct for Dice and wrong here for every row count but 8. And a record that leaves rows out of params, because with params: {} the library assumes 16.
import { commit, play, verifyRecord } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const params = { rows: 8 };
const { result, cursor } = await play({ game: 'plinko', serverSeed, clientSeed: 'galabet', nonce: 42, params });
const record = {
spec: 'GFS/1.0', profile: 'single-player', game: 'plinko', params,
serverSeed, commitment, clientSeed: 'galabet', nonce: 42, cursor, result, at: 0,
};
for (const candidate of [record, { ...record, cursor: 1 }, { ...record, params: {} }]) {
const { ok, reasons } = await verifyRecord(candidate);
console.log(ok, reasons);
}
true []
false [ 'cursor mismatch: computed 0, record 1' ]
false [
'cursor mismatch: computed 1, record 0',
'result does not match seeds'
]
The third record is an 8-row result filed as a 16-row game, and it fails twice over. Paste it into the verifier and the difference line reads result.path: recorded and calculated lengths differ (8 / 16).
Bucket Distribution
Every turn is an even split, and exactly even: of the 4,294,967,296 floats the derivation can produce, 2,147,483,648 are below 0.5. Dice has a remainder to account for. Plinko has none.
The bucket is the number of rights in rows fair turns, which is a binomial distribution. With 8 rows there are 256 paths, and the number of them ending in bucket k is "8 choose k". Here is that count beside what 20,000 nonces produced.
import { play } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const rows = 8;
const rounds = 20000;
const seen = new Array(rows + 1).fill(0);
for (let nonce = 0; nonce < rounds; nonce++) {
const { result } = await play({ game: 'plinko', serverSeed, clientSeed: 'galabet', nonce, params: { rows } });
seen[result.bucket]++;
}
console.log('bucket paths expected seen');
let paths = 1;
for (let bucket = 0; bucket <= rows; bucket++) {
const expected = (rounds * paths) / 2 ** rows;
console.log(`${String(bucket).padStart(6)} ${String(paths).padStart(5)} ${expected.toFixed(1).padStart(8)} ${String(seen[bucket]).padStart(5)}`);
paths = (paths * (rows - bucket)) / (bucket + 1);
}
bucket paths expected seen
0 1 78.1 65
1 8 625.0 609
2 28 2187.5 2192
3 56 4375.0 4362
4 70 5468.8 5495
5 56 4375.0 4475
6 28 2187.5 2120
7 8 625.0 605
8 1 78.1 77
The largest miss is bucket 5, at 4,475 against an expected 4,375. Chance alone moves a bin that size by about 58 either way, so a gap of 100 is under two of those and not worth chasing.
More rows make the edges rarer, and fast.
for (const rows of [8, 12, 16]) {
let middle = 1;
for (let k = 0; k < rows / 2; k++) middle = (middle * (rows - k)) / (k + 1);
const total = 2 ** rows;
console.log(`${rows} rows: bucket 0 is 1 path in ${total}, bucket ${rows / 2} is ${middle} paths (${((middle / total) * 100).toFixed(2)}%)`);
}
8 rows: bucket 0 is 1 path in 256, bucket 4 is 70 paths (27.34%)
12 rows: bucket 0 is 1 path in 4096, bucket 6 is 924 paths (22.56%)
16 rows: bucket 0 is 1 path in 65536, bucket 8 is 12870 paths (19.64%)
Multiplier Tables
plinko stops at the bucket. What a bucket pays is an application rule, and GFS has nothing to say about it.
The demo's table is plinkoTable in apps/api/src/demo/rules.ts, and it is narrower than the library. It accepts 8, 12 or 16 rows and throws supported rows are 8, 12 and 16 for anything else, while plinko takes every whole number from 8 to 16. Each bucket gets a weight of (1 / chance) ** 0.62, the weights are scaled until the table returns 99%, and then each entry is rounded to two decimals. At 16 rows that comes out as 222.02 on either edge and 0.63 in the middle bucket. The rounding moves the return slightly, to 99.05% at 8 rows, 99.08% at 12 and 99.06% at 16. The Plinko game page shows every bucket's chance next to its multiplier.
Those are one demo's numbers, for credits that are worth nothing. Yours will differ. Publish the table, and keep a note of which version of it settled each bet. The Wheel page says why.
Implementing Without the Library
import { createHmac } from 'node:crypto';
import { readFile } from 'node:fs/promises';
function myPlinko({ serverSeed, clientSeed, nonce }, rows) {
const path = [];
for (let cursor = 0; path.length < rows; cursor++) {
const digest = createHmac('sha256', serverSeed).update(`${clientSeed}:${nonce}:${cursor}`).digest();
for (let i = 0; i < 32 && path.length < rows; i += 4) path.push(digest[i] >> 7);
}
return { path, bucket: path.reduce((a, b) => a + b, 0) };
}
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
console.log(JSON.stringify(myPlinko({ serverSeed, clientSeed: 'galabet', nonce: 42 }, 12)));
const { games } = JSON.parse(await readFile('vectors/gfs-1.0.json', 'utf8'));
for (const rows of [8, 12, 16]) {
const vectors = games.filter((v) => v.game === 'plinko' && v.params.rows === rows);
const matched = vectors.filter((v) => JSON.stringify(myPlinko(v, rows)) === JSON.stringify(v.result));
console.log(`rows ${rows}: ${matched.length} of ${vectors.length} vectors match`);
}
{"path":[1,0,0,1,0,1,1,1,0,0,1,1],"bucket":7}
rows 8: 112 of 112 vectors match
rows 12: 112 of 112 vectors match
rows 16: 112 of 112 vectors match
digest[i] >> 7 is the whole mapping. A float is below 0.5 exactly when the top bit of its first byte is 0, so a port never needs to build the float at all. Take bytes 0, 4, 8 and so on up to 28, keep the top bit of each, and move to the next cursor when the digest runs out. The HMAC key is the seed's hex string as text, not the bytes it decodes to, which the Dice page shows going wrong.
vectors/gfs-1.0.json has 336 Plinko rounds, 112 each at 8, 12 and 16 rows. Nothing in the file exercises 9, 10, 11, 13, 14 or 15. If you offer those, compare your port with the library yourself.
Errors
| Message | What happened |
|---|---|
rows must be 8 to 16 | A whole number outside the range, such as 7 or 17 |
count must be a positive integer | rows was 0, a fraction, or a string such as "8" |
plinko with 8 rows needs 8 floats | You called plinko yourself and passed too few floats |
import { play } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
for (const rows of [7, 17, 0, 8.5, '8']) {
try {
await play({ game: 'plinko', serverSeed, clientSeed: 'galabet', nonce: 42, params: { rows } });
} catch (error) {
console.log(JSON.stringify(rows), error.message);
}
}
7 rows must be 8 to 16
17 rows must be 8 to 16
0 count must be a positive integer
8.5 count must be a positive integer
"8" count must be a positive integer
Why does a bad rows produce a message about count? play works out how many floats to fetch before it calls the mapper, and for Plinko that number is rows. A zero, a fraction or a string is refused by the float reader, and the check that mentions rows never runs. These are plain Error objects and 0.1.0 has no error codes, so test Number.isInteger(rows) and the range yourself before you call.
