Skip to content

Charger/Spider Dodge-Orbit AI Implementation Plan

Charger/Spider Dodge-Orbit AI Implementation Plan

Section titled “Charger/Spider Dodge-Orbit AI Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give SkilledAutoPlayer a real lateral/tangential dodge — orbit around a telegraphing charger/spider instead of only backing straight away, and dash out of a committed lunge’s actual path — since the current AI has zero lateral movement at all and chargers cause 60-67% of Ace’s deaths.

Architecture: Two additive changes to ai/skilled_auto_player.gd: a new tangential force term in compute_move_for() (fires during the telegraph/charge phase) and a new precise collision-ray check in wants_dash() (fires during the committed lunge phase). Both read already-existing, already-queryable sim.enemies.* fields — no /sim changes.

Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0.

  • No changes to /sim — every field this plan reads (dash_phase, dash_timer, vel, behavior, grow_t, type_id) already exists and is already queryable.
  • No changes to AutoPlayer (the deliberately-simple baseline) — SkilledAutoPlayer-only.
  • The existing flee/seek/wall/engage force composition and the existing dash triggers (inherited panic dash, proactive engage-dash) stay byte-identical — this plan only ADDS new terms/OR- conditions, never modifies the existing ones.
  • grow_t is a REUSED field across enemy types for unrelated purposes (per sim/enemy_pool.gd:193’s own comment) — it must NEVER be read for the elite-charger chain-dash meaning without first confirming sim.enemies.type_id[i] == EnemyPool.TYPE_ELITE. Reading it unconditionally would misinterpret another enemy type’s unrelated state.
  • The elite-charger chain-dash’s “no further chaining” sentinel is grow_t == -1.0 — the grow_t >= 0.0 guard is REQUIRED (matches sim/enemy_behaviors.gd:219’s own real condition exactly) or a second-dash-already-fired enemy will be misread as still telegraphing.

Task 1: Orbit force during the telegraph/charge phase

Section titled “Task 1: Orbit force during the telegraph/charge phase”

Files:

  • Modify: ai/skilled_auto_player.gd (compute_move_for)
  • Test: tests/test_skilled_auto_player.gd

Interfaces:

  • Produces: compute_move_for’s returned vector now includes a tangential contribution when a nearby dash-type enemy is telegraphing. No signature change.

Verified constants (checked directly against source, not guessed): EnemyPool.BEHAVIOR_DASH = 1 (sim/enemy_pool.gd:56), EnemyPool.DASH_CHARGE = 0 / EnemyPool.DASH_LUNGE = 1 (sim/enemy_pool.gd:68-69), EnemyPool.TYPE_ELITE = 4 (sim/enemy_pool.gd:8), EnemyBehaviors.ELITE_CHARGER_CHAIN_CHARGE_S = 0.5 (sim/enemy_behaviors.gd:173, a const on the globally-accessible class_name EnemyBehaviors — referenced as EnemyBehaviors.ELITE_CHARGER_CHAIN_CHARGE_S, no instance needed).

  • Step 1: Write the failing tests

Read the current content of tests/test_skilled_auto_player.gd first — confirm the exact existing test helper patterns (how a Sim and enemy are constructed elsewhere in the file) before appending. Append these tests:

