DocsBuilding a backend

Settling Bets

The library hands back an outcome and never sees a stake, so payout rules, rounding, the order of debit and credit, and retried requests are all the application's job.

play has no parameter for a stake and returns no payout. It can't tell you who won, because it was never told what anyone bet. Pocket 20, segment 5, a roll of 56.12: that is where @galabet/fair stops, and where GFS stops too.

If you're a player, the consequence is worth knowing before anything else on this page. A record that verifies proves the outcome came from the committed seeds. It doesn't prove you were paid correctly. For that you need the operator's payout rules in writing, and a receipt that names which version of them was applied.

Payout Rules Belong to the Application

Galabet's demo keeps its rules in one file of pure functions, apps/api/src/demo/rules.ts, which the API settles with and the site's calculators import. The file carries a version string, DEMO_RULE_VERSION, currently galabet-demo/2026-09-20.1. GET /api/demo/games/rules on a local API publishes the lot: comparisons, tables, and the return of each table as calculated from the table itself, not as advertised.

Running that catalog gives these returns, before any rounding to whole chips:

GameRuleTable return
WheelTen segments whose multipliers sum to 9.90.99
Roulette37 pockets, a straight number returns 36 times the stake0.972972… (36/37)
Plinko8, 12 and 16 rows0.990547, 0.990752, 0.990577
Keno1 to 10 picks, 10 drawn from 400.989605 (4 picks) to 0.990385 (2 picks)
Dice, Limbo, Mines1% edge applied to the multiplierDepends on the bet

The last row has no single figure because the multiplier is worked out per bet. The Dice page goes through one such rule set, including what happens when the roll equals the target, and Mines gives its formula.

Plinko isn't exactly 0.99 because its multipliers are rounded to two decimals after scaling. Nobody would have known that from a marketing page. It's visible because the published number is computed from the table the server pays from.

Change a multiplier and the version string changes with it. An old receipt then still names the rules it was settled under, which is the whole reason to version them.

A Receipt Beside the Record

settle.mjs
import { play } from '@galabet/fair';

const RULE_VERSION = 'docs-example/1';
const WHEEL = [0, 1.5, 1.2, 1.5, 0, 2, 1.2, 1.5, 0, 1];
const RED = new Set([1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]);

const rules = {
  roulette: {
    params: {},
    settle: (pocket, { color }) => ({ multiplier: 2, won: pocket !== 0 && (color === 'red') === RED.has(pocket) }),
  },
  wheel: {
    params: { segments: 10 },
    settle: (segment) => ({ multiplier: WHEEL[segment], won: true }),
  },
};

function receipt(stake, multiplier, won) {
  if (!Number.isSafeInteger(stake) || stake < 1) throw new RangeError('stake must be a whole number of credits');
  if (!Number.isFinite(multiplier) || multiplier < 0) throw new RangeError('invalid multiplier');
  const returned = won ? Math.floor(stake * multiplier) : 0;
  return { stake, multiplier, returned, net: returned - stake, ruleVersion: RULE_VERSION };
}

async function bet(game, stake, selection) {
  const { result } = await play({
    game,
    params: rules[game].params,
    serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
    clientSeed: 'galabet',
    nonce: 42,
  });
  const { multiplier, won } = rules[game].settle(result, selection);
  return { result, selection, settlement: receipt(stake, multiplier, won) };
}

for (const [game, selection] of [['roulette', { color: 'black' }], ['roulette', { color: 'red' }], ['wheel', {}]]) {
  const { result, settlement } = await bet(game, 25, selection);
  console.log(game, result, JSON.stringify(selection), JSON.stringify(settlement));
}
console.log('segment 1 instead:', JSON.stringify(receipt(25, WHEEL[1], true)));

for (const stake of [1, 10, 25, 1000]) {
  const back = WHEEL.reduce((sum, multiplier) => sum + receipt(stake, multiplier, true).returned, 0);
  console.log(`wheel, stake ${stake}: ${back} back from ${stake * WHEEL.length} staked, return ${back / (stake * WHEEL.length)}`);
}
Output
roulette 20 {"color":"black"} {"stake":25,"multiplier":2,"returned":50,"net":25,"ruleVersion":"docs-example/1"}
roulette 20 {"color":"red"} {"stake":25,"multiplier":2,"returned":0,"net":-25,"ruleVersion":"docs-example/1"}
wheel 5 {} {"stake":25,"multiplier":2,"returned":50,"net":25,"ruleVersion":"docs-example/1"}
segment 1 instead: {"stake":25,"multiplier":1.5,"returned":37,"net":12,"ruleVersion":"docs-example/1"}
wheel, stake 1: 8 back from 10 staked, return 0.8
wheel, stake 10: 99 back from 100 staked, return 0.99
wheel, stake 25: 246 back from 250 staked, return 0.984
wheel, stake 1000: 9900 back from 10000 staked, return 0.99

One outcome, two settlements. Pocket 20 is black, so the same spin returns 50 to one bet and nothing to the other, and the record would be identical for both. The selection has to be stored with the receipt or the receipt can't be checked.

The Wheel line says won: true, and it would say so on a segment that pays 0. The demo settles Wheel, Plinko and Keno like this. There is no win or lose, only a multiplier, and a multiplier of 0 returns 0.

