DocsBuilding a backend

Concealed Games

How a server runs a round whose result has to stay hidden while the player is still making choices, shown with an in-memory Mines round and the rules Galabet's demo API enforces around it.

A Dice record can go to the player the moment the bet is placed, because by then it tells them nothing they can use. Some games aren't like that. In Mines the result is the board. In Blackjack and Hi-Lo it's the order of the deck. Hand either over at the start and the player is no longer guessing.

The fairness scheme doesn't change for these games. The outcome is still derived once, from the committed seed, the client seed and one nonce, before the player's first choice. What changes is the server's bookkeeping between that moment and the end of the round.

Galabet's demo API does this for Mines and for nothing else. Blackjack and Hi-Lo on this site are practice pages that work the deck out in the browser, and no server-side engine for them exists in this project. The card section at the end says what such an engine would add.

Records Published at Bet Time

The demo has one function that derives a record, and in an earlier version it did the same thing for every game: reserve a nonce, call play, build the record, push it onto the player's history list. For Dice that is correct. Mines went through it like the rest, so the full record, mine positions included, was sitting in GET /api/demo/history from the moment the board was created. The game screen hid the board and the history endpoint printed it.

Nobody needed to break anything to read it. That's what makes this the mistake to look for in your own code: the concealment was done properly in the one place everybody was looking, and the record left by another door.

The fix, part of a hardening pass on 20 September 2026, gave that function a publish flag. Mines passes false and publishes the record itself when the round ends. The history endpoint also got a second guard, which blanks result on any record matching a live board, so that boards started before the change were covered too.

A Mines Round in Memory

Everything the pattern needs fits in one file. The seed is the public one so the output is the same on every run, and the clock is fixed for the same reason. Two rounds with three mines each and a stake of 100, from a starting balance of 1,000. The first is cashed out and the second is given up.

mines-round.mjs
import { commit, play, verifyRecord } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const session = { serverSeed, commitment: (await commit(serverSeed)).commitment, clientSeed: 'galabet', nextNonce: 42 };
const now = () => 1790000000000; // fixed so the output never changes

let chips = 1000;
let round = null;           // server side only
const history = [];         // what the player can fetch
const credited = new Map(); // one marker per paid round, holding what was paid
let busy = false;

async function locked(action) {
  if (busy) throw new Error('another action is being processed');
  busy = true;
  try { return await action(); } finally { busy = false; }
}

function multiplier(mines, picks) {
  let survive = 1;
  for (let i = 0; i < picks; i++) survive *= (25 - mines - i) / (25 - i);
  return Math.floor((0.99 / survive) * 10000) / 10000;
}
function chipPayout(stake, multiplier) { // whole ten-thousandths, then integer division
  const total = stake * Math.round(multiplier * 10000);
  return (total - (total % 10000)) / 10000;
}

function view() {
  const { record, ...rest } = round;
  const { result, ...redacted } = record;
  return { ...rest, record: round.over ? record : redacted };
}
const marker = () => `mines:${round.record.commitment}:${round.record.clientSeed}:${round.record.nonce}`;

function end(outcome, payout) { // publish the record, then close the board
  if (!history.includes(round.record)) history.push(round.record);
  Object.assign(round, { over: true, outcome, payout });
  return { ...view(), boom: outcome === 'mine', payout };
}
function live() { // a board that can still be played
  if (round.over) throw new Error('game is over');
  if (credited.has(marker())) throw new Error('round already cashed out; repeat the cash-out to close the board');
}

const start = (amount, mines) => locked(async () => {
  if (round && !round.over) throw new Error('finish the current game first');
  if (chips < amount) throw new Error('not enough chips');
  chips -= amount;
  const nonce = session.nextNonce++;
  const { result, cursor } = await play({ game: 'mines', params: { mines }, serverSeed, clientSeed: session.clientSeed, nonce });
  const record = {
    spec: 'GFS/1.0', profile: 'single-player', game: 'mines', params: { mines },
    commitment: session.commitment, clientSeed: session.clientSeed, nonce, cursor, result, at: now(),
  };
  round = { record, amount, mines, picks: [], over: false };
  return view();
});

const pick = (tile) => locked(async () => {
  live();
  if (round.picks.includes(tile)) throw new Error('tile already picked');
  if (round.record.result.includes(tile)) return end('mine', 0);
  round.picks.push(tile);
  return { ...view(), boom: false, multiplier: multiplier(round.mines, round.picks.length) };
});

