Skip to content

Charger/Spider Dodge-Orbit AI — Design

A 60-run diagnostic soak sweep found chargers (TYPE_ELITE) cause 60-67% of all Ace deaths across two post-damage-boost runs, at roughly the same mean level as every other death cause — not a late-game-specific problem, a threat throughout. Chris’s own read: “he needs to learn how to orbit them and use warp [dash] at the right time.” Investigated and confirmed: SkilledAutoPlayer’s movement is currently PURELY radial (flee/seek/wall/engage, all summed and normalized, ai/skilled_auto_player.gd) — zero lateral/tangential component exists anywhere in the AI, so there is architecturally no way for Ace to sidestep a charge today.

What already exists (verified against source)

Section titled “What already exists (verified against source)”
  • Two enemy types share the exact same charge-then-lunge mechanic (BEHAVIOR_DASH, sim/sim.gd:2178-2182 dispatch to step_dash()): TYPE_ELITE (Charger — charge 1.5s, lunge 0.64s at 980px/s) and TYPE_SPIDER (Weaver — charge 0.4s, lunge 1.0s at 600px/s).
  • Per-enemy state is fully queryable via existing pool arrays: sim.enemies.dash_phase[i] (EnemyPool.DASH_CHARGE or EnemyPool.DASH_LUNGE), sim.enemies.dash_timer[i], and — critically — sim.enemies.vel[i], which is set to the EXACT locked lunge velocity the instant a lunge fires (sim/enemy_behaviors.gd:201) and stays constant for the whole lunge duration (straight-line ballistic motion, confirmed by the else branch integrating pos[i] += vel[i] * dt with no further steering during lunge).
  • The Elite Charger has a special chained double-dash: its SECOND dash telegraphs via a separate grow_t[i] timer (0..ELITE_CHARGER_CHAIN_CHARGE_S=0.5s) while dash_phase[i] is still DASH_LUNGE from the first dash (sim/enemy_behaviors.gd, the is_elite_charger branch under the lunge integration code). A detector that only checks dash_phase == DASH_CHARGE misses this second telegraph entirely — the design below handles it explicitly.
  • ThreatMap.enemy_weight() (ai/threat_map.gd) does not currently weight a charging/lunging enemy any differently than a calm one at the same distance — no existing special-casing to build on or conflict with.
  • No orbit/tangential helper exists in ai/ today; the perpendicular-vector pattern (Vector2(-v.y, v.x)) is already used for enemy-side effects (sim/enemy_behaviors.gd:507, weave sway) and is the natural building block, not a novel technique.
  • No /sim changes — every field this design reads already exists and is already queryable. This is entirely ai/skilled_auto_player.gd (the same file the earlier loadout-aware-range plan this session already modified).
  • No change to AutoPlayer (the deliberately-simple baseline, used by coverage soaks) — this is SkilledAutoPlayer-only, matching every other AI behavior improvement this session.
  • No full trajectory prediction during the CHARGE phase — the charger’s eventual lunge direction isn’t locked/readable until the lunge actually fires (it aims at wherever the target pilot is AT THAT MOMENT, sim/enemy_behaviors.gd:197: dir := sim._nearest_pilot(...).pos - sim.enemies.pos[i], evaluated only when the lunge fires). During the telegraph, this design only orbits generally around the charging enemy — it does not attempt to predict or steer toward a specific future-safe position, since the direction genuinely isn’t chosen yet.
  • No change to which enemy an auto-weapon targets, and no change to the flee force’s own math/weights — this is purely an ADDITIVE new force term plus an additive new dash condition, not a rework of existing behavior.

New constant-weighted force term in compute_move_for, alongside the existing flee/seek/wall/engage terms. For every nearby enemy (reuse the same THREAT_RADIUS-scoped sim.hash.query_circle results the flee loop already gathers, to avoid a second spatial query) where:

  • sim.enemies.behavior[i] == EnemyPool.BEHAVIOR_DASH, AND
  • sim.enemies.dash_phase[i] == EnemyPool.DASH_CHARGE, OR (elite-charger chain case) sim.enemies.type_id[i] == EnemyPool.TYPE_ELITE and sim.enemies.grow_t[i] < ELITE_CHARGER_CHAIN_CHARGE_S while dash_phase[i] == EnemyPool.DASH_LUNGE (the concurrent second-dash telegraph)

…compute the perpendicular-to-the-enemy direction (Vector2(-d.y, d.x).normalized() where d is the vector from pilot to enemy), weighted by an ORBIT_WEIGHT constant and a distance-based falloff matching the existing flee term’s shape. Sum into the existing force composition before the final normalize. A fixed rotational handedness (always the same sign) is used for determinism and simplicity — not adaptively chosen per-encounter. This is explicitly flagged as a first-pass simplification to validate in playtest/soak, not a claimed-optimal choice.

New condition in wants_dash(), checked alongside (not replacing) the existing inherited panic dash and the proactive engage-dash from the earlier loadout-aware-range work. For every nearby enemy where sim.enemies.dash_phase[i] == EnemyPool.DASH_LUNGE:

  • Compute the enemy’s straight-line lunge ray: origin sim.enemies.pos[i], direction sim.enemies.vel[i].normalized().
  • Compute the perpendicular distance from the pilot’s current position to that ray (standard point-to-line projection).
  • If that perpendicular distance is under a DODGE_DANGER_RADIUS constant AND the enemy is still closing ((pilot.pos - enemies.pos[i]).dot(enemies.vel[i]) > 0), trigger wants_dash() -> true.

This reuses the pilot’s existing dash i-frame window (already used by the proactive cluster-punch-through dash from earlier this session) — no new invulnerability mechanic needed, just a new, more urgent reason to use the existing one.

Priority / interaction with existing dash logic

Section titled “Priority / interaction with existing dash logic”

wants_dash()’s existing structure (from the earlier loadout-aware plan) is: inherited panic dash first (point-blank, always wins) → proactive engage-dash. This adds a THIRD check, evaluated alongside the proactive engage-dash (both are “should I dash” OR-conditions, not a strict priority order beyond panic-dash-always-wins) — if either fires, wants_dash() returns true. No new priority ordering is needed since dash is a single boolean trigger, not a target selector.

  • Unit tests in tests/test_skilled_auto_player.gd: construct a Sim with one TYPE_ELITE or TYPE_SPIDER enemy at a known position, manually set its dash_phase/vel fields to simulate each phase, and assert:
    • compute_move_for during DASH_CHARGE has a nonzero component perpendicular to the pilot→enemy vector (the orbit force actually contributes).
    • wants_dash() returns true when a lunging enemy’s velocity ray passes within DODGE_DANGER_RADIUS of the pilot AND is still closing.
    • wants_dash() returns false when a lunging enemy’s velocity ray points AWAY from the pilot (a lunge headed elsewhere shouldn’t trigger a defensive dash) — this is the key test that proves the check is genuinely direction-aware, not just “any nearby lunge.”
    • The elite-charger chain-dash case: dash_phase == DASH_LUNGE but grow_t active and under threshold still contributes an orbit force (the “second telegraph while still lunging” case).
  • Soak A/B against the current (post-damage-boost) baseline, --player=skilled, same seed range as the diagnostic sweeps already run this session — the real acceptance test: does death_cause_breakdown’s charger share actually drop, and does overall survival improve, without introducing invariant failures or anomalies.