Inside the Combat AI: Broadsides, Evasive Rolls, and the Anti-Pileup Problem

2026-09-11 · Engineering

There's no scripted "boss AI" here — every ship, friendly or hostile, runs the exact same per-frame decision loop. This is a walkthrough of that loop: how a ship picks a target, decides whether to close head-on or swing broadside, and reacts when it starts taking damage on one side.

One function, every ship

All combat behavior lives in a single function, _updateCombatAI(), called once per frame for every ship on the map — player-controlled or AI, friendly or enemy. There's no separate "smart" path for enemies; the only asymmetry is orders (a player can override a friendly ship's stance), never capability.

Underneath it, a spatial hash grid (120-unit cells, rebuilt once per frame) turns every "find nearby ships" query from an O(n²) scan into a handful of bucket lookups — that's what makes it affordable to run this logic on every ship, every frame, without the frame rate falling over once a battle has 20+ ships in it.

Picking a target: pursuit class and the anti-pileup cap

Every ship type has a pursuitClass — capital, fighter, bomber, or utility — set in its YAML definition, the same idea as FreeSpace 2's ai_actively_pursues table. It changes who a ship even considers as a target:

There's also a hard cap: MAX_ATTACKERS = 3. Before a ship locks a fresh target, it checks how many other ships are already attacking that candidate this frame — if it's already got 3 attackers, the pick is skipped in favor of a less-contested target. This is checked and updated live within the same frame's loop (not just once at the top), so ships processed later in the pass see the picks made earlier in it — otherwise every ship evaluates the same stale snapshot and they all still dogpile the same target simultaneously.

Bug found in testing: targets used to be recomputed as pure "nearest enemy" every frame. Between two roughly-equidistant swarm members, that pick flips constantly — and every flip resets the broadside approach lock (more on that below), so the ship kept re-aiming at a new intercept point instead of ever actually closing distance. A headless simulation run confirmed it: one fighter's target switched roughly every 5 seconds and its distance to target diverged instead of converging, never once getting inside its own weapon range. The fix is target stickiness: once a target is locked, a ship keeps it unless it dies, leaves sensor range/line-of-sight, or a new candidate is at least ~30% closer (STICKY_SWITCH_FACTOR = 0.7).

Head-on or broadside? It depends on the loadout

A ship's hardpoints each have a facing angle. If any equipped hardpoint faces roughly sideways (60°–120° or 240°–300° from the bow), the ship is flagged as having a broadside weapon and fights by presenting a flank instead of pointing its nose at the target. Pure forward-mounted loadouts skip all of that and just drive straight at the enemy, stopping at weapon range.

The engagement distance itself — idealRange — is a DPS-weighted average across every weapon in the loadout, not the naive minimum range of any single weapon.

Why not the minimum? The first version used the shortest range in the loadout. That meant one cheap point-defense turret (very short range) dragged the whole ship's preferred engagement distance down to point-blank — even if the rest of the loadout was long-range siege torpedoes. A missile boat with a single defensive turret bolted on was handing away its own range advantage. Weighting each weapon's range by its share of total DPS means a couple of long-range launchers alongside one point-defense gun still keep the ship fighting from well outside brawling range.

The broadside maneuver

Once a target's locked, the ship computes two candidate broadside positions — one on either side of the target, perpendicular to the bearing between them — and picks whichever is closer, with hysteresis: it only abandons its current side if the other one is more than 30% closer, so it doesn't flip-flop sides every frame while both distances are similar.

That approach bearing is locked for the whole maneuver interval once chosen. Without locking it, the bearing keeps recalculating as the ship moves — since the ship is moving roughly perpendicular to the enemy during a broadside pass, the target position it's steering toward would itself keep orbiting and never settle.

On top of the base position, two more offsets get layered in every time the maneuver re-triggers:

Skill: turn-rate ratio drives everything

There's no explicit "difficulty" stat. Instead, every maneuver decision scales off skillRatio — the ship's own turn rate divided by its current target's turn rate, capped at 5× (skillFactor). A ship that out-turns its target re-evaluates its maneuver more often, jukes harder, and presses to a tighter range; a ship that's outclassed re-evaluates less often and tends to hang back.

Bug found in testing: fighters have a ~220°/s turn rate; capital ships sit around 5–9°/s. That's a skill ratio of 30–40× — the formulas below were tuned assuming a much narrower range, so at that ratio the maneuver-reevaluation interval collapsed to a fraction of a second, and the vertical flip chance (originally 0.2 × skillRatio) became mathematically guaranteed on every single cycle. In practice: fast ships visibly bounced up and down on the Y axis instead of occasionally flipping altitude. Capping skillFactor at 5 (rather than using the raw ratio) fixed it across the board.

Evasive rolls: reacting to actual damage

Each ship tracks how many hits it's taken on its port and starboard side (_hitSides), decaying those counters continuously so old damage doesn't linger forever. If one side has taken 3 or more hits than the other, the ship flips which broadside it presents — literally an evasive roll, presenting the less-damaged flank — and resets both counters so it doesn't immediately flip back. A 12-second cooldown after any flip prevents thrashing between sides if both flanks keep taking roughly even damage.

Retreat and kamikaze: what happens at zero ammo

Ships with only guided weapons (missiles, torpedoes) and no energy weapon in their loadout check their remaining ammo every frame. Once every guided weapon reads zero:

The short version

None of this is a single "AI system" so much as a stack of independently reasonable rules — pursuit class limits who's a valid target, the attacker cap spreads engagement across a fleet instead of dogpiling, stickiness keeps a lock stable long enough to actually close distance, DPS-weighted range keeps mixed loadouts fighting at a sane distance, and skill ratio scales maneuver aggressiveness without a difficulty slider anywhere. Most of the interesting bugs above came from one formula quietly assuming a narrower range of inputs than the game actually produces once fighters and capital ships share the same code path.

← All posts Read: Core Concepts →