func test_orbit_force_contributes_during_charge_telegraph() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
# TYPE_ELITE (charger) directly east of the pilot, mid-telegraph.
var ei := sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 100.0)
sim.enemies.type_id[ei] = EnemyPool.TYPE_ELITE
sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH
sim.enemies.dash_phase[ei] = EnemyPool.DASH_CHARGE
sim.hash.rebuild(sim.enemies)
var move := SkilledAutoPlayer.new().compute_move_for(sim, sim.player)
# The enemy is due east (pilot->enemy = +X), so a pure flee/engage response has zero Y
# component; a real orbit contribution must show up as a nonzero Y component.
assert_gt(absf(move.y), 0.05, "a charging enemy directly on the X axis must produce a tangential (Y) move component")
func test_orbit_force_also_fires_during_elite_charger_chain_telegraph() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
var ei := sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 100.0)
sim.enemies.type_id[ei] = EnemyPool.TYPE_ELITE
sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH
sim.enemies.dash_phase[ei] = EnemyPool.DASH_LUNGE # first dash still "in flight"
sim.enemies.vel[ei] = Vector2(500, 0) # lunging AWAY from the Y axis test below matters less here
sim.enemies.grow_t[ei] = 0.2 # mid-way through the 0.5s chain telegraph
sim.hash.rebuild(sim.enemies)
var move := SkilledAutoPlayer.new().compute_move_for(sim, sim.player)
assert_gt(absf(move.y), 0.05, "grow_t active + under threshold on an Elite must ALSO orbit, even though dash_phase is still DASH_LUNGE from dash #1")
func test_orbit_force_does_not_fire_once_chain_dash_sentinel_is_set() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
var ei := sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 100.0)
sim.enemies.type_id[ei] = EnemyPool.TYPE_ELITE
sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH
sim.enemies.dash_phase[ei] = EnemyPool.DASH_LUNGE
sim.enemies.vel[ei] = Vector2(-500, 0) # lunging STRAIGHT AT the pilot (dodge-dash territory, not orbit)
sim.enemies.grow_t[ei] = -1.0 # "no further chaining" sentinel -- this IS dash #2
sim.hash.rebuild(sim.enemies)
var move := SkilledAutoPlayer.new().compute_move_for(sim, sim.player)
assert_almost_eq(move.y, 0.0, 0.01, "grow_t==-1.0 means no second telegraph is coming -- must NOT orbit (this is pure lunge-dodge territory, tested separately in Task 2)")
func test_orbit_force_ignores_a_dash_type_enemy_that_is_only_lunging_no_chain() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
# TYPE_SPIDER has no grow_t chain-dash mechanic at all (elite-charger-only).
var ei := sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 100.0)
sim.enemies.type_id[ei] = EnemyPool.TYPE_SPIDER
sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH
sim.enemies.dash_phase[ei] = EnemyPool.DASH_LUNGE
sim.enemies.vel[ei] = Vector2(-500, 0)
sim.hash.rebuild(sim.enemies)
var move := SkilledAutoPlayer.new().compute_move_for(sim, sim.player)
assert_almost_eq(move.y, 0.0, 0.01, "a lunging (not charging) spider with no chain-dash mechanic must not trigger orbit")
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit Expected: the 4 new tests FAIL (no orbit force exists yet — move.y is 0 in every case since the enemy is due east and today’s flee/engage/wall forces are all pure X). Every OTHER existing test in the file must still PASS unchanged (this step adds no implementation yet).

  • Step 3: Add the orbit force

Read the current ai/skilled_auto_player.gd in full first (confirm it still matches the content quoted in this plan’s “Global Constraints” section — it was last touched by an earlier plan this same session). Add these two new constants near the top, alongside the existing ENGAGE_RANGE_MULT/DASH_WINDOW_MIN_MULT block:

# Orbit force: a tangential (perpendicular-to-enemy) push added while a nearby dash-type enemy
# (charger/spider) is telegraphing, so Ace curves sideways off its future lunge line instead of
# only backing straight away from it (pure radial retreat doesn't get him off the line the lunge
# will travel once it fires). Fixed rotational handedness for determinism/simplicity -- a
# first-pass choice to validate via soak, not a claimed-optimal one.
const ORBIT_WEIGHT: float = 0.7

Add a new private helper (place it near _median_weapon_range, at the bottom of the file):