const cashout = ({ dieAfterCredit = false } = {}) => locked(async () => {
  if (round.over) throw new Error('game is over');
  if (!credited.has(marker())) {
    if (round.picks.length === 0) throw new Error('pick at least one tile first');
    const payout = chipPayout(round.amount, multiplier(round.mines, round.picks.length));
    credited.set(marker(), { picks: [...round.picks], payout }); // one script with the credit
    chips += payout;
  }
  if (dieAfterCredit) throw new Error('process died after the credit');
  const receipt = credited.get(marker()); // a retry reports what was paid
  round.picks = receipt.picks;
  return end('cashout', receipt.payout);
});

const forfeit = () => locked(async () => {
  live();
  return end('forfeit', 0);
});

const rotate = () => locked(async () => {
  if (round && !round.over) throw new Error('finish the active Mines board before changing or revealing seeds');
  return { revealed: session.serverSeed };
});

const attempt = (promise) => promise.then(() => 'ok', (error) => `error: ${error.message}`);

console.log('start   ', JSON.stringify(await start(100, 3)));
console.log('history ', history.length, 'chips', chips);
console.log('rotate  ', await attempt(rotate()));

const both = await Promise.all([attempt(pick(0)), attempt(pick(1))]);
console.log('two at once:', both);
const { record: during, ...answer } = await pick(1);
console.log('pick 1  ', JSON.stringify(answer), '| result sent:', 'result' in during);

console.log('cashout ', await attempt(cashout({ dieAfterCredit: true })), '| chips', chips, '| history', history.length);
console.log('pick 2  ', await attempt(pick(2)));
console.log('forfeit ', await attempt(forfeit()));
const paid = await cashout();
console.log('retry    payout', paid.payout, '| outcome', paid.outcome, '| chips', chips, '| history', history.length);
console.log('board   ', paid.record.result);

await start(100, 3);
console.log('start    nonce', round.record.nonce, '| chips', chips, '| rotate', await attempt(rotate()));
const lost = await forfeit();
console.log('forfeit  payout', lost.payout, '| outcome', lost.outcome, '| boom', lost.boom, '| chips', chips, '| history', history.length);
console.log('cashout ', await attempt(cashout()));

const { revealed } = await rotate();
for (const record of history) {
  console.log('nonce', record.nonce, 'verifies after rotation:', (await verifyRecord({ ...record, serverSeed: revealed })).ok, record.result);
}
Output
start    {"amount":100,"mines":3,"picks":[],"over":false,"record":{"spec":"GFS/1.0","profile":"single-player","game":"mines","params":{"mines":3},"commitment":"ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7","clientSeed":"galabet","nonce":42,"cursor":2,"at":1790000000000}}
history  0 chips 900
rotate   error: finish the active Mines board before changing or revealing seeds
two at once: [ 'ok', 'error: another action is being processed' ]
pick 1   {"amount":100,"mines":3,"picks":[0,1],"over":false,"boom":false,"multiplier":1.2857} | result sent: false
cashout  error: process died after the credit | chips 1028 | history 0
pick 2   error: round already cashed out; repeat the cash-out to close the board
forfeit  error: round already cashed out; repeat the cash-out to close the board
retry    payout 128 | outcome cashout | chips 1028 | history 1
board    [ 9, 17, 22 ]
start    nonce 43 | chips 928 | rotate error: finish the active Mines board before changing or revealing seeds
forfeit  payout 0 | outcome forfeit | boom false | chips 928 | history 2
cashout  error: game is over
nonce 42 verifies after rotation: true [ 9, 17, 22 ]
nonce 43 verifies after rotation: true [ 7, 8, 15 ]

The first line is everything the player gets at the start: the stake, the mine count, and a record with the commitment, client seed, nonce 42 and cursor 2 but no result. History is empty and the stake has already left the balance, so 900 chips.

Rotation is refused while the board is live. Two picks sent together don't both run: the second is turned away by the lock, and when it is sent again it succeeds and the multiplier stands at 1.2857 for two safe tiles out of two.

Then the cash-out is made to fail at the worst moment, after the chips were credited and before the round was marked over. The balance is 1,028 and the board is still open, but it can't be played. A pick and a forfeit both get the same refusal, because the round's marker exists, and the message tells the client what to send instead. The retry pays nothing more. It reads 128 back from the marker, leaves the balance at 1,028, and only now does the record reach history, with mines at 9, 17 and 22. Those are the positions the Mines page derives from the same public inputs.

The second board, nonce 43, is given up without a pick. The forfeit pays 0, reports boom as false because no mine was hit, and publishes the record. The balance stays at 928, and a cash-out sent afterwards is game is over. After rotation both records verify, the forfeited one with its mines at 7, 8 and 15.

The sections below take the pieces one at a time, with the demo's real code where it differs from this sketch.

