Slot backend architecture (JS/TS, real-time)
Эталон для наших JS-игр. Архитектурное решение выбрано в пользу real-time + симулятор для регрессий. Эта страница — синтез всей методологии: что строим, какие слои, как тестируем, как деплоим.
Принципы
- Один codebase правил игры в TypeScript. Тот же код гоняется в simulate-mode (CI, cert) и serve-mode (прод).
- Math на бэке, рендер на фронте. Фронт — pure renderer по events stream. Никакой math-логики на фронте.
- Каждый раунд воспроизводим.
seed → identical events. Cert-friendly, dispute-friendly. - Wallet — отдельный слой с инвариантом
runningBetWin === basegameWins + freegameWins(см. wallet manager). - Сегментированная симуляция через BetMode + Distribution для устойчивых статистик по редким событиям.
- CI-baseline diff на RTP / HF / volatility ловит регрессии до прода.
- 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_disabledmode → отдельный/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Что делает:
- Читает GameConfig.
- Распределяет
[0..N)→ criteria поquota. - Параллелит по
worker_threads(илиchild_process.fork). - Для каждого
simId:seed = pcg32FromInt(simId);runSpinс force-флагами из distribution;repeatпока criteria не satisfied.
- Аккумулирует
lookUpTable_<mode>.csv,books_<mode>.json[l.zst],force_record_<mode>.json. - Пишет PAR sheet (HTML/PDF) с RTP/HF/volatility/split/percentiles.
- Сравнивает с 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.
Что планируем итерациями
- Week 1–2: foundation skeleton (GameConfig, BetMode, Distribution, WalletManager, RNG, Events, ReelstripLoader, WeightedPicker).
- Week 3: prototype simple lines-game (как Stake
0_0_lines), полный CI pipeline с baseline. - Week 4: simulator с criteria/quotas/repeat-loop, force-records, lookUpTable генерация.
- Week 5: один из cluster/scatter с tumble + multiplier-grid (наша первая «продакшен» игра).
- Месяц 2: RGS layer, audit, replay, wallet integration.
- Месяц 3: cert-pipeline, отчёт PAR sheet, evidence для GLI/eCOGRA submit.