Slot backend architecture (JS/TS, real-time)

Эталон для наших JS-игр. Архитектурное решение выбрано в пользу real-time + симулятор для регрессий. Эта страница — синтез всей методологии: что строим, какие слои, как тестируем, как деплоим.

Принципы

  1. Один codebase правил игры в TypeScript. Тот же код гоняется в simulate-mode (CI, cert) и serve-mode (прод).
  2. Math на бэке, рендер на фронте. Фронт — pure renderer по events stream. Никакой math-логики на фронте.
  3. Каждый раунд воспроизводим. seed → identical events. Cert-friendly, dispute-friendly.
  4. Wallet — отдельный слой с инвариантом runningBetWin === basegameWins + freegameWins (см. wallet manager).
  5. Сегментированная симуляция через BetMode + Distribution для устойчивых статистик по редким событиям.
  6. CI-baseline diff на RTP / HF / volatility ловит регрессии до прода.
  7. Money — bigint × 10^−6. Никакого float в денежных расчётах.

Слои

┌─────────────────────────────────────────────────────────┐
│   API layer (Fastify / Express / gRPC)                  │
│   /play, /endround, /balance, /authenticate             │
└──────┬──────────────────────────────────────────────────┘
       │
┌──────▼──────────────────────────────────────────────────┐
│   RGS layer                                              │
│   Round lifecycle: bet → spin → settle → audit          │
│   Idempotency, dedup, resume, rollback                  │
│   Wallet integration                                     │
└──────┬──────────────────────────────────────────────────┘
       │
┌──────▼──────────────────────────────────────────────────┐
│   Game runtime (per game module)                         │
│   ┌──────────────────────────────────────────────────┐  │
│   │ runSpin(seed, ctx) → {events, payoutMultiplier}  │  │
│   │   ├─ drawBoard (reelstrip-pick + RNG)            │  │
│   │   ├─ evaluateWins (lines/ways/cluster/scatter)   │  │
│   │   ├─ wallet.update*                              │  │
│   │   ├─ tumbleLoop / freespinLoop                   │  │
│   │   └─ emit events                                 │  │
│   └──────────────────────────────────────────────────┘  │
└──────┬──────────────────────────────────────────────────┘
       │
┌──────▼──────────────────────────────────────────────────┐
│   Foundation (shared by simulator and runtime)          │
│   ┌─────────────┬─────────────┬───────────────────────┐│
│   │ GameConfig  │ WalletMgr   │ RNG (PRNG / CSPRNG)   ││
│   ├─────────────┼─────────────┼───────────────────────┤│
│   │ BetModes    │ Events      │ Reelstrip-loader      ││
│   │ Distributions│ Symbol      │ WeightedPicker        ││
│   └─────────────┴─────────────┴───────────────────────┘│
└──────────────────────────────────────────────────────────┘
       │
┌──────▼──────────────────────────────────────────────────┐
│   Simulator (CI / pre-cert)                              │
│   ┌────────────────────────────────────────────────────┐│
│   │ create_books(N, mode, ctx) → books, lookup, force ││
│   │ baseline RTP / HF / volatility report             ││
│   │ baseline diff vs git history                       ││
│   └────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────┘

Ключевые модули

1. GameConfig (per-game)

import {Config, BetMode, Distribution} from "@game/foundation";
 
export class SugarBoom extends Config {
  gameId = "sugar-boom";
  rtp = 0.97;
  wincap = 5000;
  numReels = 6;
  numRows = [5, 5, 5, 5, 5, 5];
  winType = "cluster";
 
  paytable = convertRangeTable({
    [[5, 5], "H1"]: 50,
    [[6, 7], "H1"]: 100,
    // ...
  });
 
  paylines = undefined;     // не нужны для cluster
  specialSymbols = {wild: ["W"], scatter: ["S"], multiplier: ["W"]};
 
  reels = {
    BR0: loadReelCsv("reels/BR0.csv"),
    FR0: loadReelCsv("reels/FR0.csv"),
    WCAP: loadReelCsv("reels/WCAP.csv"),
  };
 
  freespinTriggers = {basegame: {3: 10, 4: 15, 5: 20}};
 
