Documentation

This is an overview of the example RTS game made with simuu lib. Most of the code is in ./src/rts.

Terminology

Two players compete. A player is one side of the battle (P1/blue vs P2/red). Each player owns buildings and units (entity.owner is 1 or 2). The human controls one player, or neither in Watch mode. Code uses humanFaction for this (1 | 2 | null).

A level defines the initial game setup: selected map plus starting state for each side. Plots are pre-defined building locations on a map.

Minerals (₥) are the currency, generated by bases. A tick is one frame of the game loop. Grid cells are square tiles sized by GAME_CONFIG.gridSize. Advanced units (Tank, Shade, Druid) require a Lab building.

Spec

Two players compete on a level with fixed building slots (plots). Each player places buildings into plots—bases generate income and spawn units, labs unlock advanced units. Spawned units fight auto-battler style: they move, target, and attack autonomously using a counter-type priority system. Destroy all enemy bases to win. Matches run 2-5 minutes.

Game flow

Click a plot to place your first Base. After a brief countdown, combat begins. The AI auto-places at the farthest available plot. Train units, expand with more Bases, destroy all enemy bases to win.

Buildings

Base generates income and spawns units. Lab unlocks Tank, Shade, and Druid.

Templates have cost and hpRatio (multiplied by buildingHpMultiplier). Derived stats export as BUILDINGS.

Units

Five unit types. Four combat units form a rock-paper-scissors counter system: Brut is basic melee Small. Flint is ranged Small with anti-air. Tank is slow Big with splash damage. Shade is a fast AntiBig assassin with bonus damage against Big types. Druid is a support unit that restores HP to wounded allies instead of attacking.

Each template defines cost (normalized), hpRatio and dpsRatio or hpsRatio (stat budget allocation, 0-1), attackSpeed (attacks per second), range (grid cells), speed, and type (small/big/antiBig/support). Optional fields: antiAir, splash, bonusVsLarge, heals.

Derived stats export as UNITS, colors as UNIT_COLORS. See window.UNITS for current values.

Combat

Units auto-target the nearest enemy, prioritizing favorable counter matchups. The counter triangle: Small beats Big/Splash, AntiBig beats Small, Big/Splash beats AntiBig. Druids target wounded allies instead, prioritizing the most damaged unit within range.

Target stickiness: units commit to their target and keep chasing until it dies or exceeds maxChaseRange (12 grid cells). This enables kiting, baiting, and pulling tactics.

Tank splash hits nearby enemies at reduced damage. Shade gets bonus damage vs Big (see bonusVsLarge). Druids cannot attack and will idle if no allies need healing.

Flocking behavior prevents stacking. Units push away from nearby allies while moving toward enemies. Tune with GAME_CONFIG.separationRadius and separationStrength.

Combat uses discrete attacks via combat.ts. Units create attack effects when in range; tickEffects() processes them and returns CombatEvents with position data. Events flow to combat log and visuals.

Units drift perpendicular to their target while attacking, creating organic movement. Toggle with GAME_CONFIG.driftEnabled, tune with driftSpeed and driftFrequency.

AI

AI players prioritize building a Lab, expand when affordable, and spawn units on a timer. Unit selection uses weighted randomness favoring cheaper units. AI never spawns Druids. Runs for any player not controlled by human (Watch mode runs AI on both sides).

Balance

hpPerCost, dpsPerCost, and hpsPerCost set HP, DPS, and HPS per normalized cost unit. Healing is deliberately less efficient than damage to prevent stalemates. mineralDisplay converts internal cost to displayed minerals. buildingHpMultiplier makes buildings tougher than units. See window.BALANCE for current values.

Each Base generates passive income. startingMinerals computes from startingCost × mineralDisplay. baseGenerationRate is income per second (incomePerMinute / 60).

Stats derive from normalized cost. Templates define cost and allocation ratios. Final stats compute automatically: HP = cost × hpPerCost × hpRatio, DPS = cost × dpsPerCost × dpsRatio, HPS = cost × hpsPerCost × hpsRatio.

Run bun test src/rts/balance.test.ts to verify economy timing, cost efficiency across units, TTK ranges, counter effectiveness, and role differentiation. The test file prints TTK matrix, cost efficiency metrics, and economy timeline.

Balance CLI at src/rts/balance-cli.ts for quick analysis:

Visual balance dashboard at /balance shows the same data in browser.

Visuals

visuals.ts renders ephemeral combat feedback without coupling to game logic.

Targeting lines show which units are attacking which targets — dashed lines drawn from active effects. Attack flashes are brief colored lines that appear when damage fires. Heals and splash damage have distinct colors.

Toggle via VISUAL_CONFIG.showTargetingLines and VISUAL_CONFIG.showAttackFlash. Tune flashDuration, lineAlpha, flashAlpha for feel.

Sounds

Hybrid system using @pixi/sound. Combat and game events are procedurally generated via Web Audio oscillators. UI sounds use sample pools with multiple variations for natural feel.

Procedural sounds: hit (square wave burst), death (sawtooth descent), explosion (low boom + noise), heal (ascending shimmer), spawn (sine tone), build (triangle wave), victory (sine tone), defeat (sawtooth), countdownTick, countdownGo.

