Starwatch Commander is a browser game built for clarity and testability. Game rules live entirely apart from the UI, constants are centralized, and every cycle resolves through one deterministic function.
Typed component UI for menus, panels, modals, and HUD. Strict types across the whole rules layer.
Dev server and production bundler. npm run dev to play locally, npm run build to ship.
Renders the full-screen galaxy map — planets, ownership rings, stationed ships, fleets in flight, and route lines.
Fast unit tests for rules, production, travel, combat, AI, and pacing.
A single game store holds state; UI subscribes, rules mutate through resolution.
Client-side saves for the MVP — no backend required to play solo.
The golden rule: rules never live in components. The Phaser scene and React panels only read state and dispatch intents; all mutation happens in the rules layer, funneled through cycle resolution.
src/
game/
rules/ constants, combat, movement, production,
research, ai, aiDifficulty, victory,
turnResolution, galaxyGeneration, garrison, …
state/ types, gameStore, createNewGame, saveLoad,
matchTemplates
phaser/ GalaxyScene.ts (map rendering only)
ui/
screens/ Home, New Game, Command Center, Results, …
components/ BattleViewer, CombatModal, BottomBar,
BuildingSlot, planet panel, news ticker
styles/ settings.css and theming
audio.ts synthesized SFX / ambient (no audio assets)
docs/ in-repo design docs (00–10)
Constants and the planet catalog are centralized so a single edit re-balances the game everywhere. Provisional values (e.g., unverified original-game production numbers) are explicitly flagged confirmed: false so they never masquerade as canonical in UI or docs.
The entire match is one serializable GameState object — which is what makes saves and deterministic tests trivial.
type GameState = {
id: string;
mode: 'standard_single_player' | 'pass_and_play'
| 'online_private' | 'quick_match' | 'sandbox';
worldSize: 'tiny'|'small'|'medium'|'large'|'huge';
gamePace: 'turn_based' | 'timed_cycles';
cycleNumber: number;
players: Player[];
planets: Planet[];
travelingShips: TravelingShips[];
newsEvents: NewsEvent[];
winnerId?: string;
phase: 'home'|'setup'|'playing'|'combat'|'game_over';
};
Planets carry their catalog identity (rating, multipliers, tech affinity, slots) plus live state (owner, ships, fractional ship progress, buildings, focus). Fleets in flight are first-class TravelingShips with origin, destination, ships, and cycles remaining — which is exactly why they can be drawn on the map instead of hidden in a menu.
One central function advances the whole galaxy one cycle, in a fixed order. Keeping this in a single place is what keeps the game deterministic and testable — nothing sneaks a rule into a render pass.
The numbers that define the game, all sourced from a central constants.ts:
| Constant | Value |
|---|---|
| Starting gold | 1,100 |
| Homeworld starting ships | 16 |
| Factory cost | 200 gold |
| Tech Lab cost | 250 gold |
| Tech Level range | 0 – 15 |
| Research per lab per cycle | 1 unit |
| Base travel range | 1 hop (+1 / Tech) |
| Base travel speed | 1 hop/cy (+0.5 / Tech) |
| Combat win-chance clamp | 10% – 90% |
| Combat tech slope | 0.4 / 15 per level |
| Homeworld defense bonus | +3 effective Tech |
| Planet slots | 4 – 10 |
getMaxTravelRange(tech) = 1 + tech // hops
getTravelSpeed(tech) = 1 + tech × 0.5 // hops/cycle
travelCycles = ceil(distanceHops / speed)
attackerWinChance = clamp(0.5 + techDiff × (0.4/15), 0.10, 0.90)
Because a match is one serializable object, saving is just persisting GameState. The MVP uses client-side storage (localStorage / IndexedDB): autosave fires after every cycle and before Save & Exit. Save cards surface world size, pace, cycle number, owned planets, total ships, Tech Level, and last-played time, with continue / rename / delete actions.
The rules layer is covered by a Vitest suite — 192 passing tests as of the latest balance pass — spanning:
The latest pass reports a clean tsc -b, a successful production build, and 192/192 green.
npm install && npm run dev.