  betModes = [
    new BetMode({
      name: "base", cost: 1, rtp: this.rtp, maxWin: this.wincap,
      distributions: [
        new Distribution("wincap",   {quota: 0.001, winCriteria: this.wincap, conditions: WINCAP_CONDS}),
        new Distribution("freegame", {quota: 0.1,                              conditions: FREEGAME_CONDS}),
        new Distribution("0",        {quota: 0.4,   winCriteria: 0,            conditions: ZERO_CONDS}),
        new Distribution("basegame", {quota: 0.5,                              conditions: BASE_CONDS}),
      ],
    }),
    new BetMode({
      name: "bonus", cost: 100, rtp: this.rtp, maxWin: this.wincap, isBuybonus: true,
      distributions: [
        new Distribution("wincap",   {quota: 0.001, winCriteria: this.wincap, conditions: WINCAP_CONDS}),
        new Distribution("freegame", {quota: 0.999,                            conditions: FREEGAME_CONDS}),
      ],
    }),
  ];
}

2. GameRuntime (runSpin)

export class SugarBoomRuntime extends GameRuntime {
  runSpin(seed: SeedBytes, ctx: GameContext): RoundResult {
    ctx.seedRng(seed);     // PCG32 from CSPRNG-bytes (см. seedable-prng-for-simulation)
    do {
      ctx.resetBook();
      this.drawBoard(ctx);
      this.runTumbleLoop(ctx);
      ctx.wallet.updateGameTypeWins(ctx.gametype);
      if (this.checkFsCondition(ctx)) this.runFreespinFromBase(ctx);
      ctx.evaluateFinalWin();
    } while (!ctx.checkRepeat());
    return ctx.imprintWins();
  }
 
  runFreespin(ctx: GameContext) {
    ctx.resetFsSpin();
    while (ctx.fs < ctx.totFs) {
      ctx.updateFreespin();
      this.drawBoard(ctx);
      this.runTumbleLoop(ctx);
      if (this.checkFsCondition(ctx)) ctx.updateFsRetriggerAmt();
      ctx.wallet.updateGameTypeWins(ctx.gametype);
    }
    ctx.endFreespin();
  }
}

runSpin с тем же signiture (seed, ctx) → result используется и в симуляторе, и на проде. На проде seed = crypto.randomBytes(32), в симуляторе seed = pcg32FromInt(simId).

3. RGS layer (round lifecycle)

async function play(req: PlayRequest): Promise<PlayResponse> {
  const session = await validateSession(req.sessionId);
  const game = registry.get(req.gameId);
  const mode = game.config.getBetMode(req.mode);
 
  const debitAmount = req.amount * mode.cost;
  await wallet.debit(session.userId, debitAmount, idempotencyKey(req));
 
  const seed = crypto.randomBytes(32);
  const round = game.runtime.runSpin(seed, makeContext(game, mode));
 
  await audit.log({
    roundId: round.id, userId: session.userId, gameId: game.gameId,
    mode: mode.name, seed: seed.toString("hex"),
    payoutMultiplier: round.payoutMultiplier,
    eventsHash: hash(round.events),
  });
 
  if (round.payoutMultiplier > 0) {
    await wallet.credit(session.userId, round.payoutMultiplier * req.amount);
  }
 
  return {balance: await wallet.balance(session.userId), round};
}

Важное:

  • seed сохраняется в audit для replay.
  • idempotencyKey дедупит double-submits.
  • eventsHash пишется отдельно — позже дёшево валидировать «events не подменили».
  • auto_close_disabled mode → отдельный /endround после явного wallet-close.

4. WalletManager

См. отдельный паттерн в wallet-manager-pattern. Реализуем как класс с bigint × 10^−6.

5. Events stream

См. events-as-stream. Discriminated union, exhaustive switch на фронте.

6. Simulator

CLI-tool в monorepo:

$ pnpm sim --game sugar-boom --mode base --n 100000 --seed 0xCAFE --out library/
Thread 0: 1.025 RTP [base: 0.412, free: 0.613] hit-rate: max-win 0.0009, freegame 0.098
...
RTP: 0.9712 (target 0.97, delta +0.12pp within tolerance)
Hit-rates per criteria: ...
Wallet invariants: 100000/100000
PAR sheet library/par-sheet.html

Что делает:

  1. Читает GameConfig.
  2. Распределяет [0..N) → criteria по quota.
  3. Параллелит по worker_threads (или child_process.fork).
  4. Для каждого simId:
    • seed = pcg32FromInt(simId);
    • runSpin с force-флагами из distribution;
    • repeat пока criteria не satisfied.
  5. Аккумулирует lookUpTable_<mode>.csv, books_<mode>.json[l.zst], force_record_<mode>.json.
  6. Пишет PAR sheet (HTML/PDF) с RTP/HF/volatility/split/percentiles.
  7. Сравнивает с baseline (baseline.json в репе игры) → exit-code != 0 если delta > tolerance.