What Is Stored and What Is Sent

Kept by the serverSent while the round is liveSent when it is over
record.resultYesNoYes
Rest of the recordYesYesYes
Stake, mine count, picks so farYesYesYes
outcome and payoutOnce the round endsAbsentYes
Current and next multiplierCalculatedYesCurrent only
serverSeedIn the sessionNoNo, until rotation

The record the player sees during play still carries the commitment, their client seed and the nonce. Those are the three things that pin the board down in advance, and the player should save them before the first pick. The cursor goes out as well. For Mines it is always 2, whatever the mine count, so it gives nothing away.

outcome is "cashout", "mine" or "forfeit", and it stays on the stored board after the round, so GET /api/demo/games/mines can say how the last one ended. Boards saved before the field existed have neither it nor payout.

The demo's redaction sets result to undefined and relies on JSON.stringify dropping the key. That works, and it depends on the serialiser. If yours writes null for undefined values the field is still empty, but check that it is, with a test that reads the bytes of a live response and looks for the mine positions.

Answering a Pick

mines.service.ts (needs NestJS and Redis)
private async end(
  sid: string,
  g: MinesGame,
  outcome: NonNullable<MinesGame["outcome"]>,
  payout: number,
) {
  g.over = true;
  g.outcome = outcome;
  g.payout = payout;
  await this.demo.publishRecord(sid, g.record);
  await this.save(sid, g);
  return { ...this.view(g), boom: outcome === "mine", payout };
}

private async live(sid: string) {
  const g = await this.load(sid);
  if (g.over) throw new BadRequestException("game is over");
  if (await this.chips.creditReceipt(sid, this.round(g)))
    throw new BadRequestException(SETTLED);
  return g;
}

async pick(sid: string, tile: number) {
  return this.demo.withSession(sid, async () => {
    const g = await this.live(sid);
    if (g.picks.includes(tile))
      throw new BadRequestException("tile already picked");
    const layout = g.record.result as number[];
    if (layout.includes(tile)) return this.end(sid, g, "mine", 0);
    g.picks.push(tile);
    const allSafe = g.picks.length === 25 - g.mines;
    if (allSafe) return this.finish(sid, g);
    await this.save(sid, g);
    return { ...this.view(g), boom: false };
  });
}

The board is read from the stored record. play is never called a second time for the same round, so there is no path on which a pick could be answered from a different board than the one the record describes.

live is the gate for anything that would change the board. It refuses a finished board with game is over, and a board whose round already has a credit marker with SETTLED, the message round already cashed out; repeat the cash-out to close the board. That second case is a cash-out that was paid and never got as far as closing the board, and the next section is about it.

tile has already been through a schema by this point, an integer from 0 to 24 in a strict object, so nothing odd reaches includes. A tile can be picked once. And when the player has opened every safe tile the round closes itself and pays, since there is no choice left to make.

end is the one way a round closes, whether by a mine, a cash-out or a forfeit. It publishes the record first and saves the board as over afterwards. If the save fails, the record is already in the history list, and the history endpoint blanks its result for as long as the stored board still reads as live, so the retry neither loses the record nor shows the board early.

The whole game lives in one JSON value, demo:mines:<session>, which is read, changed and written back. That is the pattern that lost a nonce on the seed storage page. Here it's safe only because every action on a session runs inside withSession, a Redis lock that refuses a second action with 409 while the first is running. The two at once line in the example is that refusal. The lease is 30 seconds. If an action ever took longer than that, the lock would lapse and two picks could interleave, which is on the list at the bottom of this page.

Rotation and Client Seed Changes

Rotation publishes the server seed. The client seed and the nonce are already public, and the three together are the board. So while a round is live the demo answers 400 to a rotation, with the message the example borrowed. Rotation and reveal lists this among the conditions for any reveal.

Changing the client seed gets the same refusal, because in the demo a new client seed retires the server seed, and if bets were placed under it, reveals it on the spot.

A second start is refused too, with finish the current game first. One live round per session keeps the state to a single key.

Giving Up a Board

mines.service.ts (needs NestJS and Redis)
async forfeit(sid: string) {
  return this.demo.withSession(sid, async () =>
    this.end(sid, await this.live(sid), "forfeit", 0),
  );
}

A player who walks away from a board leaves it open, and while it's open they can't rotate or change their client seed. POST /api/demo/games/mines/forfeit is the way out. It ends the round as a loss through the same end as a mine: payout 0, outcome: "forfeit", boom: false since no mine was touched, and the record published to history. The stake stays gone, and seed changes are allowed again.

