Slot Game Backend — Nx Workspace Architecture with Game Packs

Table of Contents

  1. Executive Summary
  2. Current Architecture Analysis
  3. Proposed Architecture: Nx Monorepo with Game Packs
  4. Nx Workspace Structure
  5. SDK Library Design
  6. Game Pack Strategy
  7. Game Plugin Interface
  8. Core Service Architecture
  9. Build & Deployment Pipeline
  10. Adding a New Game — Developer Workflow
  11. Performance Optimizations
  12. Migration Plan from Current Codebase
  13. Risks & Mitigations
  14. 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:

DecisionChoiceRationale
Package managerpnpmFaster than Yarn, strict dependency resolution, first-class Nx support, disk efficient
HTTP clientNode.js native fetch + undiciBuilt into Node 22, zero dependencies, undici provides connection pooling
Game response modelGenerics on ISlotGamePluginEach game defines its own response shape while sharing common fields
Math file deliveryBundled in Docker imageSimple, no external dependency at runtime, deterministic
Pilot gameGates of Olympus (gatesofolympus)Rebranded from olympus-scatter-realms. Standard size (168K math), representative complexity
Build toolingWebpack (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 checkingtsc --noEmit (separate concern)SWC transpiles, tsc validates types independently
HTTP adapterFastify2-3x raw throughput over Express for JSON-heavy workloads. @nestjs/platform-fastify
API response envelopeApiResponseDtoStandardized { success, statusCode, message, data, extensions } wrapper for all responses
Core game orchestrationlibs/game-core (GameCoreModule)Shared library with DynamicModule.register(). Provides GameController, GameService, GamePluginLoaderService. Packs just import and pass plugins
BootstrapPlatformBootstrapReusable bootstrap class in shared-nestjs. Handles Fastify adapter, interceptor stack, filters, validation pipe. Pack main.ts is ~5 lines
Migration strategyNew games onlyRemaining monolith games will NOT be migrated. New games added as needed to this repo
Old monolithRetainedWill continue to serve a different client. Not decommissioned
CI/CDDeferredWill decide Azure vs AWS later
Nx remote cacheDeferredWill evaluate after initial setup
Request routingLoad balancer rules (likely)Parked for later discussion

2. Current Architecture Analysis

2.1 What Exists Today

AspectCurrent State
FrameworkNestJS 11 on Node 24
Games17 games, all in src/games/
Math files53 JSON files, 3 megaways games at 21MB each
BuildSingle nest build, single Docker image
DeploymentSingle ECS service / container
Game registrationManual — every game service injected into GameStrategyService constructor (17 imports, 17 strategies.set() calls)
Shared codesrc/shared/ — helpers, interfaces, middleware, interceptors
Total project size~457MB (including node_modules)

2.2 Current Problems at Scale

ProblemImpact
All 17 games load into every container3 megaways games alone consume ~63MB of parsed JSON per process
Single-process architectureCannot saturate multi-core ECS tasks / Azure Container App replicas
One game change → full rebuild + redeployCI takes longer as games grow; entire fleet restarts for a single game fix
Manual game registrationAdding a game requires editing GameModule, GameStrategyService, GameIds enum — 4+ files in core
No HTTP connection poolingNew TCP connection to RGS per request
No circuit breaker / retryRGS hiccup cascades to all games
Memory-hungry lintingAlready requires 6GB heap for ESLint due to math file proximity

2.3 Current Game Inventory by Math File Size

CategoryGamesMath Size Each
Megaways (Heavy)reign-of-power-megaways, legends-awaken-megaways, battle-for-the-crescent-megaways~21MB
Mediumgolden-catch (1.1MB), sugar-frenzy-1000 (468K), mega-ace (220K), candy-bonanza-1000 (204K)200K–1.1MB
Standardroyal-81, olympus-scatter-realms, olympus-realms-1000, mahjong-mastery-2, island-of-treasures, golden-phoenix-blaze, falcon-of-anatolia~168K
Lightweightfortune-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

ApproachProsCons
1 service per gameMaximum isolation, independent scaling100+ services to manage, 100+ CI pipelines, high infra cost
All games in 1 serviceSimple deployment, shared resourcesNo isolation, memory bloat, full rebuild always
Game packs (10-15 per service)Balanced isolation, manageable infra, independent scaling per packRequires 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/typescript plugin is configured for type checking inference. Libraries and games have only a typecheck target (no build — consumed as source by webpack). Only pack apps have a build target using nx:run-commands with webpack-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

GeneratorCommandPurpose
Create workspacenpx create-nx-workspace@latest slot-game-platform --preset=nest --pm=pnpmInitialize Nx workspace with NestJS + pnpm
New game pack appnx g @nx/nest:app pack-standard-cNew deployable NestJS application
New game librarynx g @nx/js:lib game-new-game --directory=games/new-gameNew game logic library (no NestJS)
New SDK libnx g @nx/js:lib sdk --directory=libs/sdkShared SDK library
NestJS componentsnx g @nx/nest:service --project=pack-megawaysService, controller, module within a pack
Custom game scaffoldnx g @slot-platform/tools:new-game --name=my-game --pack=pack-standard-aCustom 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:

Concerntsc (--build)SWC (@nx/js:swc)
SpeedSlow (type-checks + emits)~20x faster (transpile only)
composite: true requiredYes (for cross-project refs)No
Project references requiredYesNo
tsBuildInfoFile managementYesNo
NestJS decorator supportNativeVia legacyDecorator + decoratorMetadata
Type checkingBundled with buildSeparate (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 .swcrc copy because the @nx/js:swc executor looks for .swcrc in 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 in dist/
  • Only used by the @nx/js:swc executor, never by the IDE
  • No composite, no tsBuildInfoFile, 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

ItemWhere It Lives Instead
NestJS decorators, middleware, interceptors, filterslibs/shared-nestjs/
RGS HTTP clientlibs/rgs-client/
Decryption/encryptionlibs/shared-nestjs/
Game-specific config (symbols, layout, maxWin)Inside each game library
Math JSON filesInside 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:

  1. Memory footprint — megaways games (21MB each) get their own pack with fewer games
  2. Traffic pattern — high-traffic games together so they scale as a unit
  3. Game type affinity — similar game mechanics benefit from shared CPU cache patterns
  4. Business priority — new/promoted games in a dedicated pack for independent scaling
PackGamesMath SizeRationale
pack-megawaysreign-of-power-megaways, legends-awaken-megaways, battle-for-the-crescent-megaways~63MB totalIsolated due to massive memory footprint; can have larger instance type
pack-standard-amega-ace, mahjong-mastery-2, sugar-frenzy-1000, candy-bonanza-1000, olympus-scatter-realms, olympus-realms-1000, golden-catch~2.4MB totalMedium-weight games
pack-standard-bgolden-phoenix-blaze, island-of-treasures, falcon-of-anatolia, fortune-frog, legend-of-barong, rise-of-the-simurgh, royal-81~0.9MB totalLightweight 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

ResourceConfiguration
ContainerSeparate Docker image per pack
ECS Service / Azure Container AppIndependent service with own task definition
Auto-scalingPack-level scaling based on CPU/request count
Instance sizingMegaways pack: 4GB+ RAM; Standard packs: 1-2GB RAM
ReplicasIndependent 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:

  1. Fastify adapter with configurable body limit
  2. CORS and shutdown hooks
  3. Interceptor stack (in order): AsyncSessionInterceptor → DecryptionInterceptor (unless disabled) → ResponseInterceptor (unless disabled) → custom interceptors
  4. HttpExceptionFilter globally + custom filters
  5. ValidationPipe with transform: true, whitelist: true, throws NotValidClientError on failure
  6. 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_DECRYPTION env 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 PlatformBootstrap options

8.8 Game Controller & DTOs

The GameController (in game-core) provides 4 endpoints:

MethodPathDTOResponse
GET/games/health—{ status, games[] }
POST/games/initInitRequestDtoApiResponseDto<IInitResponse>
POST/games/spinSpinRequestDtoApiResponseDto<ISpinResponse>
POST/games/featureFeatureRequestDtoApiResponseDto<IFeatureResponse>

DTOs (with class-validator + class-transformer):

  • InitRequestDto: gameId, token (required), deviceType, extraData (optional)
  • SpinRequestDto: gameId, token, betAmount (required), buyBonus (nested BuyBonusDto), 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:

  1. Nx computes the dependency graph
  2. game-golden-catch is marked affected
  3. pack-standard-a (which imports game-golden-catch) is marked affected
  4. pack-megaways and pack-standard-b are NOT affected
  5. Only pack-standard-a rebuilds and redeploys

When a developer changes libs/sdk/src/helpers/arithmetic.ts:

  1. All games depend on sdk → all games affected
  2. All packs affected → full rebuild
  3. 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 --from=builder /workspace/dist/apps/pack-standard-a ./dist
COPY --from=builder /workspace/node_modules ./node_modules
# Math JSON files bundled in Docker image (loaded at runtime)
COPY --from=builder /workspace/games/mega-ace/src/maths ./maths/mega-ace
COPY --from=builder /workspace/games/golden-catch/src/maths ./maths/golden-catch
COPY --from=builder /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=&#36;(pnpm nx show projects --affected --type=app --base=origin/main)
      echo "AFFECTED_PACKS=&#36;AFFECTED_PACKS" >> &#36;ENV_FILE

  - name: Build & push Docker images (only affected packs)
    run: |
      for pack in &#36;AFFECTED_PACKS; do
        docker build -f apps/$pack/Dockerfile -t registry/$pack:&#36;SHA .
        docker push registry/$pack:$SHA
      done

  - name: Deploy affected packs
    run: |
      for pack in &#36;AFFECTED_PACKS; do
        deploy_service $pack registry/$pack:&#36;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:

  1. Scaffold the game directory with boilerplate
  2. Create empty math file stubs
  3. Add the import to the target pack's plugin loader
  4. Update the pack's Dockerfile

10.3 Files Changed Per New Game

FileChange
games/my-new-game/*New — game logic and math files
apps/pack-X/src/app.module.tsAdd 1 import + 1 new Plugin() in GameCoreModule.register() array
apps/pack-X/DockerfileAdd 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) &ge; 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-express and @types/express have 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

StepDescriptionAffected
1.1Initialize Nx workspace with @nx/nest plugin + pnpm + SWC build tooling (@nx/js:swc)New workspace
1.2Create libs/sdk — extract all src/shared/helpers/* and src/shared/interfaces/*SDK lib
1.3Create libs/shared-nestjs — extract middleware, interceptors, filters, configShared NestJS lib
1.4Create libs/rgs-client — extract RGS service with connection poolingRGS lib
1.5Define ISlotGamePlugin interface in SDKSDK lib
1.6Configure Nx module boundary rulesnx.json / eslint

Phase 2: Pilot Game Migration

StepDescriptionAffected
2.1Migrate olympus-scatter-realms (168K math, representative complexity) as first game librarygames/olympus-scatter-realms/
2.2Create apps/pack-pilot — single NestJS app with plugin loaderapps/pack-pilot/
2.3Wire olympus-scatter-realms into pack-pilot with core game servicePack-pilot app
2.4Validate end-to-end: init, spin, feature against RGSTesting
2.5Run RTP simulation against the migrated game to verify math accuracyRTP 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.

StepDescriptionAffected
3.1New games are created directly in games/ as pure TypeScript pluginsgames/*/
3.2New games implement ISlotGamePlugin and register via GameCoreModule.register()Game libs
3.3Pack grouping for new games follows Section 6 criteriaPack apps

Phase 4: Pack Creation & Deployment

StepDescriptionAffected
4.1Create additional packs as game count grows (NATO phonetic naming)Pack apps
4.2Create Dockerfiles per pack (multi-stage)Dockerfiles
4.3Configure CI/CD with nx affectedPipeline
4.4Configure load balancer routing (gameId → pack)Infrastructure

Phase 5: Performance & Observability

StepDescriptionAffected
5.1Add cluster mode to all pack appsPack apps
5.2Add circuit breaker to RGS clientRGS lib
5.3Add Prometheus metricsShared NestJS lib
5.4Load testing per packTesting

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

RiskLikelihoodImpactMitigation
SDK breaking change affects all gamesMediumHighSemantic versioning discipline; nx affected catches all impacted games in CI; comprehensive SDK test suite
Math file accuracy regression during migrationMediumCriticalRun RTP simulations before and after each game migration; compare output distributions
Nx learning curve for the teamMediumMediumNx 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)LowMediumNot possible if module boundary rules enforced; games share nothing except SDK
Large Docker images from math filesMediumLowMulti-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 complexityLowMediumStart simple (ALB path rules); move to registry-based routing only when needed
Increased CI complexityMediumMediumNx handles this well with affected commands; remote cache reduces redundant builds

14. References

Built with LogoFlowershow