Skip to main content

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 builds
  • offenseThreshold — when the NPC switches to sending enemies
  • statPriority — 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:

RendererResponsibility
StructureRendererTower and send-structure sprites
UnitRendererEnemy unit sprites and health bars
ProjectileRendererProjectile sprites and trajectory
PlotRendererPlot highlight and interaction zones
GhostRendererPlacement preview ghost when building
GuideRendererPath guide lines

SoloPixiCameraController handles WASD/arrow-key panning, mouse-wheel zoom, and right-click drag-to-pan.

Key Constants

ConstantValueSource
FRAME_STEP_MS50apps/game/src/app/play-solo/play-solo.ts
REPLAY_CAPTURE_INTERVAL_MS250apps/game/src/app/play-solo/play-solo.ts
SOLO_GRID_CELL_SIZE50libs/game/config/src/lib/grid.ts
SOLO_MATCH_CONFIG.balance.startingGold400libs/game/config/src/lib/match-config.ts
SOLO_MATCH_CONFIG.balance.coreHealth200libs/game/config/src/lib/match-config.ts
SOLO_MATCH_CONFIG.balance.prepPhaseMs20 000libs/game/config/src/lib/match-config.ts
SOLO_MATCH_CONFIG.balance.sellRefundRate0.75libs/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:

ParamEffect
debug=1Enables debug overlay and extra logging
autoStart=1Skips 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
info

autoStart=1 only works when debug=1 is also set.