Slot Game Backend — Nx Workspace Architecture with Game Packs
Slot Game Backend — Nx Workspace Architecture with Game Packs
Table of Contents
- Executive Summary
- Current Architecture Analysis
- Proposed Architecture: Nx Monorepo with Game Packs
- Nx Workspace Structure
- SDK Library Design
- Game Pack Strategy
- Game Plugin Interface
- Core Service Architecture
- Build & Deployment Pipeline
- Adding a New Game — Developer Workflow
- Performance Optimizations
- Migration Plan from Current Codebase
- Risks & Mitigations
- References
1. Executive Summary
This document proposes migrating the current monolithic NestJS slot game backend into an Nx monorepo using the @nx/nest plugin and pnpm workspaces, organized into independently deployable game packs. Each pack is a standalone NestJS application containing 10-15 games, built and deployed as a separate container service.
Key outcomes:
- Each game pack builds and deploys independently
- Games are isolated Nx libraries — pure logic, no framework coupling
- A shared SDK library provides common helpers (reel generation, win calculation, CSPRNG, arithmetic)
- Nx's dependency graph ensures only affected packs rebuild on changes
- Scales to 100+ games without proportional increase in build times or memory pressure
- Game plugin interface uses generics for game-specific responses — different games return different shapes
- Node.js native fetch + undici for HTTP (no axios dependency)
- pnpm as package manager (faster installs, strict dependency resolution, disk efficient)
Confirmed decisions:
| Decision | Choice | Rationale |
|---|---|---|
| Package manager | pnpm | Faster than Yarn, strict dependency resolution, first-class Nx support, disk efficient |
| HTTP client | Node.js native fetch + undici | Built into Node 22, zero dependencies, undici provides connection pooling |
| Game response model | Generics on ISlotGamePlugin | Each game defines its own response shape while sharing common fields |
| Math file delivery | Bundled in Docker image | Simple, no external dependency at runtime, deterministic |
| Pilot game | Gates of Olympus (gatesofolympus) | Rebranded from olympus-scatter-realms. Standard size (168K math), representative complexity |
| Build tooling | Webpack (apps) / typecheck-only (libs) | Apps use webpack-cli via nx:run-commands. Libs have no build target — only typecheck (tsc --noEmit). Webpack bundles everything from source |
| Type checking | tsc --noEmit (separate concern) | SWC transpiles, tsc validates types independently |
| HTTP adapter | Fastify | 2-3x raw throughput over Express for JSON-heavy workloads. @nestjs/platform-fastify |
| API response envelope | ApiResponseDto | Standardized { success, statusCode, message, data, extensions } wrapper for all responses |
| Core game orchestration | libs/game-core (GameCoreModule) | Shared library with DynamicModule.register(). Provides GameController, GameService, GamePluginLoaderService. Packs just import and pass plugins |
| Bootstrap | PlatformBootstrap | Reusable bootstrap class in shared-nestjs. Handles Fastify adapter, interceptor stack, filters, validation pipe. Pack main.ts is ~5 lines |
| Migration strategy | New games only | Remaining monolith games will NOT be migrated. New games added as needed to this repo |
| Old monolith | Retained | Will continue to serve a different client. Not decommissioned |
| CI/CD | Deferred | Will decide Azure vs AWS later |
| Nx remote cache | Deferred | Will evaluate after initial setup |
| Request routing | Load balancer rules (likely) | Parked for later discussion |
2. Current Architecture Analysis
2.1 What Exists Today
| Aspect | Current State |
|---|---|
| Framework | NestJS 11 on Node 24 |
| Games | 17 games, all in src/games/ |
| Math files | 53 JSON files, 3 megaways games at 21MB each |
| Build | Single nest build, single Docker image |
| Deployment | Single ECS service / container |
| Game registration | Manual — every game service injected into GameStrategyService constructor (17 imports, 17 strategies.set() calls) |
| Shared code | src/shared/ — helpers, interfaces, middleware, interceptors |
| Total project size | ~457MB (including node_modules) |
2.2 Current Problems at Scale
| Problem | Impact |
|---|---|
| All 17 games load into every container | 3 megaways games alone consume ~63MB of parsed JSON per process |
| Single-process architecture | Cannot saturate multi-core ECS tasks / Azure Container App replicas |
| One game change → full rebuild + redeploy | CI takes longer as games grow; entire fleet restarts for a single game fix |
| Manual game registration | Adding a game requires editing GameModule, GameStrategyService, GameIds enum — 4+ files in core |
| No HTTP connection pooling | New TCP connection to RGS per request |
| No circuit breaker / retry | RGS hiccup cascades to all games |
| Memory-hungry linting | Already requires 6GB heap for ESLint due to math file proximity |
2.3 Current Game Inventory by Math File Size
| Category | Games | Math Size Each |
|---|---|---|
| Megaways (Heavy) | reign-of-power-megaways, legends-awaken-megaways, battle-for-the-crescent-megaways | ~21MB |
| Medium | golden-catch (1.1MB), sugar-frenzy-1000 (468K), mega-ace (220K), candy-bonanza-1000 (204K) | 200K–1.1MB |
| Standard | royal-81, olympus-scatter-realms, olympus-realms-1000, mahjong-mastery-2, island-of-treasures, golden-phoenix-blaze, falcon-of-anatolia | ~168K |
| Lightweight | fortune-frog, legend-of-barong, rise-of-the-simurgh | ~36K |
3. Proposed Architecture: Nx Monorepo with Game Packs
3.1 High-Level Architecture
┌──────────────────┐
│ Load Balancer │
│ (ALB / Azure FD) │
└────────┬─────────┘
│
Routes by gameId prefix
│
┌─────────────────────┼─────────────────────┐
│ │ │
┌──────▼──────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Pack A │ │ Pack B │ │ Pack C │
│ (Megaways) │ │ (Standard) │ │ (Standard) │
│ 3 games │ │ 10-15 games │ │ 10-15 games │
│ │ │ │ │ │
│ 3 replicas │ │ 5 replicas │ │ 5 replicas │
└──────────────┘ └────────────────┘ └────────────────┘
Each pack is an independent:
- Nx NestJS application
- Docker image
- ECS Service / Azure Container App
- Auto-scaling group
3.2 Why Game Packs, Not One Service Per Game
| Approach | Pros | Cons |
|---|---|---|
| 1 service per game | Maximum isolation, independent scaling | 100+ services to manage, 100+ CI pipelines, high infra cost |
| All games in 1 service | Simple deployment, shared resources | No isolation, memory bloat, full rebuild always |
| Game packs (10-15 per service) | Balanced isolation, manageable infra, independent scaling per pack | Requires smart grouping, pack-level coordination |
Game packs hit the sweet spot: you get independent scaling and deployment without the operational overhead of managing 100+ microservices.
4. Nx Workspace Structure
4.1 Directory Layout
slot-game-platform/ # Nx workspace root
├── nx.json # Nx workspace configuration
├── tsconfig.base.json # Shared TypeScript config
├── package.json # Root workspace package.json
├── pnpm-workspace.yaml # pnpm workspace config
├── pnpm-lock.yaml
│
├── libs/ # Nx libraries (shared code)
│ ├── sdk/ # @slot-platform/sdk (type:sdk — leaf node)
│ │ ├── project.json
│ │ ├── tsconfig.json
│ │ └── src/
│ │ ├── index.ts # Public API barrel
│ │ ├── interfaces/ # ISlotGamePlugin, ISpinInput, response types, etc.
│ │ ├── helpers/ # reel-generator, win-line-calc, csprng, arithmetic
│ │ ├── constants/ # Result codes, error codes
│ │ └── errors/ # Rich error classes
│ │
│ ├── http-client/ # @slot-platform/http-client (type:sdk — leaf node)
│ │ ├── project.json
│ │ └── src/
│ │ ├── http-client.ts # Native fetch + undici dispatcher
│ │ ├── http-client-error.ts # Auto-parsed error responses
│ │ └── retry.ts # NONE/CONSTANT/BACKOFF + jitter
│ │
│ ├── rgs-client/ # @slot-platform/rgs-client (type:shared)
│ │ ├── project.json
│ │ └── src/
│ │ ├── rgs.service.ts # Native fetch + undici Pool for connection pooling
│ │ ├── rgs.module.ts # @Global() module with self-contained config
│ │ └── interfaces/
│ │
│ ├── game-core/ # @slot-platform/game-core (type:shared)
│ │ ├── project.json
│ │ └── src/
│ │ ├── game-core.module.ts # DynamicModule.register(plugins)
│ │ ├── abstract-game-plugin-loader.service.ts # Plugin lifecycle via GAME_PLUGINS token
│ │ ├── game.service.ts # Core orchestrator (init/spin/feature)
│ │ ├── game.controller.ts # @Controller('games') with ApiResponseDto wrapping
│ │ └── dtos/ # InitRequestDto, SpinRequestDto, FeatureRequestDto
│ │
│ └── shared-nestjs/ # @slot-platform/shared-nestjs (type:shared)
│ ├── project.json
│ └── src/
│ ├── bootstrap/ # PlatformBootstrap, IBootstrapOptions
│ ├── interceptors/ # AsyncSession, Decryption, Response interceptors
│ ├── filters/ # HttpExceptionFilter (replaces RichErrorFilter)
│ ├── generics/ # ApiResponseDto — standardized API envelope
│ ├── services/ # DecryptionService
│ ├── monitoring/ # MonitoringModule, MonitoringService
│ ├── logger/ # AppLogger (pino, stdout only)
│ └── config/ # App, JWT, logger configuration
│
├── games/ # Game logic libraries (NO NestJS dependency)
│ ├── mega-ace/ # @slot-platform/game-mega-ace
│ │ ├── project.json
│ │ ├── tsconfig.json
│ │ └── src/
│ │ ├── index.ts # exports MegaAcePlugin
│ │ ├── plugin.ts
│ │ ├── engine.ts
│ │ ├── logic.ts
│ │ ├── interfaces.ts
│ │ └── maths/
│ │ ├── mega-ace-R3.json # includes "config" key (layout, symbols, maxWin, ...)
│ │ ├── mega-ace-R5.json
│ │ └── mega-ace-R7.json
│ │
│ ├── golden-catch/ # @slot-platform/game-golden-catch
│ ├── sugar-frenzy-1000/ # @slot-platform/game-sugar-frenzy-1000
│ ├── reign-of-power-megaways/ # @slot-platform/game-reign-of-power-megaways
│ └── ... (100+ game libraries)
│
├── apps/ # Nx applications (deployable units)
│ ├── pack-alpha/ # Pilot pack (NATO phonetic naming)
│ │ ├── project.json # type:app, webpack-cli build, @nx/js:node serve
│ │ ├── webpack.config.js
│ │ ├── games.json # Declares games in this pack
│ │ ├── tsconfig.json
│ │ └── src/
│ │ ├── main.ts # ~5 lines — PlatformBootstrap.run()
│ │ └── app.module.ts # Imports GameCoreModule.register([plugins])
│ │ # No GameController/GameService — provided by game-core
│ │
│ ├── pack-bravo/ # Future pack (NATO phonetic naming continues)
│ └── ...
│
└── tools/ # Custom Nx generators/executors (future)
└── generators/
4.2 Nx Project Configuration
nx.json (workspace root):
{
"defaultBase": "main",
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/tsconfig.spec.json"
]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"typecheck": {
"inputs": ["default", "^default"],
"cache": true
},
"lint": {
"inputs": ["default", "{workspaceRoot}/eslint.config.mjs"],
"cache": true
}
},
"plugins": []
}
Note: The
@nx/js/typescriptplugin is configured for type checking inference. Libraries and games have only atypechecktarget (no build — consumed as source by webpack). Only pack apps have abuildtarget usingnx:run-commandswithwebpack-cli.
tsconfig.base.json (workspace root):
{
"compilerOptions": {
"target": "ES2023",
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"importHelpers": true,
"baseUrl": ".",
"paths": {
"@slot-platform/sdk": ["libs/sdk/src/index.ts"],
"@slot-platform/http-client": ["libs/http-client/src/index.ts"],
"@slot-platform/rgs-client": ["libs/rgs-client/src/index.ts"],
"@slot-platform/shared-nestjs": ["libs/shared-nestjs/src/index.ts"],
"@slot-platform/game-core": ["libs/game-core/src/index.ts"],
"@slot-platform/game-*": ["games/*/src/index.ts"],
"@slot-platform/pack-*": ["apps/pack-*/src/index.ts"]
}
}
}
4.3 Nx Generators Used
| Generator | Command | Purpose |
|---|---|---|
| Create workspace | npx create-nx-workspace@latest slot-game-platform --preset=nest --pm=pnpm | Initialize Nx workspace with NestJS + pnpm |
| New game pack app | nx g @nx/nest:app pack-standard-c | New deployable NestJS application |
| New game library | nx g @nx/js:lib game-new-game --directory=games/new-game | New game logic library (no NestJS) |
| New SDK lib | nx g @nx/js:lib sdk --directory=libs/sdk | Shared SDK library |
| NestJS components | nx g @nx/nest:service --project=pack-megaways | Service, controller, module within a pack |
| Custom game scaffold | nx g @slot-platform/tools:new-game --name=my-game --pack=pack-standard-a | Custom generator (see Section 10) |
4.4 Build System — SWC
The workspace uses SWC for transpilation instead of tsc. SWC is a Rust-based compiler that is ~20x faster than tsc while supporting NestJS decorator metadata.
Why SWC over tsc:
| Concern | tsc (--build) | SWC (@nx/js:swc) |
|---|---|---|
| Speed | Slow (type-checks + emits) | ~20x faster (transpile only) |
composite: true required | Yes (for cross-project refs) | No |
| Project references required | Yes | No |
tsBuildInfoFile management | Yes | No |
| NestJS decorator support | Native | Via legacyDecorator + decoratorMetadata |
| Type checking | Bundled with build | Separate (tsc --noEmit) |
Why not esbuild: esbuild cannot emit decoratorMetadata — critical for NestJS dependency injection. SWC is the only viable fast alternative for NestJS projects.
.swcrc (workspace root — copied to each project directory):
{
"$schema": "https://json.schemastore.org/swcrc",
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"target": "es2022",
"keepClassNames": true,
"externalHelpers": true
},
"module": {
"type": "commonjs"
},
"sourceMaps": true
}
Note: Each library has its own
.swcrccopy because the@nx/js:swcexecutor looks for.swcrcin the project directory, not the workspace root.
Library project.json pattern (all libs follow this):
{
"name": "sdk",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/sdk/src",
"projectType": "library",
"tags": ["type:sdk"],
"targets": {
"build": {
"executor": "@nx/js:swc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/sdk",
"main": "libs/sdk/src/index.ts",
"tsConfig": "libs/sdk/tsconfig.lib.json",
"assets": []
}
}
}
}
4.5 TypeScript Configuration Strategy
TypeScript configs are split into two concerns: IDE (tsconfig.json) and Build (tsconfig.lib.json).
tsconfig.json (IDE — no emit, no rootDir constraint):
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../dist/libs/sdk"
},
"include": ["src/**/*.ts"],
"references": []
}
- No
rootDir— allows the IDE to resolve cross-project path aliases without TS6059 errors - No
composite— not needed since SWC handles transpilation - Empty
references— no project references anywhere
tsconfig.lib.json (Build — used by SWC executor for output structure):
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"rootDir": "src",
"outDir": "../../dist/libs/sdk"
},
"exclude": ["**/*.spec.ts", "**/*.test.ts"]
}
- Has
rootDir: "src"— controls output directory structure indist/ - Only used by the
@nx/js:swcexecutor, never by the IDE - No
composite, notsBuildInfoFile, no project references
Key rule: No library or app has project references in any tsconfig file. Cross-project imports are resolved via tsconfig.base.json path aliases at the workspace root.
5. SDK Library Design
5.1 What Goes Into the SDK
The SDK (libs/sdk/) extracts everything currently in src/shared/helpers/ and src/shared/interfaces/:
libs/sdk/src/
├── index.ts # Public API
├── interfaces/
│ ├── game-plugin.interface.ts # ISlotGamePlugin
│ ├── spin.interface.ts # ISpinInput, ISpinOutput
│ ├── init.interface.ts # IInitInput, IInitOutput
│ ├── feature.interface.ts # IFeatureInput, IFeatureOutput
│ └── math-config.interface.ts # Common math config types
├── helpers/
│ ├── arithmetic.ts # BigNumber add, multiply, divide
│ ├── csprng.ts # generateCSPRNG
│ ├── reel-generator.ts # generateReelView
│ ├── reel-set-selector.ts # selectReelSet
│ ├── stack-symbol-replacer.ts # replaceStackSymbols
│ ├── win-line-calculator.ts # calculateWinLines
│ ├── cluster-detector.ts # detectClusters
│ ├── cascade-refill.ts # cascadeRefill
│ └── generic-logic.ts # getRandomIndex, deepCopy, etc.
├── constants/
│ └── result-codes.constant.ts
└── errors/
├── base-rich-error.ts
├── client-errors.ts
└── server-errors.ts
5.2 What Does NOT Go Into the SDK
| Item | Where It Lives Instead |
|---|---|
| NestJS decorators, middleware, interceptors, filters | libs/shared-nestjs/ |
| RGS HTTP client | libs/rgs-client/ |
| Decryption/encryption | libs/shared-nestjs/ |
| Game-specific config (symbols, layout, maxWin) | Inside each game library |
| Math JSON files | Inside each game library |
5.3 Dependency Rule
games/* → depends on → libs/sdk (ONLY)
apps/* → depends on → libs/sdk, libs/shared-nestjs, libs/rgs-client, libs/game-core, games/*
libs/sdk → depends on → NOTHING (leaf node)
libs/http-client → depends on → NOTHING (leaf node)
libs/rgs-client → depends on → libs/sdk, libs/shared-nestjs, libs/http-client
libs/game-core → depends on → libs/sdk, libs/shared-nestjs, libs/rgs-client
Enforced by Nx module boundary rules in eslint.config.mjs (flat config):
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{ "sourceTag": "type:game", "onlyDependOnLibsWithTags": ["type:sdk"] },
{ "sourceTag": "type:app", "onlyDependOnLibsWithTags": ["type:sdk", "type:shared", "type:game"] },
{ "sourceTag": "type:sdk", "onlyDependOnLibsWithTags": [] }
]
}
]
}
}
This prevents games from importing other games, games from importing NestJS internals, or any circular dependencies.
6. Game Pack Strategy
6.1 Pack Grouping Criteria
Games should be grouped by:
- Memory footprint — megaways games (21MB each) get their own pack with fewer games
- Traffic pattern — high-traffic games together so they scale as a unit
- Game type affinity — similar game mechanics benefit from shared CPU cache patterns
- Business priority — new/promoted games in a dedicated pack for independent scaling
6.2 Recommended Initial Pack Layout (17 Current Games)
| Pack | Games | Math Size | Rationale |
|---|---|---|---|
| pack-megaways | reign-of-power-megaways, legends-awaken-megaways, battle-for-the-crescent-megaways | ~63MB total | Isolated due to massive memory footprint; can have larger instance type |
| pack-standard-a | mega-ace, mahjong-mastery-2, sugar-frenzy-1000, candy-bonanza-1000, olympus-scatter-realms, olympus-realms-1000, golden-catch | ~2.4MB total | Medium-weight games |
| pack-standard-b | golden-phoenix-blaze, island-of-treasures, falcon-of-anatolia, fortune-frog, legend-of-barong, rise-of-the-simurgh, royal-81 | ~0.9MB total | Lightweight games |
6.3 Scaling to 100+ Games
As more games are added:
pack-megaways → 3-5 megaways games max (memory-bound)
pack-cluster-a → 10-15 cluster-pay games
pack-cluster-b → 10-15 cluster-pay games
pack-standard-a → 10-15 standard line games
pack-standard-b → 10-15 standard line games
pack-standard-c → 10-15 standard line games
pack-premium → 5-10 high-traffic / promoted games (dedicated scaling)
...
6.4 Infrastructure Per Pack
| Resource | Configuration |
|---|---|
| Container | Separate Docker image per pack |
| ECS Service / Azure Container App | Independent service with own task definition |
| Auto-scaling | Pack-level scaling based on CPU/request count |
| Instance sizing | Megaways pack: 4GB+ RAM; Standard packs: 1-2GB RAM |
| Replicas | Independent per pack (megaways: 2-3, standard: 3-5, premium: 5-10) |
7. Game Plugin Interface
7.1 The Contract
The plugin interface uses generics so each game can define its own response shape while the core only interacts with common fields.
// libs/sdk/src/interfaces/game-plugin.interface.ts
export interface ISlotGamePlugin<
TSpinData = unknown,
TFeatureData = unknown,
TInitData = unknown
> {
/** Unique game identifier (e.g., 'goldencatch') */
readonly gameId: string;
/** Available RTP modes (e.g., ['R3', 'R5', 'R7']) */
readonly mathModes: string[];
/** Math file name prefix (e.g., 'golden-catch') */
readonly mathFileName: string;
/**
* Called once when the pack application starts.
* Use for preloading math files, initializing caches.
*/
onLoad(): Promise<void>;
/**
* Initialize game session — return paytable, game info.
* Pure logic — no RGS calls.
*/
init(input: IInitInput): IInitOutput<TInitData>;
/**
* Execute a spin — return reel view, wins, features triggered.
* Pure logic — no RGS calls.
*/
spin(input: ISpinInput): ISpinOutput<TSpinData>;
/**
* Execute a feature spin (free spins, bonus, etc.).
* Optional — not all games have features.
* Pure logic — no RGS calls.
*/
feature?(input: IFeatureInput): IFeatureOutput<TFeatureData>;
/**
* Called when pack application shuts down.
* Use for cleanup.
*/
onUnload?(): Promise<void>;
}
Example: Game-specific typing
// Olympus Scatter Realms defines its own response shapes
interface OlympusSpinData {
scatterPositions: number[];
freeSpinsAwarded: number;
multiplier: number;
}
interface OlympusFeatureData {
currentSpin: number;
totalSpins: number;
multiplier: number;
retriggerCount: number;
}
class OlympusScatterRealmsPlugin
implements ISlotGamePlugin<OlympusSpinData, OlympusFeatureData> { ... }
// Golden Catch defines completely different shapes
interface GoldenCatchSpinData {
fishValues: Record<string, number>;
wildPositions: number[];
dynamiteTriggered: boolean;
}
class GoldenCatchPlugin
implements ISlotGamePlugin<GoldenCatchSpinData, GoldenCatchFeatureData> { ... }
The core service treats game-specific data as opaque — it passes it through to the response without needing to understand its structure.
7.2 Key Design Decision: Games Are Pure Logic
Currently, each game service (e.g., GoldenCatchService) handles:
- RGS init/debit/credit/updateResult calls
- Session management
- Max win capping (RGS-level)
- Response formatting
In the new architecture, games only handle game math. Everything else moves to the core (pack app):
Current: Request → GameService → [RGS debit + spin logic + RGS credit] → Response
Proposed: Request → Core → RGS debit → GamePlugin.spin() → Max win cap → RGS credit → Response
Benefits:
- Games become trivially testable (pure input → output)
- RGS integration logic written once in
libs/rgs-client/, shared across all packs - No NestJS dependency in game code — games are plain TypeScript
- Can run game logic in simulators, workers, or any runtime without NestJS
7.3 What the Spin Input/Output Looks Like
// Input to game plugin (provided by core)
export interface ISpinInput {
gameMode: string; // 'R3', 'R5', etc.
betAmount: number;
baseBet: number;
linesSelected: number;
cheat?: ICheatConfig; // For testing
}
// Output from game plugin (consumed by core)
// TGameData is the game-specific generic — core doesn't inspect it, passes through to response
export interface ISpinOutput<TGameData = unknown> {
// --- Common fields (core uses these for RGS orchestration) ---
totalWin: number;
featureTriggered: boolean;
mathMaxWin: number;
// --- Game-specific data (core passes through, frontend consumes) ---
gameData: TGameData;
// --- Common display fields (most games have these, but shape varies) ---
reelView: string[][];
reelStops: number[];
winLines: IWinLine[];
scatterCount: number;
featureState?: unknown; // Opaque state for feature continuation
// No RGS data, no session data, no roundId
}
// Same pattern for feature output
export interface IFeatureOutput<TGameData = unknown> {
totalWin: number;
featureComplete: boolean;
mathMaxWin: number;
gameData: TGameData;
reelView: string[][];
reelStops: number[];
winLines: IWinLine[];
featureState?: unknown;
}
// Init output
export interface IInitOutput<TGameData = unknown> {
payTable: Record<string, number[]>;
gameData: TGameData; // Game-specific init info (layout, symbols, etc.)
}
The gameData field is the escape hatch — each game puts whatever it needs there, and the core service serializes it into the response untouched.
8. Core Service Architecture
8.1 Game Core Library (libs/game-core)
The GameCoreModule is a shared NestJS library (type:shared) that provides the complete game orchestration stack. Packs no longer define their own GameController, GameService, or GamePluginLoaderService — they import GameCoreModule.register() and pass their plugins.
Path alias: @slot-platform/game-core → libs/game-core/src/index.ts
// libs/game-core/src/game-core.module.ts
@Module({})
export class GameCoreModule {
static register(plugins: ISlotGamePlugin[]): DynamicModule {
return {
module: GameCoreModule,
imports: [AppConfigModule, MonitoringModule, RgsModule],
providers: [
DecryptionService,
{ provide: GAME_PLUGINS, useValue: plugins },
GamePluginLoaderService,
GameService,
],
controllers: [GameController],
exports: [GameService, GamePluginLoaderService],
};
}
}
8.2 Pack Application Structure (Minimal)
With GameCoreModule, pack apps are dramatically simplified — just main.ts and app.module.ts:
// apps/pack-alpha/src/main.ts
import { PlatformBootstrap } from '@slot-platform/shared-nestjs';
import { AppModule } from './app.module';
new PlatformBootstrap().run({
AppModule,
serviceName: 'Pack-Alpha',
});
// apps/pack-alpha/src/app.module.ts
import { Module } from '@nestjs/common';
import { GameCoreModule } from '@slot-platform/game-core';
import { GatesOfOlympusPlugin } from '@slot-platform/game-gates-of-olympus';
@Module({
imports: [
GameCoreModule.register([
new GatesOfOlympusPlugin(),
// Add more plugins here
]),
],
})
export class AppModule {}
No GameController, GameService, or GamePluginLoaderService in the pack. Everything is provided by GameCoreModule.
8.3 PlatformBootstrap
PlatformBootstrap (in libs/shared-nestjs/src/bootstrap/) standardizes NestJS application initialization across all packs:
interface IBootstrapOptions {
AppModule: any; // NestJS root module (required)
serviceName: string; // For logging (required)
bodyLimit?: number; // Fastify body limit (default: 50MB)
interceptors?: NestInterceptor[]; // Additional interceptors
filters?: ExceptionFilter[]; // Additional filters
pipes?: PipeTransform[]; // Additional pipes
disableDecryption?: boolean; // Skip DecryptionInterceptor
disableResponseWrapper?: boolean; // Skip ResponseInterceptor
factoryOptions?: NestFactoryOptions;
}
Bootstrap automatically configures:
- Fastify adapter with configurable body limit
- CORS and shutdown hooks
- Interceptor stack (in order):
AsyncSessionInterceptor→DecryptionInterceptor(unless disabled) →ResponseInterceptor(unless disabled) → custom interceptors HttpExceptionFilterglobally + custom filtersValidationPipewithtransform: true,whitelist: true, throwsNotValidClientErroron failure- Listens on
0.0.0.0:port
8.4 Game Plugin Loader (Token-Based DI)
Plugin loading uses a GAME_PLUGINS injection token — no class inheritance needed, avoiding SWC/webpack class transpilation issues:
// libs/game-core/src/abstract-game-plugin-loader.service.ts
export const GAME_PLUGINS = Symbol('GAME_PLUGINS');
@Injectable()
export class GamePluginLoaderService implements OnModuleInit, OnModuleDestroy {
private readonly pluginMap = new Map<string, ISlotGamePlugin>();
constructor(
@Inject(GAME_PLUGINS) private readonly plugins: ISlotGamePlugin[],
private readonly monitor: MonitoringService,
) {}
async onModuleInit(): Promise<void> {
for (const plugin of this.plugins) {
await plugin.onLoad();
this.pluginMap.set(plugin.gameId, plugin);
}
}
getPlugin(gameId: string): ISlotGamePlugin { /* throws NotFoundServerError if missing */ }
getAvailableGameIds(): string[] { return Array.from(this.pluginMap.keys()); }
}
8.5 Core Game Service (Thin Orchestrator)
// libs/game-core/src/game.service.ts — core orchestrator with 3 operations
@Injectable()
export class GameService {
constructor(
private readonly pluginLoader: GamePluginLoaderService,
private readonly rgsService: RgsService,
private readonly monitor: MonitoringService,
) {}
healthCheck() { return { status: 'ok', games: this.pluginLoader.getAvailableGameIds() }; }
async init(input: InitRequestDto): Promise<IInitResponse> {
// RGS init → plugin.init() → loadLastSpin for unfinished rounds → getPlayerInfo
}
async spin(input: SpinRequestDto): Promise<ISpinResponse> {
// generateUuid → handle buyBonus → RGS debit → plugin.spin() → credit or updateResult
// Returns { gameId, roundId, closed, playerInfo, result }
}
async feature(input: FeatureRequestDto): Promise<IFeatureResponse> {
// loadLastSpin → unwrap featureState → plugin.feature() → trim spinResults → credit or updateResult
// Returns { gameId, closed, betType, playerInfo, result }
}
}
8.6 Standardized API Response Envelope
All API responses are wrapped in ApiResponseDto for consistency:
// libs/shared-nestjs/src/generics/api.response.dto.ts
class ApiResponseDto<T = unknown> {
success: boolean;
statusCode: number;
message: string;
data?: T;
extensions?: ApiErrorExtensions; // Error metadata (name, code, stack in dev)
metadata?: unknown; // Pagination, etc.
static success<T>(data: T, message: string, statusCode?: number): ApiResponseDto<T>;
static error(message: string, statusCode: number, extensions?: ApiErrorExtensions): ApiResponseDto;
}
Controller usage — explicit wrapping:
@Post('spin')
async spin(@Body() input: SpinRequestDto): Promise<ApiResponseDto<ISpinResponse>> {
const data = await this.gameService.spin(input);
return ApiResponseDto.success(data, 'Spin completed successfully');
}
ResponseInterceptor — auto-wraps responses not already wrapped in ApiResponseDto.
HttpExceptionFilter — replaces the old RichErrorFilter. Universal @Catch() filter that handles HttpException, BaseRichError, and unknown errors, always responding with ApiResponseDto.error(). Dev-only error details controlled by SHOW_ERROR_INTERNALS env var.
8.7 DecryptionInterceptor with Disable Flag
The DecryptionInterceptor now supports a disableDecryption configuration flag:
- Backed by
DISABLE_DECRYPTIONenv var - Skips decryption if disabled, body is empty, or content-type isn't
application/json - Logs warning on startup when decryption is disabled
- Can also be disabled per-pack via
PlatformBootstrapoptions
8.8 Game Controller & DTOs
The GameController (in game-core) provides 4 endpoints:
| Method | Path | DTO | Response |
|---|---|---|---|
| GET | /games/health | — | { status, games[] } |
| POST | /games/init | InitRequestDto | ApiResponseDto<IInitResponse> |
| POST | /games/spin | SpinRequestDto | ApiResponseDto<ISpinResponse> |
| POST | /games/feature | FeatureRequestDto | ApiResponseDto<IFeatureResponse> |
DTOs (with class-validator + class-transformer):
InitRequestDto:gameId,token(required),deviceType,extraData(optional)SpinRequestDto:gameId,token,betAmount(required),buyBonus(nestedBuyBonusDto),extraData(optional)FeatureRequestDto:gameId,token(required),extraData(optional)
### 8.4 Routing: How Requests Reach the Right Pack
**Option A: Load Balancer Path-Based Routing (Recommended)**
Configure the ALB / Azure Front Door:
/games/spin { gameId: "megaace" } → pack-standard-a:8000 /games/spin { gameId: "reignofpowermegaways" } → pack-megaways:8000
Implementation: Use a lightweight **API Gateway / Router** service (or ALB rules) that inspects the `gameId` field in the request body and routes to the correct pack's target group.
**Option B: Each Pack Handles Unknown Games Gracefully**
All packs receive all requests. If a pack doesn't have the game, it returns a `404` / `STRATEGY_NOT_FOUND`. The load balancer tries the next target group. (Simple but wasteful.)
**Option C: Game-to-Pack Registry Service**
A small Redis-backed or config-backed service maps `gameId → pack URL`. The router (or a shared middleware) queries this registry and proxies.
**Recommendation:** Likely **Option A** (load balancer rules). This decision is **parked for later** — will revisit when deploying multiple packs to production.
---
## 9. Build & Deployment Pipeline
### 9.1 Nx Build Commands
All builds use `@nx/js:swc` executor — no tsc compilation involved.
```bash
# Build a specific library (uses SWC)
pnpm nx build sdk
pnpm nx build shared-nestjs
# Build a specific pack (builds its dependencies first via dependsOn: ["^build"])
pnpm nx build pack-megaways
# Build only packs affected by changes since main
pnpm nx affected -t build
# Build all projects
pnpm nx run-many -t build
# Type check (separate from build — uses tsc --noEmit)
pnpm nx run-many -t typecheck
# Visualize dependency graph
pnpm nx graph
# Clean dist and rebuild
rm -rf dist && pnpm nx run-many -t build
9.2 How Nx Affected Works
When a developer changes games/golden-catch/src/logic.ts:
- Nx computes the dependency graph
game-golden-catchis marked affectedpack-standard-a(which importsgame-golden-catch) is marked affectedpack-megawaysandpack-standard-bare NOT affected- Only
pack-standard-arebuilds and redeploys
When a developer changes libs/sdk/src/helpers/arithmetic.ts:
- All games depend on
sdk→ all games affected - All packs affected → full rebuild
- This is correct behavior — SDK changes are rare and should be tested across all packs
9.3 Dockerfile Per Pack
# apps/pack-standard-a/Dockerfile
# Stage 1: Build
FROM node:24-alpine AS builder
RUN corepack enable
WORKDIR /workspace
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY nx.json tsconfig.base.json ./
COPY libs/ ./libs/
COPY games/mega-ace/ ./games/mega-ace/
COPY games/golden-catch/ ./games/golden-catch/
COPY games/sugar-frenzy-1000/ ./games/sugar-frenzy-1000/
# ... only games for THIS pack
COPY apps/pack-standard-a/ ./apps/pack-standard-a/
RUN pnpm install --frozen-lockfile
RUN pnpm nx build pack-standard-a
# Stage 2: Production
FROM node:24-alpine
WORKDIR /app
COPY /workspace/dist/apps/pack-standard-a ./dist
COPY /workspace/node_modules ./node_modules
# Math JSON files bundled in Docker image (loaded at runtime)
COPY /workspace/games/mega-ace/src/maths ./maths/mega-ace
COPY /workspace/games/golden-catch/src/maths ./maths/golden-catch
COPY /workspace/games/sugar-frenzy-1000/src/maths ./maths/sugar-frenzy-1000
EXPOSE 8000
CMD ["node", "dist/main.js"]
9.4 CI/CD Pipeline
STATUS: DEFERRED — Cloud provider (Azure vs AWS) decision pending. The pipeline structure below is a reference for when CI/CD setup begins.
# Pseudocode — adapt to your CI system (Bitbucket Pipelines / GitHub Actions / Azure DevOps)
steps:
- name: Install
run: pnpm install --frozen-lockfile
- name: Lint affected
run: pnpm nx affected -t lint --base=origin/main
- name: Build affected
run: pnpm nx affected -t build --base=origin/main
- name: Determine affected packs
run: |
AFFECTED_PACKS=$(pnpm nx show projects --affected --type=app --base=origin/main)
echo "AFFECTED_PACKS=$AFFECTED_PACKS" >> $ENV_FILE
- name: Build & push Docker images (only affected packs)
run: |
for pack in $AFFECTED_PACKS; do
docker build -f apps/$pack/Dockerfile -t registry/$pack:$SHA .
docker push registry/$pack:$SHA
done
- name: Deploy affected packs
run: |
for pack in $AFFECTED_PACKS; do
deploy_service $pack registry/$pack:$SHA
done
9.5 Nx Remote Cache
STATUS: DEFERRED — Will evaluate Nx Cloud vs self-hosted after initial setup is stable.
Nx can cache build artifacts remotely. When enabled, if a pack was already built with the same inputs, CI skips the build entirely and pulls from cache.
10. Adding a New Game — Developer Workflow
10.1 Step-by-Step
Step 1: Generate the game library
nx g @nx/js:lib game-my-new-game --directory=games/my-new-game --tags="type:game"
This creates:
games/my-new-game/
├── project.json # { "tags": ["type:game"] }
├── tsconfig.json
└── src/
└── index.ts
Step 2: Implement the game plugin
// games/my-new-game/src/index.ts
import { ISlotGamePlugin, ISpinInput, ISpinOutput } from '@slot-platform/sdk';
export class MyNewGamePlugin implements ISlotGamePlugin {
readonly gameId = 'mynewgame';
readonly mathModes = ['R3', 'R5', 'R7'];
readonly mathFileName = 'my-new-game';
async onLoad() { /* load math files */ }
init(input) { /* ... */ }
spin(input) { /* ... */ }
feature(input) { /* ... */ }
}
Step 3: Add math files
games/my-new-game/src/maths/
├── my-new-game-R3.json
├── my-new-game-R5.json
└── my-new-game-R7.json
Step 4: Register in a pack's app.module.ts
// apps/pack-alpha/src/app.module.ts
import { Module } from '@nestjs/common';
import { GameCoreModule } from '@slot-platform/game-core';
import { MyNewGamePlugin } from '@slot-platform/game-my-new-game';
@Module({
imports: [
GameCoreModule.register([
// ...existing plugins
new MyNewGamePlugin(), // ← Add this line
]),
],
})
export class AppModule {}
Step 5: Update pack's Dockerfile to copy math files (or automate with a script)
That's it. No changes to SDK, game-core, shared-nestjs, rgs-client, other packs, or other games.
10.2 Custom Nx Generator (Optional)
Create a custom generator to automate steps 1-4:
nx g @slot-platform/tools:new-game --name=my-new-game --pack=pack-standard-b
This would:
- Scaffold the game directory with boilerplate
- Create empty math file stubs
- Add the import to the target pack's plugin loader
- Update the pack's Dockerfile
10.3 Files Changed Per New Game
| File | Change |
|---|---|
games/my-new-game/* | New — game logic and math files |
apps/pack-X/src/app.module.ts | Add 1 import + 1 new Plugin() in GameCoreModule.register() array |
apps/pack-X/Dockerfile | Add COPY line for math files |
Zero changes in: SDK, game-core, shared-nestjs, rgs-client, other packs, other games.
11. Performance Optimizations
11.1 HTTP Connection Pooling for RGS (Native Fetch + Undici)
Uses Node.js built-in fetch (backed by undici) — no axios dependency.
// libs/rgs-client/src/rgs.service.ts
import { Agent } from 'undici';
// Connection-pooled agent shared across all RGS calls
const rgsAgent = new Agent({
keepAliveTimeout: 60_000,
keepAliveMaxTimeout: 120_000,
connections: 100, // max connections per origin
pipelining: 1,
});
// Usage in RGS service
async debit(payload: IDebitRequest): Promise<IDebitResponse> {
const response = await fetch(`${this.rgsUrl}/bet`, {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
// @ts-expect-error -- dispatcher is a Node.js/undici-specific option
dispatcher: rgsAgent,
});
if (!response.ok) {
throw new RgsError(`RGS debit failed: ${response.status}`);
}
return response.json() as Promise<IDebitResponse>;
}
Impact: Eliminates TCP handshake per request. Reduces RGS call latency by 50-100ms. Zero external HTTP dependencies.
11.2 Cluster Mode (Multi-Process)
Each pack runs in cluster mode (see Section 8.1). For a 4-core container, this means 4 Node.js processes, 4x throughput.
11.3 Math File Preloading
Games preload all math files in onLoad() during application startup:
async onLoad() {
for (const mode of this.mathModes) {
const filePath = path.join(__dirname, 'maths', `${this.mathFileName}-${mode}.json`);
const raw = await fs.promises.readFile(filePath, 'utf-8');
this.mathConfigs.set(mode, JSON.parse(raw));
}
}
Impact: No blocking require() calls during request handling. Predictable memory usage.
11.4 Circuit Breaker for RGS
// libs/rgs-client/src/circuit-breaker.ts
import CircuitBreaker from 'opossum';
const breaker = new CircuitBreaker(
(url: string, options: RequestInit) ≥ fetch(url, { ...options, dispatcher: rgsAgent }),
{
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 10000,
}
);
Impact: Prevents cascading failures when RGS has transient issues.
11.5 Fastify (Confirmed — Default Adapter)
All packs use Fastify via @nestjs/platform-fastify. 2-3x raw throughput over Express for JSON-heavy workloads. Configured automatically by PlatformBootstrap with:
FastifyAdapter({ bodyLimit: 50MB })(configurable)- All shared-nestjs code is adapter-agnostic (no Express/Fastify imports in shared code)
@nestjs/platform-expressand@types/expresshave been removed from dependencies
11.6 Observability
Add Prometheus metrics to each pack:
// libs/shared-nestjs/src/monitoring/
- Request rate per game
- Spin latency histogram
- RGS call duration
- Math file load time
- Active connections
- Memory usage per process
12. Migration Plan from Current Codebase
Phase 1: Foundation
| Step | Description | Affected |
|---|---|---|
| 1.1 | Initialize Nx workspace with @nx/nest plugin + pnpm + SWC build tooling (@nx/js:swc) | New workspace |
| 1.2 | Create libs/sdk — extract all src/shared/helpers/* and src/shared/interfaces/* | SDK lib |
| 1.3 | Create libs/shared-nestjs — extract middleware, interceptors, filters, config | Shared NestJS lib |
| 1.4 | Create libs/rgs-client — extract RGS service with connection pooling | RGS lib |
| 1.5 | Define ISlotGamePlugin interface in SDK | SDK lib |
| 1.6 | Configure Nx module boundary rules | nx.json / eslint |
Phase 2: Pilot Game Migration
| Step | Description | Affected |
|---|---|---|
| 2.1 | Migrate olympus-scatter-realms (168K math, representative complexity) as first game library | games/olympus-scatter-realms/ |
| 2.2 | Create apps/pack-pilot — single NestJS app with plugin loader | apps/pack-pilot/ |
| 2.3 | Wire olympus-scatter-realms into pack-pilot with core game service | Pack-pilot app |
| 2.4 | Validate end-to-end: init, spin, feature against RGS | Testing |
| 2.5 | Run RTP simulation against the migrated game to verify math accuracy | RTP validation |
Phase 3: New Games Only (No Bulk Migration)
Decision: Remaining monolith games will NOT be migrated to this repo. New games are added as needed following the game plugin pattern. The old monolith is retained for a different client.
| Step | Description | Affected |
|---|---|---|
| 3.1 | New games are created directly in games/ as pure TypeScript plugins | games/*/ |
| 3.2 | New games implement ISlotGamePlugin and register via GameCoreModule.register() | Game libs |
| 3.3 | Pack grouping for new games follows Section 6 criteria | Pack apps |
Phase 4: Pack Creation & Deployment
| Step | Description | Affected |
|---|---|---|
| 4.1 | Create additional packs as game count grows (NATO phonetic naming) | Pack apps |
| 4.2 | Create Dockerfiles per pack (multi-stage) | Dockerfiles |
| 4.3 | Configure CI/CD with nx affected | Pipeline |
| 4.4 | Configure load balancer routing (gameId → pack) | Infrastructure |
Phase 5: Performance & Observability
| Step | Description | Affected |
|---|---|---|
| 5.1 | Add cluster mode to all pack apps | Pack apps |
| 5.2 | Add circuit breaker to RGS client | RGS lib |
| 5.3 | Add Prometheus metrics | Shared NestJS lib |
| 5.4 | Load testing per pack | Testing |
Phase 6: Old Monolith (Retained)
The old monolith (cl_slot_game_be) will NOT be decommissioned — it continues to serve a different client. This Nx workspace operates independently alongside it.
13. Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| SDK breaking change affects all games | Medium | High | Semantic versioning discipline; nx affected catches all impacted games in CI; comprehensive SDK test suite |
| Math file accuracy regression during migration | Medium | Critical | Run RTP simulations before and after each game migration; compare output distributions |
| Nx learning curve for the team | Medium | Medium | Nx has excellent documentation; start with pilot game; the @nx/nest plugin handles most NestJS-specific config |
| Cross-pack game dependency (game A needs game B's config) | Low | Medium | Not possible if module boundary rules enforced; games share nothing except SDK |
| Large Docker images from math files | Medium | Low | Multi-stage builds minimize image size; math files are bundled (confirmed). Megaways packs will have larger images (~63MB math) — use larger pull timeout in container config |
| Load balancer routing complexity | Low | Medium | Start simple (ALB path rules); move to registry-based routing only when needed |
| Increased CI complexity | Medium | Medium | Nx handles this well with affected commands; remote cache reduces redundant builds |