DocsReference
Library API
Every export of @galabet/fair 0.1.0 and @galabet/fair/games, with its signature, what it returns, what it throws and what it leaves unchecked.
This page covers version 0.1.0. There are two entry points: @galabet/fair for seeds, derivation, records, signing and the crash profile, and @galabet/fair/games for the mappers that turn floats into results. Each section opens with the signatures as the source file declares them. The entries below say what comes back, what is thrown, and what the function does not check.
All hashing, signing and random bytes go through Web Crypto, which is asynchronous. A signature marked async returns a Promise and has to be awaited. Everything else is synchronous and pure. Nothing in the library keeps state between calls, and there are no runtime dependencies.
Hex in a signature is an alias for string. It means lowercase hex with no 0x prefix, and the compiler can't enforce that, so the functions that care check at run time. Errors are plain Error objects with a message and no code. The errors page lists every message.
Seeds and Commitments
async function createServerSeed(): Promise<Hex>
async function createClientSeed(): Promise<string>
function assertServerSeed(serverSeed: string): void
function assertClientSeed(clientSeed: string): void
async function commit(serverSeed: Hex): Promise<Commitment>
async function verifyCommitment(serverSeed: Hex, commitment: Hex): Promise<boolean>
interface Commitment { commitment: Hex; publishedAt: number }
createServerSeed
Returns 32 bytes from the platform CSPRNG as 64 lowercase hex characters.
createClientSeed
Returns 16 random bytes as 32 hex characters. It is the default for a player who hasn't typed a seed of their own. Any string that passes assertClientSeed is as valid.
assertServerSeed
Throws server seed must be 64 lowercase hex characters unless the value matches /^[0-9a-f]{64}$/. Capital A to F fails. So does undefined. Returns nothing.
assertClientSeed
Two rules. The seed is a string of 1 to 64 characters, and it contains no colon, because the colon separates the parts of the HMAC message. Breaking them throws client seed must be 1 to 64 characters or client seed must not contain ":" (reserved as the HMAC message separator).
Length is JavaScript's length, which counts UTF-16 code units, so 33 emoji of two units each are too long. Nothing else is checked: spaces, control characters and leading or trailing whitespace all pass and all change the result.
commit
Resolves to { commitment, publishedAt }. commitment is the SHA-256 of the seed's hex string read as text, not of the 32 bytes it decodes to. publishedAt is Date.now() at the moment of the call. It records when the function ran and is not evidence that anyone saw the commitment. Throws what assertServerSeed throws.
verifyCommitment
Resolves to true when the seed hashes to the commitment. The commitment is lowercased before comparing, the seed is not, so the two arguments are treated differently:
import { commit, verifyCommitment } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
console.log(commitment);
console.log(await verifyCommitment(serverSeed, commitment.toUpperCase()));
console.log(await verifyCommitment(serverSeed, 'abc'));
try {
await verifyCommitment(serverSeed.toUpperCase(), commitment);
} catch (error) {
console.log(error.message);
}
ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7
true
false
server seed must be 64 lowercase hex characters
A malformed seed throws. A malformed commitment returns false. A commitment that isn't a string raises a TypeError from toLowerCase.
Derivation
async function deriveDigest(seeds: SeedPair, cursor?: number): Promise<Uint8Array>
function bytesToFloat(b0: number, b1: number, b2: number, b3: number): number
function digestToFloats(digest: Uint8Array): number[]
async function deriveFloats(seeds: SeedPair, count: number): Promise<FloatStream>
interface SeedPair { serverSeed: Hex; clientSeed: string; nonce: number }
interface FloatStream { floats: number[]; cursor: number }
deriveDigest
One 32-byte HMAC-SHA256 digest. The key is the server seed as its 64-character string. The message is ${clientSeed}:${nonce}:${cursor}, and cursor defaults to 0.
It runs both seed assertions, then throws nonce must be a non-negative integer or cursor must be a non-negative integer for anything negative, fractional or not a number. A numeric string such as "42" fails. There is no upper bound. A nonce of 1e21 is accepted and goes into the message as the text 1e+21, and past Number.MAX_SAFE_INTEGER neighbouring integers collapse into the same double, so 2**53 and 2**53 + 1 give one digest. inspectRecord is stricter and stops at Number.MAX_SAFE_INTEGER.
bytesToFloat
b0/256 + b1/256² + b2/256³ + b3/256⁴. Four bytes in, one float in the range 0 up to but not including 1. No modulo is involved. The arguments are not range checked, so a value of 256 gives an answer outside the range instead of an error.
digestToFloats
Splits a digest into eight floats, four bytes each, in order. Throws digest must be 32 bytes for any other length.
deriveFloats
Resolves to the first count floats for a bet. Cursor 0 supplies floats 0 to 7, cursor 1 supplies 8 to 15, and the function requests digests until it has enough. The cursor it returns is the highest one it used, which is the number that goes in a record. That makes it ceil(count / 8) - 1, not a count of digests.
import { bytesToFloat, deriveFloats } from '@galabet/fair';
const seeds = {
serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
clientSeed: 'galabet',
nonce: 42,
};
for (const count of [1, 8, 9, 51]) {
const { floats, cursor } = await deriveFloats(seeds, count);
console.log(`count ${count}: ${floats.length} floats, cursor ${cursor}`);
}
console.log(bytesToFloat(143, 168, 234, 224));
console.log(bytesToFloat(255, 255, 255, 255));
console.log(bytesToFloat(256, 0, 0, 0));
count 1: 1 floats, cursor 0
count 8: 8 floats, cursor 0
count 9: 9 floats, cursor 1
count 51: 51 floats, cursor 6
0.5611712262034416
0.9999999997671694
1
Throws count must be a positive integer for zero, negatives and fractions, plus everything deriveDigest throws. The last output line is the unchecked argument mentioned above.
Playing and Verifying
async function play(input: PlayInput): Promise<PlayOutput>
async function verifyRecord(record: FairRecord): Promise<VerifyOutcome>
interface PlayInput extends SeedPair { game: GameName; params?: GameParams }
interface PlayOutput { result: unknown; cursor: number; floats: number[] }
interface VerifyOutcome {
ok: boolean;
computed: unknown;
claimed: unknown;
commitmentOk: boolean;
cursorOk: boolean;
signatureOk: boolean | null;
recordHash: string;
reasons: string[];
}
play
Looks the game up in GAMES, asks its definition how many floats it needs, derives them and maps them. The operator calls it to produce a result, and a verifier calls it with the same inputs to reproduce one. floats holds every float the game consumed, so a page can show its working.
result is typed unknown because its shape depends on the game. With default parameters:
import { GAMES, play } from '@galabet/fair';
const seeds = {
serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
clientSeed: 'galabet',
nonce: 42,
};
for (const game of Object.keys(GAMES)) {
const { result, cursor, floats } = await play({ game, ...seeds });
const text = JSON.stringify(result);
const shown = text.length > 34 ? `${text.slice(0, 34)}...` : text;
console.log(`${game.padEnd(9)} floats ${String(floats.length).padStart(2)} cursor ${cursor} ${shown}`);
}
dice floats 1 cursor 0 56.12
limbo floats 1 cursor 0 1.76
roulette floats 1 cursor 0 20
wheel floats 1 cursor 0 5
plinko floats 16 cursor 1 {"path":[1,0,0,1,0,1,1,1,0,0,1,1,1...
mines floats 24 cursor 2 [9,17,22]
keno floats 39 cursor 4 [7,10,11,17,22,26,28,32,33,36]
blackjack floats 51 cursor 6 ["7C","KC","3S","AC","QH","5H","6S...
hilo floats 51 cursor 6 ["7C","KC","3S","AC","QH","5H","6S...
Mines always draws 24 floats and keno always draws 39, whatever mines or draws is set to, because both shuffle the whole grid or pool and then take the front of it.
| Game | Parameter read | Default | Range enforced by play |
|---|---|---|---|
dice, roulette | none | ||
limbo | houseEdge | 0.01 | none |
wheel | segments | 10 | integer, 2 or more, no upper limit |
plinko | rows | 16 | 8 to 16 |
mines | mines | 3 | 1 to 24 |
keno | draws | 10 | 1 to 40 |
blackjack, hilo | decks | 1 | 1 to 8 |
Parameters a game doesn't read are ignored without complaint. They still change the record hash, and inspectRecord rejects them.
Throws unknown game "…", everything deriveFloats throws, and the mapper's own range error. One message is misleading. The float count is worked out before the mapper validates anything, so rows: 0, rows: 8.5 or decks: 0 fails with count must be a positive integer, which never mentions rows or decks.
verifyRecord
Takes a record that carries its revealed serverSeed and resolves to a VerifyOutcome. It checks that spec is GFS/1.0, that the seed hashes to commitment, that replaying the inputs gives the same result (compared as canonical JSON) and the same cursor, and that the signature verifies if signature or signer is present. ok is true exactly when reasons is empty. signatureOk is null for an unsigned record. computed stays null when there was no seed or no known game to replay.
What it does not look at: profile, at, beacon, and whether the commitment was published before the bet. A record with no serverSeed comes back with ok: false and the reason server seed not revealed yet; verify after rotation, which describes a record that can't be checked yet and not a bad one.
The reason strings are listed on the errors page.
Records
function canonicalJson(value: unknown): string
function signingPayload(record: FairRecord): string
async function recordHash(record: FairRecord): Promise<string>
canonicalJson
Serialises a value so that the same data always gives the same bytes: object keys sorted by UTF-16 code unit, no whitespace, numbers in JavaScript's shortest round-trip form. The source calls it the subset of RFC 8785 that records need. It is not a full implementation.
import { canonicalJson } from '@galabet/fair';
console.log(canonicalJson({ b: 1, a: { d: [1, undefined, 'x'], c: undefined }, B: 2 }));
console.log(canonicalJson(54.70), canonicalJson(-0), canonicalJson(1e21));
console.log(canonicalJson(new Date(0)));
for (const bad of [NaN, 10n, undefined]) {
try {
canonicalJson(bad);
} catch (error) {
console.log(error.message);
}
}
{"B":2,"a":{"d":[1,null,"x"]},"b":1}
54.7 0 1e+21
{}
canonicalJson: non-finite number
canonicalJson: unsupported type bigint
canonicalJson: unsupported type undefined
Capital B sorts ahead of lowercase a. An object member that is undefined is dropped, while undefined inside an array becomes null. toJSON is never called, so a Date turns into {}, and a Map or a class instance is walked as a plain object. Pass plain data. Throws canonicalJson: non-finite number and canonicalJson: unsupported type … for functions, symbols, bigints and a top-level undefined.
signingPayload
The canonical JSON of the record with signature and signer removed. This string is what an operator signs. Every other field is included, serverSeed among them when it is present.
recordHash
SHA-256 of the signing payload, as hex. It identifies a record, with one catch that the next example shows: adding the revealed seed changes the payload, so a record has one hash before rotation and another after.
import { commit, recordHash, signingPayload } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const record = {
spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
commitment, clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 0,
};
console.log(signingPayload({ ...record, signature: 'ignored', signer: 'ignored' }));
console.log(await recordHash(record));
console.log(await recordHash({ ...record, serverSeed }));
{"at":0,"clientSeed":"galabet","commitment":"ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7","cursor":0,"game":"dice","nonce":42,"params":{},"profile":"single-player","result":56.12,"spec":"GFS/1.0"}
b13169760e839679756ef014f5e5cfee6178e3b44520a33fb702e50139c75095
462eae66e656858c40cca33db7630b3013370b9405962df593bece1de4f85a27
Both throw whatever canonicalJson throws. Neither validates the record.
Inspecting Untrusted Input
function parseInspection(text: string): Record<string, unknown>
function validateInspectionParams(game: GameName, value: unknown): GameParams
async function inspectRecord(input: unknown): Promise<Inspection>
type CheckState = 'matches' | 'mismatch' | 'not-provided' | 'unsupported'
interface InspectionCheck { name: string; state: CheckState; detail: string }
interface Inspection {
kind: string;
status: 'matches' | 'mismatch' | 'incomplete';
computed: unknown;
claimed: unknown;
checks: InspectionCheck[];
difference: string | null;
recordHash?: string;
note: string;
}
These three exist for records pasted into a form or posted to an API. They validate before any hashing is done, and their messages are written for the person at the form.
parseInspection
Parses JSON text and returns the object. It refuses text over MAX_RECORD_BYTES (64 KB, measured as UTF-8), invalid JSON, nesting deeper than 12 levels, a number that parses to infinity such as 1e999, and any top-level value that isn't an object. It knows nothing about records. {"a":1} passes.
validateInspectionParams
Checks params against the one parameter each game accepts and returns the same object, not a copy.
| Game | Accepted key | Range |
|---|---|---|
dice, roulette | none | |
limbo | houseEdge | number, 0 to 0.5 |
wheel | segments | whole number, 2 to 100 |
plinko | rows | whole number, 8 to 16 |
mines | mines | whole number, 1 to 24 |
keno | draws | whole number, 1 to 40 |
blackjack, hilo | decks | whole number, 1 to 8 |
Any other key throws params.<key>: not supported for <game>. Two of these ranges are narrower than what play accepts: play puts no limit on houseEdge and no ceiling on segments. The game name itself is not checked here, and an unknown name with empty params returns {}.
inspectRecord
Accepts anything. The input is passed through JSON.stringify and parseInspection first, so the size and depth limits apply to objects as well as text. What happens next depends on whether the record has a gameHash key.
Without gameHash it is a single-player record. spec, profile, game, params, clientSeed and nonce are required and validated. params has to be an object even when it is {}, which is stricter than verifyRecord. cursor, at, commitment and serverSeed are validated when present. A record with signature or signer must have both in the right format, plus result, cursor, at and commitment. If the platform can't do Ed25519, the Signature check is reported as unsupported and nothing is thrown.
With gameHash it is a crash record: gameHash, salt (1 to 1024 characters) and houseEdge (0 to 0.999999) are required, and houseEdge has no default here. result, commitment and previousHash are optional. kind is Crash, or Galabet Flight when the record also has an id. Signatures on crash records are reported as unsupported.
status is mismatch if any check mismatched. Otherwise it is incomplete if any check is unsupported, or if a required check didn't match because something was missing. Required means Outcome, plus Commitment (a crash record with previousHash needs Chain link in its place), plus Cursor for seed games. A beacon field is always reported unsupported, so a record that carries one can't reach matches. recordHash is set only on single-player records that have result, cursor, at and commitment.
import { crashResult, inspectRecord, sha256Hex } from '@galabet/fair';
const gameHash = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const record = {
gameHash,
previousHash: await sha256Hex(gameHash),
salt: 'example-salt',
houseEdge: 0.01,
result: await crashResult(gameHash, 'example-salt', 0.01),
};
const report = await inspectRecord(record);
console.log(report.kind, report.status, report.computed);
for (const check of report.checks) console.log(`${check.name}: ${check.state}`);
const edited = await inspectRecord({ ...record, result: 2 });
console.log(edited.status, edited.difference);
Crash matches 1.17
Commitment: not-provided
Chain link: matches
Outcome: matches
mismatch result: recorded 2, calculated 1.17.
Commitment is not-provided and the first status is still matches, because previousHash was supplied and the chain link stands in for it. The function throws for malformed input and resolves for everything else, mismatches included. The Dice page has a single-player example.
Signing
async function generateKeyPair(): Promise<KeyPair>
async function sign(message: string | Uint8Array, secretKey: Hex): Promise<Hex>
async function verifySignature(message: string | Uint8Array, signature: Hex, publicKey: Hex): Promise<boolean>
async function signRecord(record: FairRecord, secretKey: Hex): Promise<FairRecord>
async function verifyRecordSignature(record: FairRecord): Promise<boolean>
interface KeyPair { publicKey: Hex; secretKey: Hex }
Ed25519 through Web Crypto. The source lists the supported platforms as Node 18.4+, Chrome 113+, Firefox 130+, Safari 17+, Bun, Deno and Workers. That is narrower than the rest of the library, which its source says runs on Node 18+. On a platform without Ed25519 these functions reject with whatever error the platform raises. The examples on this page were run on Node 24 only.
generateKeyPair
publicKey is 32 bytes as 64 hex characters. secretKey is 64 bytes as 128 hex characters: the 32-byte seed followed by the public key, which is the libsodium layout.
sign
Resolves to a 64-byte signature as 128 hex characters. A string message is signed as its UTF-8 bytes. Throws secret key must be 128 hex chars (seed || public key) unless the key is 128 lowercase hex characters. If the two halves of the key don't belong together, Node's Web Crypto rejects the import with a DataError.
verifySignature
Resolves to false, without throwing, when the public key is not 64 lowercase hex characters or the signature is not 128. Uppercase hex counts as malformed.
signRecord
Returns a copy of the record with signature and signer set. The original is not modified, and any existing signature is replaced. signer is read from the second half of the secret key.
verifyRecordSignature
false when either field is missing, otherwise the result of verifySignature over the signing payload. It proves the record was signed by the holder of signer. It says nothing about who that is.
import { commit, generateKeyPair, signRecord, verifyRecordSignature } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const keys = await generateKeyPair();
const record = {
spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
commitment, clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 0,
};
const signed = await signRecord(record, keys.secretKey);
console.log(signed.signer === keys.publicKey, signed.signature.length);
console.log('as signed:', await verifyRecordSignature(signed));
console.log('result edited:', await verifyRecordSignature({ ...signed, result: 99.99 }));
console.log('seed added:', await verifyRecordSignature({ ...signed, serverSeed }));
true 128
as signed: true
result edited: false
seed added: false
Crash Profile
async function createCrashChain(length: number): Promise<CrashChain>
async function crashGameHash(chain: CrashChain, k: number): Promise<Hex>
async function expandCrashChain(chain: CrashChain): Promise<Hex[]>
async function verifyCrashLink(gameHash: Hex, previousHash: Hex): Promise<boolean>
async function crashResult(gameHash: Hex, salt: string, houseEdge?: number): Promise<number>
interface CrashChain { secret: Hex; terminatingHash: Hex; length: number }
Crash has no client seed and no nonce. A chain of hashes is built from a secret, h(i+1) = SHA-256(h(i)) over the hex string, and the last one is published as the terminating hash before game 1. Games then walk the chain backwards, so game k of N uses h(N-k) and the final game uses the secret itself.
createCrashChain
Picks a 32-byte random secret and hashes it length times. Throws chain length must be 1 to 10,000,000. Each link is one awaited Web Crypto call. A chain of 10,000 took about half a second on the machine that ran these examples.
crashGameHash
The hash for 1-based game k. Throws game index out of range unless k is a whole number from 1 to chain.length. It recomputes length - k hashes from the secret on every call and caches nothing.
expandCrashChain
The whole chain as an array of length + 1 strings. Index 0 is the terminating hash and index k is game k. The chain object is not validated, and its terminatingHash field is never read.
verifyCrashLink
true when SHA-256(gameHash) equals previousHash, which is lowercased first. For game 1 the previous hash is the terminating hash. One call checks one link. Neither argument is format checked, and a malformed one gives false.
crashResult
The multiplier for a game hash. The digest is HMAC-SHA256(key = gameHash, message = salt), h is its first 52 bits, and the result is floor(100 × 2⁵² / (h + 1) × (1 − houseEdge)) / 100 with a floor of 1. houseEdge defaults to 0.01.
Throws game hash must be 64 lowercase hex chars and house edge must be in [0, 1). A houseEdge of NaN slips past that comparison and the function resolves to NaN. The salt is not checked at all.
import { crashGameHash, crashResult, createCrashChain, expandCrashChain, verifyCrashLink } from '@galabet/fair';
const chain = await createCrashChain(5);
const hashes = await expandCrashChain(chain);
console.log(hashes.length, hashes[0] === chain.terminatingHash, hashes[5] === chain.secret);
console.log(await crashGameHash(chain, 3) === hashes[3]);
console.log('game 1 to terminating hash:', await verifyCrashLink(hashes[1], chain.terminatingHash));
console.log('game 3 to game 2:', await verifyCrashLink(hashes[3], hashes[2]));
console.log('wrong direction:', await verifyCrashLink(hashes[2], hashes[3]));
const fixed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
console.log(await crashResult(fixed, 'example-salt'), await crashResult(fixed, 'example-salt', 0));
6 true true
true
game 1 to terminating hash: true
game 3 to game 2: true
wrong direction: false
1.17 1.18
Bytes and Hashes
function toHex(bytes: Uint8Array): string
function fromHex(hex: string): Uint8Array
async function randomBytes(length: number): Promise<Uint8Array>
async function sha256(input: string | Uint8Array): Promise<Uint8Array>
async function sha256Hex(input: string | Uint8Array): Promise<string>
async function hmacSha256(key: string | Uint8Array, message: string | Uint8Array): Promise<Uint8Array>
function timingSafeEqualHex(a: string, b: string): boolean
The source describes this file as a thin wrapper over Web Crypto that runs in browsers, Node 18+, Bun, Deno and Cloudflare Workers. When globalThis.crypto.subtle is missing it imports node:crypto and uses webcrypto from there.
toHex and fromHex
toHex always writes lowercase. fromHex reads either case and throws fromHex: input must be an even-length hex string for an odd length or a character that isn't hex. An empty string gives an empty array.
randomBytes
Bytes from crypto.getRandomValues. Web Crypto serves at most 65,536 bytes per call and the library doesn't split larger requests, so a bigger length rejects with the platform's error.
sha256 and sha256Hex
The same hash, returned as bytes or as lowercase hex. A string input is hashed as UTF-8. It is never hex-decoded for you.
hmacSha256
A string key is used as its UTF-8 bytes, and that is the detail every port has to copy. GFS keys the HMAC with the server seed's 64 characters, not with the 32 bytes they spell.
import { fromHex, hmacSha256, toHex } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
console.log(toHex(await hmacSha256(serverSeed, 'galabet:42:0')).slice(0, 16));
console.log(toHex(await hmacSha256(fromHex(serverSeed), 'galabet:42:0')).slice(0, 16));
8fa8eae01ccd4312
85a7175ab26231fc
The first line is what GFS uses. An empty key is rejected by Node's Web Crypto.
timingSafeEqualHex
Compares two strings without stopping at the first difference. Different lengths return false straight away, so length is not hidden. The comparison is by character code: 'ab' and 'AB' are unequal, and nothing checks that either string is hex. Lowercase both sides first.
Constants
| Name | Value | Meaning |
|---|---|---|
SPEC_VERSION | 'GFS/1.0' | The spec value verifyRecord and inspectRecord accept |
SERVER_SEED_BYTES | 32 | Bytes in a server seed, 64 hex characters |
CLIENT_SEED_MAX_LENGTH | 64 | Longest client seed, in UTF-16 code units |
BYTES_PER_DIGEST | 32 | Size of one HMAC-SHA256 digest |
FLOATS_PER_DIGEST | 8 | Floats taken from one digest, so floats per cursor step |
MAX_RECORD_BYTES | 65536 | Largest input parseInspection accepts, in UTF-8 bytes |
GAMES is documented with the mappers below.
The Games Subpath
function dice(f: number): number
function limbo(f: number, houseEdge?: number): number
function roulette(f: number): number
function wheel(f: number, segments: number): number
function plinko(floats: readonly number[], rows: number): { path: (0 | 1)[]; bucket: number }
function mines(floats: readonly number[], count: number): number[]
function keno(floats: readonly number[], draws?: number): number[]
function deck(floats: readonly number[], decks?: number): string[]
const blackjack: typeof deck
const hilo: typeof deck
function cardLabel(index: number): string
function shuffle(floats: readonly number[], size: number): number[]
function floatsNeededForShuffle(size: number): number
const GAMES: Record<GameName, GameDefinition>
function isGameName(value: string): value is GameName
interface GameDefinition {
floats: (params: GameParams) => number;
map: (floats: readonly number[], params: GameParams) => unknown;
}
All of these are synchronous and pure. They take floats you already have and don't touch seeds or crypto. None of them checks that a float lies between 0 and 1. dice(1) returns 100.01, and shuffle given a float of 1 writes past the end of its array and returns a list with a hole in it. Floats from deriveFloats are always in range. Floats from anywhere else are yours to check.
GAMES and isGameName are the same objects the main entry point exports.
dice
floor(f × 10001) / 100, a number from 0 to 100 with two decimals. The Dice page explains the 10001.
limbo
floor(1e8 / (f × 1e8 + 1) × (1 − houseEdge) × 100) / 100, never below 1. houseEdge defaults to 0.01 and is not validated. At 1 or above every result is 1.
roulette
floor(f × 37), a European pocket from 0 to 36.
wheel
floor(f × segments). Throws segments must be an integer >= 2. segments has no default here. The default of 10 belongs to GAMES.wheel.
plinko
One float per row, left (0) below 0.5 and right (1) otherwise. bucket is the number of rights, from 0 to rows. Throws rows must be 8 to 16, or plinko with <rows> rows needs <rows> floats when the array is short.
mines
Shuffles the 25 tiles of a 5 by 5 grid and returns the first count as mine positions, 0 to 24, sorted ascending. Needs 24 floats. Throws mines must be 1 to 24. count has no default at this level.
keno
Shuffles the numbers 1 to 40 and returns the first draws, sorted ascending, so the order of the draw is not kept. Needs 39 floats. draws defaults to 10. Throws draws must be 1 to 40.
deck, blackjack and hilo
One function under three names. It shuffles 52 × decks cards and returns their labels in dealing order, which takes 52 × decks − 1 floats. decks defaults to 1. Throws decks must be 1 to 8. Dealing, hand values and hi-lo guesses are game logic, and the library has none.
cardLabel
Index 0 to 51 to a two-character label, rank then suit. The index is suit × 13 + rank, with suits in the order C D H S and ranks A 2 3 4 5 6 7 8 9 T J Q K. Index 0 is AC and index 51 is KS. Throws card index out of range, which covers fractions too.
shuffle and floatsNeededForShuffle
Fisher-Yates over the integers 0 to size − 1, walking from the last position down and using one float per swap as j = floor(float × (i + 1)). It returns a new array. floatsNeededForShuffle(size) is size − 1, and 0 for a size below 1. Extra floats are ignored. Throws size must be a positive integer and shuffle of <size> needs <n> floats, got <m>.
import { deriveFloats } from '@galabet/fair';
import { cardLabel, deck, dice, floatsNeededForShuffle, keno, mines, plinko, shuffle } from '@galabet/fair/games';
const seeds = {
serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
clientSeed: 'galabet',
nonce: 42,
};
const { floats } = await deriveFloats(seeds, floatsNeededForShuffle(52));
console.log(shuffle([0.5, 0.5, 0.5], 4));
console.log(mines(floats, 3), keno(floats, 5));
console.log(JSON.stringify(plinko(floats, 8)));
console.log(deck(floats).slice(0, 5), cardLabel(0), cardLabel(51));
console.log(dice(1), shuffle([1, 1, 1], 4));
[ 0, 3, 1, 2 ]
[ 9, 17, 22 ] [ 11, 17, 28, 32, 33 ]
{"path":[1,0,0,1,0,1,1,1],"bucket":5}
[ '7C', 'KC', '3S', 'AC', 'QH' ] AC KS
100.01 [ 0, undefined, 1, 2, 3 ]
The last line is the out-of-range behaviour described at the top of this section.
GAMES and isGameName
GAMES maps each of the nine names (dice, limbo, roulette, wheel, plinko, mines, keno, blackjack, hilo) to a GameDefinition. floats(params) says how many floats the game consumes and map(floats, params) produces the result, filling in the defaults from the table under play. This table is what play and verifyRecord iterate. Crash is not in it.
isGameName is an own-property test on GAMES, so isGameName('toString') is false. Use it before passing a user's string to play.
