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

seeds.ts
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:

api-commitment.mjs
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);
}
Output
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

derive.ts
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.

api-cursor.mjs
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));
Output
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

verify.ts
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:

api-play-games.mjs
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}`);
}
Output
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.

GameParameter readDefaultRange enforced by play
dice, roulettenone
limbohouseEdge0.01none
wheelsegments10integer, 2 or more, no upper limit
plinkorows168 to 16
minesmines31 to 24
kenodraws101 to 40
blackjack, hilodecks11 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

record.ts
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.

api-canonical.mjs
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);
  }
}
Output
{"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.

api-record-hash.mjs
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 }));
Output
{"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

inspect.ts
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.

GameAccepted keyRange
dice, roulettenone
limbohouseEdgenumber, 0 to 0.5
wheelsegmentswhole number, 2 to 100
plinkorowswhole number, 8 to 16
minesmineswhole number, 1 to 24
kenodrawswhole number, 1 to 40
blackjack, hilodeckswhole 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.

api-inspect-crash.mjs
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);
Output
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

sign.ts
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.

api-sign-record.mjs
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 }));
Output
true 128
as signed: true
result edited: false
seed added: false

Crash Profile

crash.ts
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.

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.

api-crash-chain.mjs
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));
Output
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

crypto.ts
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.

api-hmac-key.mjs
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));
Output
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

NameValueMeaning
SPEC_VERSION'GFS/1.0'The spec value verifyRecord and inspectRecord accept
SERVER_SEED_BYTES32Bytes in a server seed, 64 hex characters
CLIENT_SEED_MAX_LENGTH64Longest client seed, in UTF-16 code units
BYTES_PER_DIGEST32Size of one HMAC-SHA256 digest
FLOATS_PER_DIGEST8Floats taken from one digest, so floats per cursor step
MAX_RECORD_BYTES65536Largest input parseInspection accepts, in UTF-8 bytes

GAMES is documented with the mappers below.

The Games Subpath

@galabet/fair/games
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>.

api-mappers.mjs
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));
Output
[ 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.