In the demo's responses the receipt travels as settlement, next to record and outside it. When receipts were added the GFS record was left as it was, and that is the right way round. The record is what gets hashed, signed and verified against the seeds. A stake and a payout aren't things the seeds can vouch for, so they don't belong inside it.

Whole Credits

The demo pays floor(stake × multiplier) and never a fraction of a chip. Segment 1 at a stake of 25 is 37.5 on paper and 37 in the balance.

The last four lines of the output play every segment once at each stake. At 10 and at 1,000 the table returns its published 0.99. At 25 it returns 0.984. At a stake of 1 it returns 0.8, because 1.5 and 1.2 both floor to 1.

Flooring always goes the house's way and it bites hardest at the smallest stake. When the unit is a cent it rarely shows. When the unit is a whole chip and the minimum bet is one of them, the return at the minimum is 0.8 against a published 0.99, and a rules page should carry both figures. The demo's doesn't. Its source has a comment saying the returns describe the tables before chip rounding, and the published catalog has no such line.

Stakes are whole numbers too. The demo's chipReceipt throws on a stake that isn't a safe integer of at least 1, and on a return too large to be one, which keeps floating point out of balances altogether.

Order of Operations

Every one-shot game in the demo's controller runs the same sequence.

  1. The body is validated against a strict schema. Stake is a whole number from 1 to 100,000, and anything unexpected in the body is a 400.
  2. The session lock is taken. A second action on the same session gets 409 here, before any chips have moved.
  3. The session is loaded, so an expired one answers 404 with the balance untouched.
  4. The stake is debited by a Lua script that checks the balance and subtracts in one step. Too few chips is a 402, not enough chips, and nothing has been derived.
  5. A nonce is reserved and play runs. The record goes into the player's history.
  6. The rules turn the outcome into a multiplier and a receipt.
  7. The payout is credited and the response goes out with the record, the receipt and the new balance.

Debit comes before derive for a plain reason. Reverse them and a player with an empty balance can make the server burn nonces and produce outcomes for bets that will never be paid for. The lock comes before the debit for a reason the project's QA notes spell out: acquire it after, and a request refused for overlapping another has already lost its stake. There is a test for that ordering, listed in those notes as "rejection before debit".

A Process That Dies Mid-Bet

Steps 4, 5 and 7 are three separate writes to Redis, and the demo has nothing that ties them together.

The process stops afterWhat is left behind
The debitStake gone. No nonce used, no record, nothing to show for it
The deriveStake gone, and a record in history that may show a win. The payout was never credited
The creditCorrect balance. The client never got the response and doesn't know the bet happened

For practice chips with a free refill, that is tolerable, and the QA notes say in as many words that durable settlement is follow-up work. For money none of the three rows is acceptable.

What makes recovery possible at all is that the outcome is a pure function of things you can store before you derive it. Write the bet down first: session, stake, selection, rule version, and the nonce you reserved, in the same database transaction as the debit. If the process dies, a sweeper finds the unsettled row, calls play with the stored nonce, gets the same outcome the dead process got, and finishes the settlement. The credit is then keyed by the round's identity, commitment plus client seed plus nonce, so that finishing it twice pays once. The demo already does that last part for Mines cash-outs, and Concealed games shows the script.

Retried Requests

The third row of that table is the common one. A phone loses signal, the app retries, and without protection the retry is a second bet.

The demo's answer is an Idempotency-Key header, handled by one interceptor on the whole games controller. Code for that stack belongs to the NestJS recipe. What follows is the behaviour, which is the part to copy whatever you build on. The client makes up a key per attempt, 8 to 128 characters from letters, digits, _ and -, and sends the same key again on retry. This request needs the NestJS API and Redis running on http://localhost:3000, and $SESSION holding an id from POST /api/demo/session:

Terminal
curl -X POST http://localhost:3000/api/demo/games/wheel \
  -H "content-type: application/json" \
  -H "x-demo-session: $SESSION" \
  -H "idempotency-key: 7b0c6c1e-wheel-0001" \
  -d '{"amount":25}'
SituationResponse
First time this key is seenThe handler runs. Its status and body are stored for 24 hours
Same key, same bodyThe stored response, with the header idempotency-replayed: true. The handler doesn't run
Same key, different body422, idempotency key reused with a different request body
Same key while the first request is still running409, request outcome pending; inspect the session before retrying
Definite HTTP 4xx refusalThe refusal is stored and replayed
Uncertain failure or HTTP 5xxKeep the pending marker; reconcile the session before sending another action
No headerThe request runs without this cache
Malformed keyHTTP 400

Keys are stored under the session id, the route and the key together, so two players can't collide and neither can two routes. "Same body" means the SHA-256 of the JSON body matches.

The in-flight marker is a SET NX with a 24-hour expiry, written before the handler starts. A matching retry cannot enter the handler while its outcome is uncertain. The response-cache write is awaited before a successful response is emitted. A definite 4xx refusal is cached; a storage failure, lost process or other uncertain failure leaves the marker in place for reconciliation. Do not clear such a marker and repeat a wager without inspecting its accepted round and balance.

This Redis interceptor is a bounded retry cache for the demo API, not a durable transaction spanning every service. After its retention window or a Redis restore, an old key is not a permanent uniqueness guarantee. The multiplayer Crash bet and payment operations also have atomic per-round guards. Production Galabet Flight uses a different boundary: its idempotency response, state, ledger and completed receipt share one Postgres transaction. See Flight Endpoint.