Game Engine Architecture
This page explains the game engine design: how the ECS loop works, what the two match phases do, and how the Angular component drives the renderer.
For the full type and constant reference, see Reference.
Library Layers
The game engine splits across three libraries. Each layer only knows about the layers below it:
ECS Architecture (libs/game/core)
The engine uses bitECS for entity-component storage. All game entities live in a MatchWorld. The match lifecycle is:
advanceMatch() is a pure function — it takes a MatchState and deltaMs, runs the pipeline, and returns the next MatchState. No side effects.
Match Phases
The match alternates between two phases:
Preparation Phase
- Duration:
SOLO_MATCH_CONFIG.balance.prepPhaseMs(20 seconds by default) - NPCs and the player place or upgrade towers
- The phase ends when all alive players are ready, or the timer hits zero
- NPC AI thinks every
npcAi.thinkIntervalMs(1400 ms)
Combat Phase
- Enemies spawn from wave plans
- Tower, projectile, enemy, and spawn-queue systems run per frame
- The phase ends when all enemies are cleared from all lanes
- Dead players are eliminated (
eliminatePlayer)
NPC AI
Three NPC play styles (defensive, aggressive, balanced) are assigned by a hash of the NPC's PlayerId. Each style controls:
upgradeOverBuildChance— how often the NPC upgrades vs buildsoffenseThreshold— when the NPC switches to sending enemiesstatPriority— which tower stat to upgrade first
NPCs think on a timer. If the NPC has a pending action or active channel, it skips the turn.
Renderer (libs/game/pixi)
SoloPixiRenderer is instantiated once from PlaySoloComponent via SoloPixiRenderer.create(options). Dependency injection inside the renderer uses tsyringe containers.
Each frame, render(snapshot, options) passes a MatchSnapshot to sub-renderers:
| Renderer | Responsibility |
|---|---|
StructureRenderer | Tower and send-structure sprites |
UnitRenderer | Enemy unit sprites and health bars |
ProjectileRenderer | Projectile sprites and trajectory |
PlotRenderer | Plot highlight and interaction zones |
GhostRenderer | Placement preview ghost when building |
GuideRenderer | Path guide lines |
SoloPixiCameraController handles WASD/arrow-key panning, mouse-wheel zoom, and right-click drag-to-pan.
Key Constants
| Constant | Value | Source |
|---|---|---|
FRAME_STEP_MS | 50 | apps/game/src/app/play-solo/play-solo.ts |
REPLAY_CAPTURE_INTERVAL_MS | 250 | apps/game/src/app/play-solo/play-solo.ts |
SOLO_GRID_CELL_SIZE | 50 | libs/game/config/src/lib/grid.ts |
SOLO_MATCH_CONFIG.balance.startingGold | 400 | libs/game/config/src/lib/match-config.ts |
SOLO_MATCH_CONFIG.balance.coreHealth | 200 | libs/game/config/src/lib/match-config.ts |
SOLO_MATCH_CONFIG.balance.prepPhaseMs | 20 000 | libs/game/config/src/lib/match-config.ts |
SOLO_MATCH_CONFIG.balance.sellRefundRate | 0.75 | libs/game/config/src/lib/match-config.ts |
FRAME_STEP_MS = 50 caps the maximum deltaMs passed to advanceMatch() per frame. This prevents spiral-of-death if a frame stalls. The game loop clamps deltaMs before advancing the simulation.
REPLAY_CAPTURE_INTERVAL_MS = 250 controls how often the current MatchSnapshot is captured into replayFrames during a live match.
Debug Mode
Append ?debug=1 to the URL to enable debug mode. Additional params:
| Param | Effect |
|---|---|
debug=1 | Enables debug overlay and extra logging |
autoStart=1 | Skips deck selection (requires debug=1) |
deck=<deckId> | Selects a deck automatically |
seed=<number> | Sets the match RNG seed |
startRound=<number> | Starts the match at a specific round |
autoStart=1 only works when debug=1 is also set.