Skip to main content

Architecture

Project layout, the scripts, and the collision design pillar.
Published

Last updated: 2026-11-03

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
NoteA deliberate deviation from a scene-heavy design

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

Table 1: Each script and its responsibility.
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)

  1. ✅ No-stack soft separation between live units.
  2. ✅ Per-type footprint — cavalry wider than infantry, so charges and screens read correctly. Landed as the per-type separation radii in Unit.gd.
  3. ✅ Formation cohesion — a regiment holds a block/line shape while moving, not a blob. Landed as the block/line slot geometry in UnitFormation.gd.
  4. ✅ 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.
  5. ✅ 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 as SpatialHash.gd/SoldierSpatialHash.gd; see docs/individual-collision-design.md for 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.

Figure 1: The engaged close-tier front (fully detailed, individually-simulated soldiers) in the foreground, and the far larger aggregate far-tier reserve force revealed as the camera pulls back.

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

Table 2: Common tuning knobs and where each lives.
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

Source: PLAN.md and README.md in the repository.

Back to top