A forfeit needs no picks, where a cash-out needs at least one. It goes through live, so it can't be used on a finished board, and it can't turn a paid round into a loss either. In the example the forfeit sent after the interrupted cash-out gets the already cashed out refusal.

Nothing forfeits a board on a timer. The board key is linked to the session in the seed store, so it expires no earlier than the session does, which by default is a day after the last request that loaded the session. If they never come back, it goes with the session and the unrevealed seed, and its record is never published. With practice chips nobody is hurt. With stakes, close idle rounds under a rule you've published and publish their records like any other.

Crediting Once

The cash-out in the example dies after the credit and before the round is marked over. The retry finds the round still open and doesn't pay again, because the first attempt left a marker. In the demo the marker and the credit happen inside one Lua script, so there is no moment when one exists without the other:

creditOnce (needs Redis; KEYS[1] balance, KEYS[2] marker; ARGV amount, starting chips, ttl, receipt)
local bal = redis.call('GET', KEYS[1])
if not bal then bal = ARGV[2]; redis.call('SET', KEYS[1], bal, 'EX', ARGV[3]) end
local prior = redis.call('GET', KEYS[2])
if prior then return {tonumber(bal), prior, 0} end
local next = redis.call('INCRBY', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[3])
redis.call('SET', KEYS[2], ARGV[4], 'EX', ARGV[3])
return {next, ARGV[4], 1}

The marker key is demo:credited:<session>:mines:<commitment>:<clientSeed>:<nonce>. Commitment, client seed and nonce identify one derivation and can never repeat inside a session, which makes them a better round id than anything generated at request time. A request id changes on retry. This doesn't.

The marker holds a receipt, ARGV[4], which is JSON.stringify({ ...detail, payout: amount }). Mines passes its picks as the detail. The script answers with the balance, the marker's contents and whether this call was the one that paid, and creditOnce hands them back as { balance, credited, receipt }. creditReceipt reads a marker without crediting anything, and it's what live asks before a pick or a forfeit, and what the cash-out asks before it pays.

A retried cash-out therefore reports the stored payout and restores the stored picks, rather than working either out again from the board. The picks matter in one case. When the last safe tile is picked, the round pays itself inside the pick, and if the board write then fails the saved board doesn't have that pick. The API's state tests cover it with a 24-mine board: the one safe pick is credited at 2,475 chips on a stake of 100, the board write is made to fail, and the cash-out that follows closes the board with that pick and that payout.

An earlier version stored the string 1 as the marker. After a credit that wasn't followed by a save, the player could go on picking tiles before retrying, and the retry recalculated payout from the picks it saw, so it could report more than had been paid. Both halves of that are closed. A marker left by that version still reads as paid, with a payout of null, and the cash-out then falls back to working the amount out from the board.

Settling bets covers the same ordering question for games that finish in one request.

Card Games

A Blackjack or Hi-Lo record's result is the whole shuffled deck, so everything above applies with "board" replaced by "deck". A card engine then has three jobs Mines doesn't. It reveals a prefix of the result that grows by one card per action, where Mines reveals nothing until the end. It has to refuse illegal actions, because which cards get dealt depends on what the player did. And it has to keep a transcript of those actions, since the record proves the deck and says nothing about hit or stand.

None of that is written here. The demo's POST /api/demo/bet hands back a Blackjack or Hi-Lo record with all 52 cards in it, which is tolerable only because its published rules mark both games settlement: false. The game pages say more under Withholding the Deck During a Hand and Withholding the Deck.

What the Demo Does Not Do

The project's own QA notes are blunt that this hardening is partial, and the same list applies to anyone copying the pattern.

Credit, history and board state are three separate writes. They happen in that order, the marker makes the credit safe to retry, publishRecord skips a record already in the list, and the history endpoint hides a published record while its board is live. But nothing makes the three succeed or fail together. A database transaction would.

The credit marker expires one session TTL after the credit, a day by default, and isn't renewed with the session. If a cash-out is paid, the board write fails and the player keeps the session alive for longer than that without retrying, the board becomes playable again and a second cash-out would be paid. We read that from the code and haven't reproduced it.

The lock is a 30 second lease with no renewal. Nothing recovers a round whose action outlived it.

Most of the tests for all of this, including concurrent cash-outs, the interrupted cash-out and forfeit, run against an in-memory double of the Redis commands. Two in apps/api/test/demo-redis.test.ts run the real scripts against a Redis when REDIS_TEST_URL is set, one of them through an interrupted cash-out, its retry and a forfeit, and both passed against the project's container. None has been run with several API workers.

And there are no durable receipts for picks. The stored game holds the list of picked tiles, not when each was made or what the server answered.