Architecture
Sparta is a self-contained tactical battle and a turn-based campaign map, wired up in code, with the campaign able to launch a battle and take its result back. The codebase is small and deliberately flat — easy to read end to end.
Project layout
project.godot Godot project config (main scene = scenes/MainMenu.tscn)
scenes/MainMenu.tscn Title screen: launch a battle, a custom battle, or a campaign
scenes/Battle.tscn Battle scene: camera + units container + selection + HUD
scenes/Prebattle.tscn Custom-battle roster picker
scenes/Campaign.tscn Campaign map: province view/controller + campaign HUD
scripts/
Battle.gd Spawns armies, enemy AI, win/lose check, tick clock + replay orders
Replay.gd Deterministic record/playback (autoload): seeded RNG + order log
Unit.gd Regiment: stats, movement, melee, flanking, morale, routing
SelectionManager.gd Click + drag-box selection, move/attack orders
CameraController.gd WASD / edge pan, wheel zoom
HUD.gd Unit info panel, victory/defeat overlay
assets/ CC0 art goes here (see Assets) --- not required to run
The UI is built in code in HUD.gd instead of separate .tscn files, and units are instantiated in code (Unit.new() in Battle.gd) instead of a Unit.tscn. Fewer scene files to corrupt, functionally equivalent.
The scripts at a glance
| Script | Responsibility |
|---|---|
Battle.gd |
Spawns two armies, runs the enemy AI, the fixed-tick clock, applies queued orders, checks win/lose, saves the replay on battle end. |
Unit.gd |
A regiment: stats and the IDLE→MOVING→FIGHTING→ROUTING/DEAD state machine, movement, melee with flanking, morale/routing, soft separation, and _draw() visuals. |
SelectionManager.gd |
Left-click + drag-box selection; right-click move/attack order issuing. |
CameraController.gd |
WASD / arrow / screen-edge pan, mouse-wheel zoom. |
HUD.gd |
Selected-unit info panel, victory/defeat overlay, active pause, ● REC indicator. |
Replay.gd |
Autoload: the single seeded RNG and the record/playback system (see Replays). |
Design pillar: collision is core
In a large-scale tactical battle, where the bodies are is the game. Sparta treats collision as a first-class system, not polish: units must physically occupy space, press against each other, hold formation, and be blocked by friend and foe. Flanking, screening spearmen, cavalry charges, and chokepoints are only meaningful if units cannot pass through or stack on one another. Collision correctness and performance take priority over new-feature breadth.
Current state — soft separation, plus everything below. Each frame, a unit pushes out of any overlapping unit (live or routing) by half the overlap; the neighbor corrects the other half. The center-to-center floor is a per-type radius (cavalry wider than infantry), not a shared constant, and it’s intentionally soft so regiments still press into melee contact (attack reach > separation floor) instead of bouncing apart. Two engaged enemy lines close until their front ranks meet, and a spearman line holds firm against cavalry — the horse takes the full correction, so it can’t ride through a screen. The logic lives in Unit.gd → _separate(), on top of the SpatialHash.gd neighbor grid. All five roadmap items below have landed.
Collision roadmap (priority order)
- ✅ No-stack soft separation between live units.
- ✅ Per-type footprint — cavalry wider than infantry, so charges and screens read correctly. Landed as the per-type separation radii in
Unit.gd. - ✅ Formation cohesion — a regiment holds a block/line shape while moving, not a blob. Landed as the block/line slot geometry in
UnitFormation.gd. - ✅ Hard blocking — spearmen screen and physically stop cavalry passage; lines form chokepoints. Where collision and the rock-paper-scissors design meet. Landed as the asymmetric push-share in
Unit.gd→_separate(): a spearman takes none of the separation correction against cavalry, so the horse can’t push through. - ✅ Scale — replace the O(n²) neighbor scan with a spatial grid (and/or
CharacterBody2D/move_and_slide) before unit counts grow past a few dozen. Landed asSpatialHash.gd/SoldierSpatialHash.gd; seedocs/individual-collision-design.mdfor the full individual-collision design.
Scaling to large battles: two simulation tiers
A single battle scene can mix two simulation fidelities at once, one per formation:
- Close tier — full per-soldier simulation, exactly the individual-body collision/combat model described above. Used for formations currently in or near active combat.
- Far tier — an aggregate record (living count, morale, position, facing) evolved by statistical rules instead of per-soldier physics, for formations far from any fight (reserves, a second echelon). A far tier formation costs the same regardless of how many soldiers it represents, so a handful of aggregate reserve corps can stand in for tens of thousands of soldiers without the per-soldier cost.
A formation crosses tiers automatically as it nears or leaves combat — promoting to close tier as an enemy closes to strike range, demoting back to far tier once it disengages and falls back.
Where a far tier formation is within reach of an enemy — running down a broken one, which the tier triggers deliberately ignore, or in a scenario that deliberately tightens the promote/demote band — its attrition resolves through the same casualty, morale, and rout machinery the close tier uses, at aggregate cost: it takes losses, its morale erodes, and it breaks and flees like any other regiment, instead of standing locked until it promotes. See docs/large-scale-simulation-design.md for the full design and the phased build-out.
Determinism
The whole simulation is built to be reproducible — one seeded RNG stream, a fixed 60 Hz physics tick, and orders applied on the next tick so live play and replay share a code path. This is what powers the replay system and the CI demo clips. See Replays for the full story.
Tuning pointers
| Want to change… | Look in… |
|---|---|
| Unit stats / loadout | Battle.gd → _spawn_line() array |
| Collision spacing / soft-resolve | Unit.gd → _separate() (floor = RADIUS + other.RADIUS); spawn gaps via spacing in _spawn_line() |
| Movement pace | Battle.gd → SPEED_SCALE constant (lower = slower) |
| Combat math | UnitCombat.gd → strike() / take_casualties() / flank_multiplier() |
| Active pause | HUD.gd → _toggle_pause() (Space) |
| Enemy AI | Battle.gd → _run_enemy_ai(), layering General.gd, Subcommander.gd, and UnitLeader.gd |