Wager, escrow, and settle

You need: multiplayer, ledger and auth composed.

The shape

Holds and settlement is the ledger model underneath, idempotency refs is why a retry cannot double-charge, randomness and wagering is what makes the outcome auditable, and the pattern helpers are what call it.

Hold the stake when the bet is placed. Capture or release when the outcome lands.

await ledger.hold("alice", "chips", 100, `bet:${sessionId}:alice`);
// …
await ledger.capture(`bet:${sessionId}:alice`);   // she lost
await ledger.release(`bet:${sessionId}:alice`);   // pushed

A hold does not move the balance. It raises held, which lowers available — so the stake is unspendable without having been spent, and the outcome can still go either way.

Inside a game, you declare rather than call

A model’s apply and resolve are pure — they cannot touch a database.

So a wagering model declares effects and the session settles them afterwards:

{ op: "hold",    userId, currency, amount, ref }
{ op: "capture", ref, amount?, memo? }
{ op: "release", ref }
{ op: "credit",  userId, currency, amount, ref, memo? }

That is what keeps the model deterministic — a requirement for replay and provable fairness — while still letting a game hold a stake, capture a loss, or pay a win.

Refs are the whole safety property

Build them from the session and the game’s own state:

`${ctx.sessionId}:round-3:alice:stake`

Because the model is deterministic, a replayed transition re-emits the same refs — so applying them twice is a no-op and a payout pays once.

An unaffordable hold rejects the action

The transition is never persisted.

That is the correct order: the ledger’s CHECK (held <= balance) is the authority on whether a stake is affordable, and the game does not get to record a bet the player could not make.

The refusal surfaces as ledger/insufficient_funds.

Settling a whole session

for (const loser of losers) {
  await ledger.capture(`bet:${sessionId}:${loser}`);
}
for (const winner of winners) {
  await ledger.release(`bet:${sessionId}:${winner}`);
  await ledger.credit(winner, "chips", payout, `payout:${sessionId}:${winner}`);
}

Every ref names the session and the player, so the whole settlement is replayable and a retried result settles once.

A persistent table

mode: "table" is long-lived — active from creation, players joining and leaving between rounds.

{ key: "craps", kind: "craps", mode: "table", players: 8,
  rules: { currency: "chips", minBet: 5, maxBet: 100 } }

onJoin and onLeave are where a table settles somebody in or out — release their open holds, cash them out, deal them in next round.

Provable fairness comes with the session

Every session mints a seed at creation and commits its SHA-256 hash up front, shown to players before a single die is rolled. The seed is revealed when the session ends.

An auditor hashes the revealed seed against the commitment and replays the stream to verify every roll.

Draw from ctx.random — only in init and apply.

An unsettled hold is a question worth asking

Open holds that have outlived their outcome are the reconciliation query to run.

Nothing expires them for you, on purpose. A hold that timed out on its own would release a stake the game may still be settling — which is the one thing worse than a stuck hold.

The regulation is yours

Pithy provides the mechanics. How any of this maps to real money, and what that implies, is your concern — see regulatory concerns.

ESC