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.
Global Constraints
Section titled “Global Constraints”- 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_tis a REUSED field across enemy types for unrelated purposes (persim/enemy_pool.gd:193’s own comment) — it must NEVER be read for the elite-charger chain-dash meaning without first confirmingsim.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— thegrow_t >= 0.0guard is REQUIRED (matchessim/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.7Add 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 falseModify 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 * dangerto:
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
git add ai/skilled_auto_player.gd tests/test_skilled_auto_player.gdgit 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_dashgains 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.0Modify 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_MULTto:
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_MULTAdd 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
git add ai/skilled_auto_player.gd tests/test_skilled_auto_player.gdgit commit -m "feat(ai): SkilledAutoPlayer dodges an incoming charger/spider lunge that's actually on a collision course"Task 3: Verification, soak A/B, and push
Section titled “Task 3: Verification, soak A/B, and push”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
git push origin main