Sample pools in /public/sfx/ui/ subfolders for click, hover, error, confirm. Each play picks a random sample from the pool.

Throttling prevents audio spam for high-frequency sounds. playSoundVaried() adds pitch variation.

Controls: toggleSound(), setMasterVolume(volume), window.SOUND_CONFIG. Edit src/rts/sounds.ts for volume settings.

Controls

1-5 spawn units (Brut, Flint, Tank, Shade, Druid). 6-7 select buildings (Base, Lab) for placement. Tab cycles controlled player: P1 (blue), Watch (AI vs AI), P2 (red). Space pauses, Escape deselects, arrow keys pan.

Click on map to set rally point - your units move there, fighting enemies in range (attack-move). Pulsing circle shows rally position. Use this to bait enemies, retreat, or stage flanks.

S opens level picker. R restarts after game over. G toggles target priority (units vs buildings).

Time controls: - or [ slows, + or ] speeds. F enables frame-step mode, . advances one frame.

Tank, Shade, and Druid require a Lab.

Level pipeline

A level defines initial game setup — a map (geometry + plots) plus starting state for each player:

Level
├── map: string                        // references MAPS key
├── player1/player2: LevelPlayer    // starting state per player
│   ├── buildings?: [{ type, slot }]   // pre-placed, slot indexes into plots
│   ├── units?: [{ type, count }]      // spawn near first base
│   └── minerals?: number
└── countdown?: boolean                // 3-2-1-go before combat

Map
├── width/height: number               // grid size in cells
└── plots: [{ x, y }, ...]              // building slot coordinates

Maps are defined in src/rts/map-data.ts. Levels reference them by id (most use default).

Loader flow (loadLevel(id)):

reset state, clear combat log


store level in gameState.level (with id and resolved plots)


create plot graphics from gameState.level.plots


place buildings at indexed slots (type + slot → position)


spawn units near first base per side


set minerals per side


status → 'ready' (awaiting START click)

Game loop phases:

'ready'     → waiting for START button
'countdown' → timer ticks, construction runs, no combat
'playing'   → resources → construction → production → units → effects → deaths → AI → visuals
'ended'     → winner determined, game over overlay

Headless path (simulation.ts) runs same logic without graphics—used for balance testing.

Architecture

Three layers, each with clear responsibility:

lib/ is the pure stdlib. effects.ts defines effect timing/payloads, combat-log.ts has event types and query filters. No game knowledge, no pixi, no side effects. Reusable across projects.

src/rts/combat.ts bridges stdlib and game. Knows about Unit/Entity types, creates effects from unit stats, processes effects and returns combat events. Still no pixi, no sounds—just data in, data out.

src/rts/combat-log-store.ts handles event logging and IndexedDB persistence. Browser-specific concerns live here.

src/rts/tick.ts orchestrates the simulation loop. Each tick: economy → construction → production → behavior → effects → deaths → AI. src/rts/behavior.ts handles unit decision-making (healer vs combat logic). src/rts/production.ts handles spawning and building placement, src/rts/state.ts manages game state.

src/rts/simulation.ts is the headless twin. Same logic path, no graphics. Used for balance testing.

runSimulation(level, { seed, maxTicks }) loads a level and ticks until someone wins or times out. Seeded RNG makes results deterministic for regression testing. simulation.test.ts runs every level to completion and logs win rates across seeds.

Config

PixiJS engine, @pixi/sound for audio, Astro framework. Runs at 60 FPS via requestAnimationFrame with delta time tracking. Grid-based positioning (cell size in GAME_CONFIG.gridSize). Plot locations defined per map in src/rts/map-data.ts.

Logging

WoW-style combat log. Types and queries in lib/combat-log.ts, storage in src/rts/combat-log-store.ts. Events use short keys (t time, e event, s source, d dest, v value) and entity tuples [id, owner, type].

Composable filters (byType, bySourceOwner, byTimeRange) and aggregations (totalDamageByOwner, killsByOwner, avgLifespanByUnitType). Games persist to IndexedDB locally and sync to Cloudflare D1 remotely.

Remote storage uses a games table with queryable metadata (id, ts, level, duration, winner) plus a data column storing the full GameRecord JSON blob. API endpoints in src/pages/api/games/:

Client functions: syncToRemote(game), getRemoteGameIndex(), loadRemoteGame(id). Games auto-sync when finishGame() completes. Schema in schema.sql, D1 binding configured in wrangler.jsonc.

Debugging

The time device in the bottom-right shows game time, tick counter (T###), and speed multiplier. Transport buttons control playback: << slows, >|| plays/pauses, >> speeds, >| enables frame-step mode.

After load, window.simuu exposes config for live tweaking:

simuu.BALANCE // core balance constants
simuu.UNIT_TEMPLATES // unit stat templates
simuu.BUILDING_TEMPLATES
simuu.GAME_CONFIG // grid size, speeds, etc
simuu.UNITS // derived unit stats
simuu.gameState // live game state
simuu.VISUAL_CONFIG // targeting lines, attack flashes
simuu.LEVELS // level definitions
simuu.loadLevel('swarmVsTank') // load a test level

References