# True while enemy `i` is in a state Ace should orbit around: mid-telegraph (DASH_CHARGE), or --
# for the Elite Charger specifically -- mid-chain-telegraph for its second dash while grow_t is
# active and under threshold (grow_t is a REUSED field across enemy types for unrelated purposes,
# so it is only ever read here after confirming TYPE_ELITE; grow_t==-1.0 is the "no further
# chaining" sentinel set once dash #2 has already fired, matching
# sim/enemy_behaviors.gd:219's own real condition exactly).
func _is_dash_telegraphing(sim: Sim, i: int) -> bool:
if sim.enemies.behavior[i] != EnemyPool.BEHAVIOR_DASH:
return false
if sim.enemies.dash_phase[i] == EnemyPool.DASH_CHARGE:
return true
if sim.enemies.type_id[i] == EnemyPool.TYPE_ELITE and sim.enemies.dash_phase[i] == EnemyPool.DASH_LUNGE:
var gt: float = sim.enemies.grow_t[i]
return gt >= 0.0 and gt < EnemyBehaviors.ELITE_CHARGER_CHAIN_CHARGE_S
return false

Modify compute_move_for to add an orbit term, reusing the SAME near array the flee loop already gathers (no second spatial query). Change:

func compute_move_for(sim: Sim, pilot: PlayerState) -> Vector2:
var p := pilot.pos
var flee := Vector2.ZERO
var near := sim.hash.query_circle(p, THREAT_RADIUS, sim.enemies)
for i in near:
var d := p - sim.enemies.pos[i]
var dist := d.length()
if dist > 0.001:
var w := (THREAT_RADIUS - dist) / THREAT_RADIUS
var danger := ELITE_DANGER_WEIGHT if sim.enemies.is_elite[i] == 1 else 1.0
flee += d / dist * w * w * danger

to:

func compute_move_for(sim: Sim, pilot: PlayerState) -> Vector2:
var p := pilot.pos
var flee := Vector2.ZERO
var orbit := Vector2.ZERO
var near := sim.hash.query_circle(p, THREAT_RADIUS, sim.enemies)
for i in near:
var d := p - sim.enemies.pos[i]
var dist := d.length()
if dist > 0.001:
var w := (THREAT_RADIUS - dist) / THREAT_RADIUS
var danger := ELITE_DANGER_WEIGHT if sim.enemies.is_elite[i] == 1 else 1.0
flee += d / dist * w * w * danger
if _is_dash_telegraphing(sim, i):
var tangent := Vector2(-d.y, d.x) / dist # fixed handedness: +90 deg from pilot->enemy... using enemy->pilot (d) rotated
orbit += tangent * w * ORBIT_WEIGHT

(The d vector here is pilot.pos - enemy.pos, i.e. enemy→pilot. Rotating it 90° gives a consistent tangential direction around the enemy regardless of which side the pilot approaches from — verify this matches the test expectations above: enemy due east of pilot means d = (-150, 0) roughly normalized to (-1, 0), so Vector2(-d.y, d.x) = Vector2(0, -1) — a nonzero Y component, matching what Step 1’s tests assert.)

Then include orbit in the final sum:

var dir := flee + seek + wall * WALL_WEIGHT + engage + orbit
  • Step 4: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit Expected: PASS — all 4 new tests green, every pre-existing test in the file still green unchanged (the orbit term is additive and zero for every scenario that has no dash-telegraphing enemy nearby, so no pre-existing test’s expected vector should change).

  • Step 5: Commit
Terminal window
git add ai/skilled_auto_player.gd tests/test_skilled_auto_player.gd
git commit -m "feat(ai): SkilledAutoPlayer orbits telegraphing chargers/spiders instead of only fleeing radially"

Task 2: Dodge-dash trigger during the committed lunge

Section titled “Task 2: Dodge-dash trigger during the committed lunge”

Files:

  • Modify: ai/skilled_auto_player.gd (wants_dash)
  • Test: tests/test_skilled_auto_player.gd

Interfaces:

  • Consumes: nothing new from Task 1 (independent addition to a different function).

  • Produces: wants_dash gains a third OR-condition. No signature change.

  • Step 1: Write the failing tests

Read the current wants_dash test coverage in tests/test_skilled_auto_player.gd first (to match existing helper/setup patterns), then append:

