Charger/Spider Dodge-Orbit AI — Design
Charger/Spider Dodge-Orbit AI — Design
Section titled “Charger/Spider Dodge-Orbit AI — Design”Problem
Section titled “Problem”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-2182dispatch tostep_dash()):TYPE_ELITE(Charger — charge 1.5s, lunge 0.64s at 980px/s) andTYPE_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_CHARGEorEnemyPool.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 theelsebranch integratingpos[i] += vel[i] * dtwith 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) whiledash_phase[i]is stillDASH_LUNGEfrom the first dash (sim/enemy_behaviors.gd, theis_elite_chargerbranch under the lunge integration code). A detector that only checksdash_phase == DASH_CHARGEmisses 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.
Non-goals
Section titled “Non-goals”- No
/simchanges — every field this design reads already exists and is already queryable. This is entirelyai/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 isSkilledAutoPlayer-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.
Design
Section titled “Design”1. Orbit force (telegraph phase)
Section titled “1. Orbit force (telegraph phase)”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, ANDsim.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_Swhiledash_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.
2. Dodge-dash trigger (lunge phase)
Section titled “2. Dodge-dash trigger (lunge phase)”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], directionsim.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_RADIUSconstant AND the enemy is still closing ((pilot.pos - enemies.pos[i]).dot(enemies.vel[i]) > 0), triggerwants_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.
Testing
Section titled “Testing”- Unit tests in
tests/test_skilled_auto_player.gd: construct aSimwith oneTYPE_ELITEorTYPE_SPIDERenemy at a known position, manually set itsdash_phase/velfields to simulate each phase, and assert:compute_move_forduringDASH_CHARGEhas 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 withinDODGE_DANGER_RADIUSof 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_LUNGEbutgrow_tactive 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: doesdeath_cause_breakdown’s charger share actually drop, and does overall survival improve, without introducing invariant failures or anomalies.