7. CI pipeline

# pseudo .github/workflows/cert.yml
- run: pnpm test                    # unit-тесты win-eval
- run: pnpm sim --game sugar-boom --mode base  --n 100000 --check-baseline
- run: pnpm sim --game sugar-boom --mode bonus --n 100000 --check-baseline
- run: pnpm property-tests          # fast-check: invariants на random boards
- run: pnpm sim --game sugar-boom --mode base  --n 10000000 --output ci-artifacts/  # nightly only
- uses: actions/upload-artifact@v4 with: {path: ci-artifacts/}

Pre-PR — лёгкие 100K симуляции (~30 сек). Pre-release — полные 10M+ для cert-evidence.

Replay

Любой раунд воспроизводим:

async function replayRound(roundId: string): Promise<RoundResult> {
  const audit = await audit.get(roundId);
  const game = registry.get(audit.gameId);
  const mode = game.config.getBetMode(audit.mode);
  const seed = Buffer.from(audit.seed, "hex");
  const round = game.runtime.runSpin(seed, makeContext(game, mode));
  assert(round.payoutMultiplier === audit.payoutMultiplier, "Replay mismatch!");
  assert(hash(round.events) === audit.eventsHash, "Events hash mismatch!");
  return round;
}

Используется для:

  • Cert-аудита (лаборатория просит «покажи мне раунд X»).
  • Dispute resolution (игрок жалуется).
  • Регрессий (старый раунд после code change должен производить тот же результат).

Warning

Если меняем math-логику (новый paytable, новый wild-режим) — старые раунды по тому же seed дадут другой исход. Это нормально и ожидаемо. Нужно версионировать gameVersion в audit и при replay подтягивать ту версию кода, которая была live на момент раунда.

RNG-изоляция

См. seedable PRNG для симуляции.

Архитектурно:

@game/foundation
  ├── @game/foundation-rng-prod    ← exports: cryptoSeed(), pcg32FromBytes()
  └── @game/foundation-rng-sim     ← exports: pcg32FromInt(simId)

Прод-сервис не импортирует -rng-sim. CI-симулятор не импортирует -rng-prod. Простой ESLint-rule + dep-check в CI.

Конфигурационные артефакты

В репе игры:

games/sugar-boom/
├── src/
│   ├── config.ts          # GameConfig
│   ├── runtime.ts         # GameRuntime (runSpin)
│   ├── tumble.ts          # custom tumble logic
│   ├── grid-mult.ts       # multiplier-grid в freegame
│   └── events.ts          # game-specific event types
├── reels/
│   ├── BR0.csv            # base reelstrip
│   ├── FR0.csv            # freegame reelstrip
│   └── WCAP.csv           # max-win-сегмент reelstrip
├── tests/
│   ├── unit/              # win-eval unit tests
│   ├── property/          # fast-check tests
│   └── snapshots/         # baseline RTP / HF
├── baseline.json          # RTP target ± tolerance, HF per criteria
└── cert/
    ├── par-sheet-v1.0.html
    └── publish-checksum.txt

Reelstrips — версионируются git-ом, hash в publish-checksum.txt сабмитится с cert-документами.

Multi-game runtime

Один RGS-сервис может хостить несколько игр:

const registry = new GameRegistry();
registry.register("sugar-boom", new SugarBoomRuntime(new SugarBoomConfig()));
registry.register("aviatrix-flight", new AviatrixFlightRuntime(new AviatrixFlightConfig()));
// ...

Каждая игра — отдельный package, общий foundation.

Что планируем итерациями

  1. Week 1–2: foundation skeleton (GameConfig, BetMode, Distribution, WalletManager, RNG, Events, ReelstripLoader, WeightedPicker).
  2. Week 3: prototype simple lines-game (как Stake 0_0_lines), полный CI pipeline с baseline.
  3. Week 4: simulator с criteria/quotas/repeat-loop, force-records, lookUpTable генерация.
  4. Week 5: один из cluster/scatter с tumble + multiplier-grid (наша первая «продакшен» игра).
  5. Месяц 2: RGS layer, audit, replay, wallet integration.
  6. Месяц 3: cert-pipeline, отчёт PAR sheet, evidence для GLI/eCOGRA submit.

Связанные