Skip to content

Add a new house game

Goal: ship a new in-house casino game with provably-fair RNG, REST endpoint, and frontend route (analogous to dice / plinko / mines / limbo). Audience: customer engineering team adding a custom game (e.g. "wheel of fortune", "hi-lo", "crash"). Time: 2–4 days backend + 2–4 days frontend. Friction: low — the abstraction is well-formed.

What you'll change

Layer Path Action
Game slug enum libs/games/src/house-games.config.ts:23 Add an entry to enum HouseGameSlug.
Game config libs/games/src/house-games.config.ts (struct around line 100+) Add the config record (rtp, min/max bet, payout schedule).
RNG helper libs/games/src/rng/rng.games.ts Add a getRandom<Game> static for the new game's RNG.
Backend module apps/api/src/casino/house/<game>/ New folder. Copy from dice/.
Backend wiring apps/api/src/casino/casino.module.ts (or relevant casino module) Add <Game>Module to imports.
Frontend route ebit-fe/src/app/[locale]/games/originals/<game>/ New folder; new page.tsx.
Translations ebit-fe/messages/{en,de}.json Add UI copy.
Tests apps/api/src/casino/house/<game>/__tests__/ Vitest specs for service + RNG bound.

Canonical example

apps/api/src/casino/house/dice/ is the cleanest reference; it has the smallest surface area:

dice/
├── dice.module.ts                  # imports BetService, ProvablyFairService, etc.
├── dice.controller.ts              # POST /casino/games/house/dice/bet · GET /casino/games/house/dice/config
├── dice.service.ts                 # @PlaceBetLock + RNG + payout calc + DB write
├── dice.const.ts
├── dto/
│   └── dice.dto.ts                 # request/response DTOs with class-validator
└── utils/
    ├── dice.mapper.ts              # serialize bet payload for storage
    └── dice.utils.ts               # calculateDiceMultiplier

Read dice.controller.ts and dice.service.ts first — together ~150 lines, they show every pattern you need.

Steps

1. Add the game slug

Edit libs/games/src/house-games.config.ts:23:

export enum HouseGameSlug {
  PLINKO = 'plinko',
  MINES = 'mines',
  LIMBO = 'limbo',
  ROULETTE = 'roulette',
  DICE = 'dice',
  KENO = 'keno',
  // NEW:
  HI_LO = 'hi-lo',
}

The slug is also used as the URL segment in both backend (/casino/games/house/<slug>) and frontend (/games/originals/<slug>).

2. Add the game config

Same file, around line 100+ (look for the array literal of HouseGameConfig):

{
  slug: HouseGameSlug.HI_LO,
  originalSlug: HouseGameSlug.HI_LO,
  rtp: 0.99,                     // return-to-player ratio
  minBet: { /* per-currency min */ },
  maxBet: { /* per-currency max */ },
  settings: {
    [HouseGameSlug.HI_LO]: {
      // your game-specific config
    },
  },
}

The rtp and min/max bet values feed BetAmountValidationService which the controller uses to reject out-of-range bets.

3. Implement the RNG

Edit libs/games/src/rng/rng.games.ts and add a static method analogous to getRandomDice:

static getRandomHiLo(args: { serverSeed: string; clientSeed: string; nonce: number }): number {
  // Use the existing HMAC-SHA256 helper; produce a uniformly distributed
  // value in [0, 1) and map it to the game's outcome space.
  
}

The RNG must be deterministic and reproducible from (serverSeed, clientSeed, nonce) — that's the provably-fair contract. See docs/api-reference/api.md under "Fairness API" for the verification endpoints.

4. Scaffold the backend module

cd ebit-api
cp -r apps/api/src/casino/house/dice apps/api/src/casino/house/hi-lo
# rename: hi-lo.module.ts, hi-lo.controller.ts, hi-lo.service.ts, etc.

Adapt hi-lo.controller.ts (pattern from dice.controller.ts:24-44):

@ApiTags('Casino API')
@SkipThrottle({ profile: true, externalApi: true })
@ApiBearerAuth()
@Controller('casino/games/house/hi-lo')
export class HiLoController {
  constructor(
    private readonly hiLoService: HiLoService,
    private houseGamesService: HouseGamesService,
  ) {}

  @Post('bet')
  @UseGuards(JwtGuard, UserHttpThrottlerGuard)
  @Throttle({ bets: { limit: 25, ttl: 5000 } })
  @UsePipes(new ValidationPipe({ transform: true }))
  async bet(@Body() dto: HiLoBetRequestDto, @Request() request: RequestExt) {
    return this.hiLoService.play(dto, request.user);
  }