func test_wants_dash_true_when_lunge_path_is_heading_at_the_pilot() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
sim.player.dash_cd = 0.0
var ei := sim.enemies.add(Vector2(200, 0), Vector2.ZERO, 14.0, 100.0)
sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH
sim.enemies.dash_phase[ei] = EnemyPool.DASH_LUNGE
sim.enemies.vel[ei] = Vector2(-500, 0) # straight at the pilot, closing
sim.hash.rebuild(sim.enemies)
assert_true(SkilledAutoPlayer.new().wants_dash(sim, sim.player), "a lunge whose ray passes directly through the pilot's position must trigger a dodge dash")
func test_wants_dash_false_when_lunge_path_points_away_from_the_pilot() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
sim.player.dash_cd = 0.0
var ei := sim.enemies.add(Vector2(200, 0), Vector2.ZERO, 14.0, 100.0)
sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH
sim.enemies.dash_phase[ei] = EnemyPool.DASH_LUNGE
sim.enemies.vel[ei] = Vector2(500, 0) # lunging FURTHER east, away from the pilot at origin
sim.hash.rebuild(sim.enemies)
assert_false(SkilledAutoPlayer.new().wants_dash(sim, sim.player), "a lunge heading away from the pilot must NOT trigger a dodge dash -- proves the check is direction-aware, not just proximity")
func test_wants_dash_false_when_lunge_path_passes_wide_of_the_pilot() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
sim.player.dash_cd = 0.0
var ei := sim.enemies.add(Vector2(200, 300), Vector2.ZERO, 14.0, 100.0)
sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH
sim.enemies.dash_phase[ei] = EnemyPool.DASH_LUNGE
sim.enemies.vel[ei] = Vector2(-500, 0) # lunging due west, passing well north of the pilot at y=300
sim.hash.rebuild(sim.enemies)
assert_false(SkilledAutoPlayer.new().wants_dash(sim, sim.player), "a lunge ray that passes wide of the pilot (perpendicular distance beyond the danger radius) must not trigger a dodge")
func test_wants_dash_ignores_a_dash_enemy_still_in_telegraph() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
sim.player.dash_cd = 0.0
var ei := sim.enemies.add(Vector2(200, 0), Vector2.ZERO, 14.0, 100.0)
sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH
sim.enemies.dash_phase[ei] = EnemyPool.DASH_CHARGE # still telegraphing, not lunging yet
sim.hash.rebuild(sim.enemies)
assert_false(SkilledAutoPlayer.new().wants_dash(sim, sim.player), "a still-charging enemy is Task 1's orbit territory, not a dodge-dash trigger")
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit Expected: the 4 new tests FAIL (wants_dash doesn’t check lunge rays yet — the true-case test fails because dash isn’t triggered; the two false-case tests may already incidentally pass since wants_dash currently returns false for everything in these scenarios, which is fine — a not-yet-red assertion on a false-expecting test is acceptable here since Step 3 must not break it, verify in Step 4 that all 4 are green, not just the true-case one).

  • Step 3: Add the dodge-dash trigger

Read the current wants_dash (it was rewritten by an earlier plan this session — verify against the content quoted in Task 1’s context above, or re-read the live file). Add a new constant near ORBIT_WEIGHT:

# Perpendicular distance (px) from the pilot to a lunging enemy's straight-line velocity ray,
# under which a dodge dash triggers. Comfortably larger than typical pilot+enemy contact radii
# (roughly 14-24px each) to give the dash time to actually start moving/grant i-frames before
# contact, not react exactly at the moment of impact.
const DODGE_DANGER_RADIUS: float = 70.0

Modify wants_dash. Change:

func wants_dash(sim: Sim, pilot: PlayerState) -> bool:
if super.wants_dash(sim, pilot):
return true
if pilot.dash_cd > 0.0:
return false
var top := ThreatMap.best(ThreatMap.compute(sim, pilot))
if not bool(top["has_target"]):
return false
var target_pos: Vector2 = top["target_pos"]
var dist := pilot.pos.distance_to(target_pos)
var median := _median_weapon_range(pilot)
return dist >= median * DASH_WINDOW_MIN_MULT and dist <= median * DASH_WINDOW_MAX_MULT

