Skip to content

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.ts Generated: 2026-04-16 · Author: prisma-otel-engineer · Services traced: ebit-api. Fairness commitment verified end-to-end (4 bets locally recomputed against the revealed serverSeed). 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 in dropbet-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) / < thresholdstrict (dice.service.ts:54-56) randomMultiplier >= userMultiplierinclusive (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.betDiceService.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):

  1. Build a byte stream by HMAC-SHA256-ing ${clientSeed}:${nonce}:${currentRound} under key serverSeed (line 32-34). currentRound starts at 0 and increments every 32 bytes consumed (line 53-62), so multistage games (mines, plinko, keno) just keep consuming.
  2. 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 by limit, 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.betLimboService.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

  1. Nonce-burn on roll-back is intentional. popUserSeed runs inside the bet's PrismaTransactional.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.
  2. PUT /fairness/seed active-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.
  3. Limbo randomMultiplier = 1.0 collapses two cases. Genuine bust (LIMBO_MIN_VALUE-clamped raw) and near-one crash both report 1.00. Limbo response DTO has no explicit didWin — clients compute payout > 0 themselves.
  4. Dice multiplier < 1.01 is a hard reject, not a clamp. Combos like threshold=99, above=false (chance=99 ⇒ multiplier ≈ 1.0) surface as a BAD_REQUEST rather than capping at 1.01. UI should pre-validate.
  5. @Throttle({ bets }) bucket is shared across all house games (same call-out as dropbet-bet-place.md §6.5, despite the per-endpoint limit numbers differing).

7. Unresolved

  • Commitment verified end-to-end by the new E2E. Strategy: rotate seed → 4 bets → rotate again → recover revealed serverSeed via GET /fairness/unhashed-seed → recompute every randomValue/randomMultiplier locally with HMAC-SHA256 (tests-e2e/tests/dropbet-house-game.spec.ts). Test green. Does not catch cross-user serverSeed reuse — separate fixture needed if that's a concern.
  • No GET /fairness/verify?betId=... in production. Players DIY against the spec in libs/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.