Flow: dropbet house game mechanics — dice vs limbo¶
Dice trace:
d3a9f385225bd39cd51948495da64d55· Limbo trace:cb0c90f550d18a57c6b0a6ba66b02be1· Jaeger: http://localhost:16686/ · E2E:tests-e2e/tests/dropbet-house-game.spec.tsGenerated: 2026-04-16 · Author: prisma-otel-engineer · Services traced:ebit-api. Fairness commitment verified end-to-end (4 bets locally recomputed against the revealedserverSeed). Scope: this doc covers the per-game mechanics that diverge inside the otherwise-shared bet pipeline (RNG, multiplier math, win condition, payload, RTP knob). The pipeline plumbing — controller routing,BetService.createAndSettleBet, accounting,BET_SETTLED_QUEUE, rt fan-out — is documented indropbet-bet-place.md(task #31).
1. User-visible contract¶
Both games are POST-only, JWT-authenticated, per-user-throttled under the bets bucket. They share BaseBetRequestDto's currencyId: CurrencySymbolBalance (e.g. 'DBC') and windowId (limbo's controller forwards windowId to EvoContext.setWindowId for tracing — dice does not).
| Dice | Limbo | |
|---|---|---|
| Endpoint · throttle | POST /casino/games/house/dice/bet · 25/5s |
POST /casino/games/house/limbo/bet · 30/5s |
| Request DTO | DiceBetRequestDto (dto/dice.dto.ts:10-22): betAmount, threshold ∈ [0.01,99.99], above:boolean |
LimboBetRequestDto (limbo.dto.ts:22-30): betAmount, multiplier ≥ 1.01 |
| Response DTO | betId, currencyId, betAmount, payout, multiplier, randomValue ∈ [0,100], threshold, above, didWin |
betId, currencyId, betAmount, payout, multiplier, randomMultiplier ∈ [1.0,∞) (no explicit didWin) |
| Win condition | randomValue > threshold (above) / < threshold — strict (dice.service.ts:54-56) |
randomMultiplier >= userMultiplier — inclusive (limbo.service.ts:56) |
| Multiplier on win | (100 / chance) * rtp, ≥ 1.01, ≤ maxMultiplier. chance = above ? 100-thr : thr. (dice.utils.ts:7-30) |
userMultiplier itself — limbo's multiplier field is the player's target. (limbo.service.ts:57) |
| Payout | betAmount * multiplier on win else 0 |
betAmount * multiplier (0 on loss) |
| Config | GET /casino/games/house/dice/config |
GET /casino/games/house/limbo/config |
2. Sequence diagram (per-game contrast)¶
The shared pipeline (betService.createAndSettleBet → accounting → prisma:client:transaction with the 9 ops → BET_SETTLED_QUEUE) is identical and is collapsed into a single arrow here. What differs is the prelude: how each game derives its randomValue / randomMultiplier and the multiplier it hands to houseOperation.
sequenceDiagram
participant U as Browser
participant DC as DiceController.bet
participant DS as DiceService.play
participant LC as LimboController.bet
participant LS as LimboService.play
participant PF as ProvablyFairService.popUserSeed
participant RNG as RngGames.{getRandomDice,getRandomLimbo}
participant SP as Shared bet pipeline
alt Dice
U->>DC: POST /dice/bet { threshold, above, betAmount }
DC->>DS: play(dto, user) [@PlaceBetLock]
DS->>PF: popUserSeed(userId) — nonce++
PF-->>DS: { serverSeed, clientSeed, nonce, hashedServerSeed }
DS->>RNG: getRandomDice({ s, c, n })
RNG-->>DS: randomValue ∈ [0.00, 100.00]
DS->>DS: didWin = above ? rv > thr : rv < thr (strict)
DS->>DS: multiplier = (100 / chance) * rtp, ≥ 1.01, ≤ maxMultiplier
DS->>SP: createAndSettleBet(houseOperation: { withdrawAmount, depositPayout, ...}, payload)
else Limbo
U->>LC: POST /limbo/bet { multiplier, betAmount }
LC->>LS: play(dto, user) [@PlaceBetLock]
LS->>PF: popUserSeed(userId) — nonce++
PF-->>LS: same shape as dice
LS->>RNG: getRandomLimbo({ s, c, n, rtp })
RNG-->>LS: randomMultiplier ≥ 1.00
LS->>LS: isWin = randomMultiplier >= userMultiplier (inclusive)
LS->>LS: multiplier = isWin ? userMultiplier : 0
LS->>SP: createAndSettleBet(...) [liveBetDelayMs:2000 instead of dice's notificationDelayMs:100]
end
SP-->>U: 201 + game-specific response DTO
3. Component diagram¶
Edges are numbered in request-flow order. Section §4 below has the same numbers — each (N) on the diagram has its own §4.N subsection, so you can click straight through. This diagram covers only the per-game prelude that diverges between dice and limbo; the shared bet pipeline that both vectors hand off to (BetService.createAndSettleBet → accounting → BET_SETTLED_QUEUE → rt fan-out) is drawn in dropbet-bet-place.md §3 and not redrawn here.
flowchart TD
%% Datastores
pg[("Postgres<br/>UserFairnessSeeds · UserInactiveFairnessSeeds<br/>· Bet.payload (game-specific)")]
%% NestJS process — per-game prelude
subgraph api["ebit-api (NestJS) — per-game prelude"]
dctrl["DiceController.bet<br/><i>POST /casino/games/house/dice/bet · 25/5s</i>"]
dsvc["DiceService.play<br/><i>@PlaceBetLock + PrismaTransactional</i>"]
lctrl["LimboController.bet<br/><i>POST /casino/games/house/limbo/bet · 30/5s</i>"]
lsvc["LimboService.play<br/><i>@PlaceBetLock + PrismaTransactional</i>"]
pf["ProvablyFairService.popUserSeed<br/><i>upsert {nonce: increment 1}</i>"]
rng["RngGames<br/><i>getRandomDice / getRandomLimbo(rtp)</i>"]
rngcore["Rng.createRandomGenerator<br/><i>HMAC-SHA256 → 4-byte → float</i>"]
dutil["calculateDiceMultiplier<br/><i>(100 / chance) * rtp, ≥ 1.01, ≤ max</i>"]
common["HouseGameBetPayload.create<br/><i>multiplier · gameSeedNonce · hashedServerSeed · params</i>"]
end
%% Shared pipeline — drawn in dropbet-bet-place.md §3
subgraph shared["Shared bet pipeline (see dropbet-bet-place.md §3)"]
bet["BetService.createAndSettleBet<br/><i>accounting + Bet INSERT + BET_SETTLED_QUEUE</i>"]
end
%% (1)-(6) Dice prelude
dctrl -- "(1) play(dto, user)" --> dsvc
dsvc -- "(2) popUserSeed(userId)" --> pf
pf -- "(3) upsert UserFairnessSeeds (nonce++)" --> pg
dsvc -- "(4) getRandomDice({s,c,n})" --> rng
rng -- "(5) createRandomGenerator" --> rngcore
dsvc -- "(6) multiplier formula" --> dutil
%% (7)-(9) Limbo prelude (shared popUserSeed/RNG core collapsed onto (2)/(5))
lctrl -- "(7) play(dto, user)" --> lsvc
lsvc -- "(8) popUserSeed (same span as 2)" --> pf
lsvc -- "(9) getRandomLimbo({s,c,n,rtp})" --> rng
%% (10)-(11) Payload envelope wrap per game
dsvc -- "(10) payload wrap {rv,thr,ab,w}" --> common
lsvc -- "(11) payload wrap {rm,um}" --> common
%% (12)-(13) Handoff into shared pipeline
dsvc -- "(12) createAndSettleBet" --> bet
lsvc -- "(13) createAndSettleBet (liveBetDelayMs:2000)" --> bet
%% Style: datastores stand out
classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
class pg db;
4. Per-step walkthrough¶
Section headers below mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. Everything downstream of step (12)/(13) (accounting, Bet row insert, BET_SETTLED_QUEUE, rt fan-out) is the shared pipeline documented in dropbet-bet-place.md §4 and not duplicated.
4.1 Step (1) — DiceController.bet → DiceService.play¶
Nest wrapper accepts POST /casino/games/house/dice/bet and hands the parsed DiceBetRequestDto to DiceService.play(dto, request.user). The handler body is wrapped by @PlaceBetLock(user.id) (Redis mutex via @bebkovan/server-core/WaitMutex) and then by PrismaTransactional.execute — every subsequent dice-side step in §4 runs inside that single transaction. Limbo's controller forwards windowId to EvoContext.setWindowId for tracing; dice does not.
4.2 Steps (2)–(3) — popUserSeed + UserFairnessSeeds upsert (shared)¶
ProvablyFairService.popUserSeed(userId) (provably-fair.service.ts:44-52) delegates to ProvablyFairRepository.popActiveSeed, which is a single userFairnessSeeds.upsert at provably-fair.repository.ts:69-79 with update: { nonce: { increment: 1 } }. Returned nonce is post-increment (the service's Russian docstring at line 41-43 says so explicitly), so first-ever bet for a user runs at nonce=1. create branch generates serverSeed, nextServerSeed, clientSeed via generateServerSeed, plus their SHA-256 commitments.
Seed rotation: PUT /fairness/seed (provably-fair.controller.ts:61-73) archives the currently-active row to UserInactiveFairnessSeeds (its raw serverSeed now revealable via GET /fairness/unhashed-seed?hashedServerSeed=...), then writes a new active row with serverSeed = oldNextServerSeed, fresh nextServerSeed, the user-supplied clientSeed, nonce = 0. A service-line 60-70 guard refuses rotation if the user has any active multistage HOUSE_GAME bet open — dice/limbo settle inside the bet tx (isRoundClose: true) so they never block.
4.3 Step (4) — RngGames.getRandomDice invocation¶
// rng.games.ts:54-67
const randomInt = getRandom(DICE_RANDOM_POSITION_LIMIT); // limit = 10001
const randomValue = randomInt / 100; // ∈ [0.00, 100.00]
DICE_RANDOM_POSITION_LIMIT = 10001 (constants.games.ts:5) gives 10 000 distinct outcomes including 0; division by 100 yields two-decimal precision over [0, 100]. Note: randomValue = 100.00 is reachable only when randomInt = 10000 — at the upper limit, not above it. Win check is strict: randomValue > threshold (above) / < threshold (dice.service.ts:54-56).
4.4 Step (5) — RNG core: HMAC-SHA256 → 32 bits → integer (shared)¶
Rng.createRandomGenerator in libs/games/src/rng/rng.ts:81-113 is shared by every house RNG (also Roulette/Plinko/Mines/Keno/MonkeyRun):
- Build a byte stream by HMAC-SHA256-ing
${clientSeed}:${nonce}:${currentRound}under keyserverSeed(line 32-34).currentRoundstarts at 0 and increments every 32 bytes consumed (line 53-62), so multistage games (mines, plinko, keno) just keep consuming. getRandom(limit = 2**32)(line 99-110): consume 4 bytes, treat them as a base-256 fraction in[0, 1)viaΣ byte_i / 256^(i+1), multiply bylimit, floor.
Determinism: given (serverSeed, clientSeed, nonce), the output is bit-stable. The E2E exploits this — see §5 of the test file — to recompute every reported randomValue/randomMultiplier post-hoc against the revealed serverSeed, end-to-end.
4.5 Step (6) — calculateDiceMultiplier¶
// dice.utils.ts:7-30
const chance = args.above ? (100 - args.threshold) : args.threshold;
let computedMultiplier = (DICE_MAX_VALUE /* 100 */ / chance) * rtp;
if (computedMultiplier < 1.01) throw ApiException(BAD_REQUEST, 'Multiplier must be greater than 1.01.');
return roundValue(Math.min(computedMultiplier, maxMultiplier), 2 /* DICE_MULTIPLIER_PRECISION */);
The literal 100 / chance is the fair multiplier; multiplying by rtp < 1 is the house-edge knob (RTP 0.99 ⇒ 1% house edge, the dropbet default). Rounded to 2 decimals.
4.6 Step (7) — LimboController.bet → LimboService.play¶
Same shape as step (1): @PlaceBetLock + PrismaTransactional wrap the handler. Difference: limbo's controller forwards windowId from the DTO to EvoContext.setWindowId for trace tagging. LimboBetRequestDto carries multiplier ≥ 1.01 instead of dice's threshold + above — limbo lets the player pick the target and the RNG decides whether they hit it.
4.7 Step (8) — Limbo's popUserSeed call (same span as step 2)¶
LimboService.play calls the same ProvablyFairService.popUserSeed(userId) documented in §4.2. The Prisma upsert span on UserFairnessSeeds is structurally identical between the two games; the diagram lists this as a separate edge (8) only to make the parallel limbo path scannable. Detail is in §4.2.
4.8 Step (9) — RngGames.getRandomLimbo(rtp)¶
// rng.games.ts:87-108
const safe = Math.max(getRandom(), LIMBO_MIN_VALUE /* 1e-10 */);
const floatPoint = (2 ** 32 / safe) * rtp;
const crash = Math.floor(floatPoint * LIMBO_DIVISOR /* 100 */) / LIMBO_DIVISOR;
return Math.max(crash, 1);
Reciprocal of a uniform on [0, 2^32) produces the heavy-tailed crash-point distribution; multiplying by rtp shaves the average down by the house-edge factor. LIMBO_MIN_VALUE = 1e-10 floors the divisor so the formula can't blow up (without this, randomly drawing 0 would yield infinity). The Math.max(crash, 1) floor at the end means even a "bust" round still reports randomMultiplier = 1.00, not 0 — and any user-target ≥ 1.01 then loses cleanly under the inclusive comparator (randomMultiplier >= userMultiplier, limbo.service.ts:56).
Limbo treats multiplier differently from dice: dice computes the payout multiplier server-side (player only chooses threshold + above), limbo lets the player pick the multiplier and the RNG decides whether they hit it. So LimboBetResponseDto.multiplier is the player's target on win and 0 on loss, while randomMultiplier is the RNG output. The RNG core call this routes through is the same createRandomGenerator covered in §4.4 — limbo's getRandomLimbo adds only the reciprocal + clamp on top.
Limbo's validate (limbo.service.ts:116-134) is invoked before this step: checks multiplier ∈ [config.minMultiplier, config.maxMultiplier] and config.rtp ∈ (0.01, 0.99] (limbo.constants.ts:2-3). Dice relies on class-validator @Min(0.01)/@Max(99.99) on threshold (dto/dice.dto.ts:15-21) so its validation fires inside ValidationPipe before the handler. Both delegate currency/amount to BetAmountValidationService.validate.
4.9 Steps (10)–(11) — HouseGameBetPayload.create envelope (per game)¶
HouseGameBetPayload.create(multiplier, seed, gameParams) (apps/api/src/casino/common.dto.ts) is the only writer of the outer payload envelope; both games (and roulette, plinko, mines, keno, monkey-run) call it with their game-specific params. Dice passes { rv, thr, ab, w } (dice.mapper.ts); limbo passes { rm, um } (limbo.mapper.ts). The envelope also carries multiplier, gameSeedNonce (post-increment nonce from step 2/8), and gameHashedServerSeed. Result is the Bet.payload JSON column written by the shared pipeline.
4.10 Steps (12)–(13) — handoff to createAndSettleBet (shared pipeline)¶
Both services call betService.createAndSettleBet(user, payload, houseOperation) with their per-game payload envelope. Dice passes notificationDelayMs:100; limbo passes liveBetDelayMs:2000 to stagger live-bet broadcasts more aggressively. Everything from this edge onward — veto checks, WITHDRAW + DEPOSIT ledger writes, Bet row INSERT, BET_SETTLED_QUEUE enqueue, BalanceUpdated pub/sub, and the worker-side fan-out — is the shared pipeline documented in dropbet-bet-place.md §4 (steps 3–12 there). The shared pipeline returns the game-specific response DTO (DiceBetResponseDto / LimboBetResponseDto) and the HTTP layer renders 201.
5. Data model (game-specific differences)¶
Bet.payload JSON is the single column that varies per game; everything else (amount, payout, multiplier, currencyId, usdAmount/usdPayout, gameId, roundId, status) is shared and documented in dropbet-bet-place.md §5.
| Column / shape | Dice | Limbo |
|---|---|---|
Bet.gameId (FK to GameIdentity) |
dice's id (7 in local seed) | limbo's id |
Bet.payload.params |
{ rv: number, thr: number, ab: boolean, w: boolean } (dice.mapper.ts) |
{ rm: number, um: number } (limbo.mapper.ts) |
Bet.payload.multiplier |
dice's calculated multiplier (or capped) | userMultiplier on win, 0 on loss |
Bet.payload.gameSeedNonce |
nonce post-increment | nonce post-increment |
Bet.payload.gameHashedServerSeed |
sha256(serverSeed) | sha256(serverSeed) |
UserFairnessSeeds row (Postgres) |
shared, {nonce: increment 1} upsert per bet |
same |
UserInactiveFairnessSeeds (Postgres) |
written on PUT /fairness/seed rotation, exposes raw serverSeed for verification |
same |
The wrapper HouseGameBetPayload.create(multiplier, seed, gameParams) (apps/api/src/casino/common.dto.ts) is the only writer of the outer payload envelope; both games (and roulette, plinko, mines, keno, monkey-run) call it with their game-specific params.
6. Failure modes¶
- Nonce-burn on roll-back is intentional.
popUserSeedruns inside the bet'sPrismaTransactional.execute(dice line 44, limbo line 45). Any later failure rolls the upsert back, so nonce doesn't advance — an attacker can't sweep the seed by spamming failing bets. PUT /fairness/seedactive-game guard misses dice/limbo.updateClientSeed(provably-fair.service.ts:60-70) blocks rotation only when active multistage HOUSE_GAME bets are open. Dice/limbo settle inside the bet tx (isRoundClose: true), so they never appear active and rotations always succeed for these — desirable, since a dice/limbo bet is atomic.- Limbo
randomMultiplier = 1.0collapses two cases. Genuine bust (LIMBO_MIN_VALUE-clamped raw) and near-one crash both report1.00. Limbo response DTO has no explicitdidWin— clients computepayout > 0themselves. - Dice multiplier
< 1.01is a hard reject, not a clamp. Combos likethreshold=99, above=false(chance=99 ⇒ multiplier ≈ 1.0) surface as aBAD_REQUESTrather than capping at 1.01. UI should pre-validate. @Throttle({ bets })bucket is shared across all house games (same call-out asdropbet-bet-place.md§6.5, despite the per-endpointlimitnumbers differing).
7. Unresolved¶
- Commitment verified end-to-end by the new E2E. Strategy: rotate seed → 4 bets → rotate again → recover revealed
serverSeedviaGET /fairness/unhashed-seed→ recompute everyrandomValue/randomMultiplierlocally with HMAC-SHA256 (tests-e2e/tests/dropbet-house-game.spec.ts). Test green. Does not catch cross-userserverSeedreuse — separate fixture needed if that's a concern. - No
GET /fairness/verify?betId=...in production. Players DIY against the spec inlibs/games/src/rng/rng.ts. A server-side recomputation endpoint would be a better UX; out of scope here. - Game settings source not traced.
HouseGamesService.getGame(slug).settings.{dice,limbo}returns RTP / multiplier bounds / bet limits, but I did not chase where the config is loaded (env, DB, static module). Worth auditing for change-control. - Speed-roulette, blackjack, multistage games (mines/plinko/keno/monkey-run) deferred — see tasks #33 / #34 / future per-game docs. Each has its own state machine; this doc only covers the two single-shot HMAC-shaped games.