to:

func wants_dash(sim: Sim, pilot: PlayerState) -> bool:
if super.wants_dash(sim, pilot):
return true
if pilot.dash_cd > 0.0:
return false
if _incoming_lunge_will_hit(sim, pilot):
return true
var top := ThreatMap.best(ThreatMap.compute(sim, pilot))
if not bool(top["has_target"]):
return false
var target_pos: Vector2 = top["target_pos"]
var dist := pilot.pos.distance_to(target_pos)
var median := _median_weapon_range(pilot)
return dist >= median * DASH_WINDOW_MIN_MULT and dist <= median * DASH_WINDOW_MAX_MULT

Add the new helper (near _is_dash_telegraphing from Task 1):

# True if any nearby lunging (DASH_LUNGE) enemy's straight-line velocity ray passes within
# DODGE_DANGER_RADIUS of the pilot's CURRENT position and is still closing (not already past).
# The lunge velocity is locked and constant for its whole duration (sim/enemy_behaviors.gd:201),
# so this is a precise "is this actually going to hit me" check, not a proximity guess.
func _incoming_lunge_will_hit(sim: Sim, pilot: PlayerState) -> bool:
var near := sim.hash.query_circle(pilot.pos, THREAT_RADIUS, sim.enemies)
for i in near:
if sim.enemies.behavior[i] != EnemyPool.BEHAVIOR_DASH:
continue
if sim.enemies.dash_phase[i] != EnemyPool.DASH_LUNGE:
continue
var enemy_pos: Vector2 = sim.enemies.pos[i]
var enemy_vel: Vector2 = sim.enemies.vel[i]
var vel_len := enemy_vel.length()
if vel_len < 0.001:
continue
var vel_dir := enemy_vel / vel_len
var to_pilot: Vector2 = pilot.pos - enemy_pos
var closing := to_pilot.dot(vel_dir)
if closing <= 0.0:
continue # already past the pilot along this ray, or heading away
var perp: Vector2 = to_pilot - vel_dir * closing
if perp.length() < DODGE_DANGER_RADIUS:
return true
return false
  • Step 4: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit Expected: PASS — all 4 new tests green, every pre-existing test (including Task 1’s 4 new ones) still green.

  • Step 5: Commit
Terminal window
git add ai/skilled_auto_player.gd tests/test_skilled_auto_player.gd
git commit -m "feat(ai): SkilledAutoPlayer dodges an incoming charger/spider lunge that's actually on a collision course"

Files: none (verification only).

  • Step 1: Full suite

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: 0 unexpected failures, same 4 pre-existing risky/pending.

  • Step 2: Count guard

Run: bash scripts/check-test-count.sh Expected: test-count guard OK.

  • Step 3: Boot smoke

Run: godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR" Expected: no output.

  • Step 4: Determinism baseline

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit Expected: PASS unchanged (this plan is entirely ai/, never /sim).

  • Step 5: Soak A/B — the real acceptance test

Run: bash scripts/soak.sh --runs=30 --ticks=36000 --ships=cobalt,obsidian --coop=solo --player=skilled --seed0=13000

Compare the printed fun_signals.death_cause_breakdown and fun_signals.mean_level_at_death_by_cause against this session’s most recent post-damage-boost diagnostic sweep (seed0=12000: {"boss":1,"charger":36,"ranged":23}, 60% charger share). A DIFFERENT seed range here (13000 vs 12000) means this isn’t a strict paired A/B — report the charger share and overall death_tick_distribution/level_distribution means honestly as a fresh data point, not a rigorously matched comparison, and let Chris judge whether the charger share meaningfully dropped. Also confirm: 0 invariant failures, 0 anomalies.

  • Step 6: Push
Terminal window
git push origin main