  @Get('config')
  getConfig(): HiLoConfigResponseDto { /* … */ }
}

The throttle limit 25 bets / 5 s matches the other house games.

5. Implement the service

Pattern from dice.service.ts:25-80:

@Injectable()
export class HiLoService {
  constructor(
    private readonly provablyFairService: ProvablyFairService,
    private readonly betService: BetService,
    private readonly houseGamesService: HouseGamesService,
    private readonly betAmountValidationService: BetAmountValidationService,
  ) {}

  @PlaceBetLock({ userId: (_: never, user: UserDto) => user.id })
  async play(args: HiLoBetRequestDto, user: UserDto): Promise<HiLoBetResponseDto> {
    this.validateBet(args);
    const game = this.houseGamesService.getGame(HouseGameSlug.HI_LO);
    return await PrismaTransactional.execute(async () => {
      const seed = await this.provablyFairService.popUserSeed(user.id);
      const random = RngGames.getRandomHiLo({ ... });
      const outcome = this.calculateOutcome(args, random);
      // settle the bet via betService.createAndSettleBet(...)
    });
  }
}

Key invariants: - @PlaceBetLock prevents concurrent bets for the same user (per-user mutex). - PrismaTransactional.execute wraps the seed-pop + settle into one DB transaction. - provablyFairService.popUserSeed consumes the next nonce; the seed is verifiable post-hoc.

6. Wire the module

Add HiLoModule to whichever casino module aggregates house games (search for import.*DiceModule to find the parent — typically apps/api/src/casino/house/).

7. Frontend route

Create ebit-fe/src/app/[locale]/games/originals/hi-lo/page.tsx:

// ebit-fe/src/app/[locale]/games/originals/hi-lo/page.tsx
import { HiLoBoard } from '@/features/games/hi-lo';
export default function HiLoPage() { return <HiLoBoard />; }

Build the HiLoBoard component under ebit-fe/src/features/games/hi-lo/ — pattern follows the existing originals/{dice,limbo,mines,plinko,...} pages. See ebit-fe/src/app/[locale]/games/originals/dice/page.tsx for the canonical layout.

The dynamic route [slug]/page.tsx already exists for catalog browsing; adding a new game does not require touching it.

8. Translations

Add UI copy to ebit-fe/messages/en.json and ebit-fe/messages/de.json under a new hi-lo namespace. See add-locale.md for the namespace conventions.

9. BullMQ async settlement (if applicable) [optional]

Most house games settle synchronously inside BetService.createAndSettleBet. If your game needs deferred settlement (e.g. a reveal animation across multiple ticks), follow add-bullmq-queue.md and emit a job from the play() method. Note: the BullMQ bet-settled consumer currently starts an orphan trace because traceparent is not stored in the job payload — see docs/audits/perf-trace-coverage-audit.md. Plan for this when debugging cross-job traces.

10. Observability

@PlaceBetLock and PrismaTransactional are auto-instrumented. The bet-place HTTP entry-span is captured. However:

  • HiLoService.play itself has no manual span by default — same blind spot as DiceService.play documented in docs/audits/perf-trace-coverage-audit.md. To add one, see add-otel-span.md.
  • The bet appears in Grafana's ebit-perf-test dashboard automatically once it's emitting calls_total spans.

11. Tests

npm test -- apps/api/src/casino/house/hi-lo/

Required specs: 1. RNG bound — for N samples from getRandomHiLo, every value lies in the expected range. 2. Payout calculation — table-driven specs of (input, random) → expected payout. 3. Service end-to-end — uses the test database; bets settle correctly, balance updates, Bet row created. 4. API surface — Vitest e2e against the controller.

12. Update the API surface SOT

./docs/api/sync-postman.sh

Append a docs/api/changelog.md entry: POST /casino/games/house/hi-lo/bet and GET /casino/games/house/hi-lo/config under "New endpoints", tag Casino API.

Verification

  1. API smoke:
    curl -X POST http://localhost:4000/casino/games/house/hi-lo/bet \
      -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
      -d '{"amount":"1.0","currency":"DBC", /* game-specific args */}'
    
    Expect a 200 OK with the outcome + payout. 403 means the throttle / bet-lock rejected (try again later).
  2. Database: select id, payload, total_amount from bets order by id desc limit 1; shows the new bet with payload->>'gameSlug' = 'hi-lo'.
  3. Provably fair: GET /fairness/verify (per docs/api-reference/api.md "Fairness API") returns a verification record for the new bet.
  4. Frontend: navigate to http://localhost:3000/games/originals/hi-lo; play a round.
  5. Grafana: open ebit-perf-test; the new Casino API route shows traffic in the per-route panels.