Game Rules: Ketapola Dice¶
Single source of truth for how the game plays. Player-facing terms, operator-facing SLA, and cert-lab (GLI-11) submission all cite this document.
Paired documents:
- par-sheet.md, math model, RTP, volatility.
- rng-spec.md, cryptographic outcome derivation.
- error-codes.md, platform-wide status codes.
Any change to this document is a regated change, see change-management.md.
1. Game overview¶
Ketapola Dice (කැටපොල) is a Sri Lankan single-die wagering game. The die has six faces split into two sides:
A player wagers on a side (LOW or HIGH). If the rolled face belongs to that side, the player wins. The specific face value (3 vs 6 vs 9 within LOW) does not affect payout, only the side matters.
2. Round lifecycle¶
Each round proceeds through the following phases. Durations are operator- configurable; defaults shown.
| Phase | Default duration | Player can… | Server action |
|---|---|---|---|
PENDING |
transient | , | Allocate round id, snapshot seed state |
BETTING_OPEN |
15 000 ms | place a bet | Accept bets, debit wallets on success |
ROLLING |
4 000 ms | , | Derive outcome via determineOutcome |
RESULT |
transient | , | Emit result to all listeners |
SETTLED |
terminal | , | Credit winning bets to operator wallets |
VOIDED |
terminal | , | Refund every accepted bet via rollback |
Between rounds: a cooldown phase (default 3 000 ms) separates consecutive rounds.
Phase transitions emit round_state events over Socket.IO. The terminal
events are round_result (on SETTLED) and round_voided (on VOIDED).
Configurable per operator per currency on OperatorGameConfig:
bettingWindowMs, rollingWindowMs, cooldownMs.
3. Placing a bet¶
3.1 Eligibility¶
A bet is accepted if all of the following hold:
- The player's session is active (not terminated, not expired).
- The current round phase is
BETTING_OPEN. minBetMicro ≤ amountMicro ≤ maxBetMicrofor the operator's config.- The player has not already placed a bet in this round (one bet per player per round).
- The operator's RG limits (daily loss, daily wager, session-time) allow the
additional stake, enforced by
RGLimitsEnforcer.ts. - The operator wallet returns
RS_OKto the outbound/wallet/betcall.
3.2 Inputs¶
interface PlaceBetInput {
side: 'LOW' | 'HIGH';
amountMicro: bigint; // stake in micro-units (× 100,000 of major unit)
}
3.3 Rejection reasons¶
Every rejection emits a bet_rejected event with a reason code:
| Code | Meaning |
|---|---|
bet_out_of_range |
Stake is below min or above max for this operator/currency |
session_not_found |
Session referenced by socket no longer exists in DB |
session_terminated |
Operator forced termination (self-exclusion, timeout, risk) |
session_expired |
JWT exp passed |
phase_not_open |
Bet arrived outside the BETTING_OPEN window |
already_bet_this_round |
Player already has one bet on this round |
rg_limit_reached |
Responsible-gambling limit would be breached |
wallet_rejected:<RS_*> |
Operator wallet returned a rejection status |
wallet_timeout |
Outbound /wallet/bet exceeded WALLET_CALL_TIMEOUT_MS; synthetic rollback scheduled |
invalid_payload |
Zod validation of input failed (non-recoverable client bug) |
4. Payout¶
4.1 Formula¶
Let c = commissionMicro / MICRO_PER_UNIT (unitless fraction; default 0.03).
If outcome.side == player.side:
gross_payout = 2.0 * stake
commission = gross_payout * c
net_payout = gross_payout - commission
= (2.0 - 2.0 * c) * stake
wallet /win credits net_payout
player net gain = net_payout - stake = (1.0 - 2.0 * c) * stake
If outcome.side != player.side:
no payout, no /win call
player net gain = -stake (already debited at bet time)
At the default configuration (payoutMultiplier = 2.0, c = 0.03):
| Stake | On win (net payout) | Player net gain on win | Player net gain on loss |
|---|---|---|---|
| 100.00 LKR | 194.00 LKR | +94.00 LKR | −100.00 LKR |
| 1 000.00 LKR | 1 940.00 LKR | +940.00 LKR | −1 000.00 LKR |
4.2 Free bets¶
A free bet is one where the operator passes isFree: true on the outbound
/wallet/bet call (the operator's accounting flags this; the RGS does not
mint free bets). Behaviour:
| Event | Regular bet | Free bet |
|---|---|---|
| Stake debited on bet | yes | no |
| Payout credited on win | net payout | net payout |
| Player net gain on win | net_payout − stake |
net_payout |
| Player net gain on loss | −stake |
0 |
Free-bet accounting is the operator's responsibility; the RGS preserves the
isFree flag on every Bet and WalletCall row for audit.
4.3 Rounding¶
All money is in bigint micro-units end-to-end. There is no floating-point
arithmetic in the settlement path. Commission is computed as
(gross × commissionMicro) / MICRO_PER_UNIT with bigint division, which
truncates. The truncated remainder accrues to the operator (favouring the
house at the fifth decimal place), matching standard cert-lab practice.
5. Outcome derivation¶
See rng-spec.md for the formal specification. In brief:
outcome = HMAC_SHA256( serverSeed, clientSeed + ":" + nonce )
first 32 bits → side (weighted LOW/HIGH)
next 32 bits → face index within side (mod 3)
A player can verify every outcome post-hoc via GET /v1/rounds/:id/proof
after the round settles or voids. See
provably-fair.md for the 20-line verifier.
6. Void and refund conditions¶
A round transitions to VOIDED (not SETTLED) in any of these cases:
- Outcome derivation failure:
rng.determineOutcomethrows (should be impossible given schema validation; defensive). - Server crash during ROLLING with no persisted outcome: on startup
recovery,
GameEngine.resumeUnsettledRounds()detectsRound.settled = falsewith nooutcomeand voids it. - Operator-forced termination mid-round:
POST /v1/session/:id/terminatewhile round inBETTING_OPENorROLLINGvoids any bets belonging to that session.
When a round is voided:
- Every
Betwith statusACCEPTEDon that round is flipped toVOIDED. - A
PendingWalletJobof typeROLLBACKis enqueued for each, referencing the originalbetTransactionUuid. - The corresponding
PendingRoundBettransitionsHELD → REFUNDEDwithresolutionReason = 'ROUND_VOIDED'.
The operator wallet will receive a /wallet/rollback call for each bet,
eventually (retried with exponential backoff until accepted or ticketed by
reconciliation).
Invariant: no money leaves the operator permanently on a voided round. Every debit is reversed.
7. Disconnect handling¶
A player may drop their connection at any time. The game's behaviour is:
| When disconnect happens | Behaviour |
|---|---|
Before place_bet in round N |
No effect, no bet was placed |
After place_bet ack, before round end |
Bet remains in force. Round completes normally. Settlement fires against the operator wallet. Player can reconnect (same token) and see the result. |
After round_result, before SETTLED |
Settlement fires regardless of connection state |
| Mid-round, server crashes | See §6, round either resumes (outcome known) or voids (outcome unknown) |
Sessions are durable. Disconnect does not cancel or void a session. Session
termination is an explicit operator action (/terminate) or token
expiration.
8. Session lifecycle¶
8.1 Creation¶
POST /v1/session: operator-signed. See wallet-api.md
and Appendix B of B2B_ROADMAP.md.
Creates a GameSession row bound to:
- one
operatorId - one
playerRef(opaque operator-side id) - one
currency - one
gameCode(e.g.ketapola-dicefor this spec; other plugins ship ascrash-minimaletc.) - one
serverSeed+serverSeedHash - one
clientSeed(operator- or RGS-provided)
8.2 Lifetime¶
- Max JWT
exp: 60 minutes from creation (config: session JWT expiry). - Max session duration (enforced): 60 minutes (cert-jurisdiction dependent).
- Seed rotation: player-initiated. Rotating produces a new server seed; the
old seed is revealed via
/prooffor every round that used it.
8.3 Termination¶
A session ends by:
- JWT
exppassing →session_expiredon next bet attempt. - Operator-forced termination (
POST /v1/session/:id/terminate). - Player closing the game, the RGS does not eagerly terminate on socket
disconnect; sessions remain reconnectable until
exp.
On termination:
GameSession.terminatedAtis set.- Any in-flight round belonging to that session voids (§6).
- The
serverSeedof the session is revealed via/proofendpoints for every historical round.
9. Responsible gambling¶
RG limits are carried as claims in the session JWT and enforced at bet time
by services/RGLimitsEnforcer.ts:
| Claim | Unit | Check |
|---|---|---|
dailyLossMicro |
micro-units per 24h | Reject bet if sum(lost_stakes_last_24h) + stake > limit |
dailyWagerMicro |
micro-units per 24h | Reject bet if sum(wagers_last_24h) + stake > limit |
sessionTimeSeconds |
seconds | Reject bet if now − sessionStart > limit |
A rejection here surfaces to the client as bet_rejected / reason:
rg_limit_reached, and the RGS does not attempt the outbound /wallet/bet.
Self-exclusion is an operator-side concern; the RGS relies on the operator to not issue a session JWT for a self-excluded player.
10. Edge cases and tie-breaking¶
10.1 Two players bet different sides, same round¶
Independent, each bet settles against its own operator wallet entry. No cross-player tie exists; the die outcome decides both independently.
10.2 Weight change mid-session¶
If an operator edits lowWeight / highWeight via the portal during a live
session, the edit is logged in OperatorConfigAuditLog. The engine picks up
the new weights on the next round (not retroactively applied). Mid-round
edits are impossible, the round snapshots (lowWeight, highWeight) at
PENDING.
10.3 Operator wallet returns a non-standard status¶
Unrecognised RS_* codes are treated as uncertain: the classifier
defaults to the retry-with-rollback path. This is the safe-by-construction
default: never double-spend, always reconcile.
10.4 Duplicate transactionUuid on /wallet/bet¶
Operator responds RS_ERROR_DUPLICATE_TRANSACTION. This is classified as
success (the operator has already processed the transaction). The RGS
does not retry, does not emit a rejection, and the bet proceeds as if
accepted.
10.5 amountMicro = 0¶
Rejected as bet_out_of_range: zero stakes are not permitted regardless of
operator config. (Even if minBetMicro is configured to 0, the Zod schema
rejects zero at the outer boundary.)
10.6 Currency mismatch¶
The session JWT binds a currency. A bet payload without an explicit currency inherits the session currency. The RGS does not support switching currency mid-session; the operator must issue a new session.
11. Spec version¶
| Field | Value |
|---|---|
| Spec version | 1.0.0 |
| Last reviewed | 2026-04-23 |
| Source of truth for rules | This document |
| Source of truth for math | par-sheet.md |
| Source of truth for RNG | rng-spec.md |