Skip to content

Threat Radar + Aggressive AI Pilot Implementation Plan

Threat Radar + Aggressive AI Pilot Implementation Plan

Section titled “Threat Radar + Aggressive AI Pilot 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: Make the AI pilot (SkilledAutoPlayer, informally “Ace” in chat/docs only) actively hunt the swarm, dash offensively through clusters, deploy drones the instant they’re charged, and aim the manual weapon at the worst threat — driven by a new shared ThreatMap utility that also feeds a permanent, always-on red-glow screen-edge radar showing where the danger is.

Architecture: One new pure utility (ai/threat_map.gd, ThreatMap) buckets every enemy into 16 angular sectors around a pilot, scoring each by count + elite/boss + remaining-HP-fraction. Two consumers read it: SkilledAutoPlayer (movement/dash/decoy/aim decisions) and a new ui/threat_radar.gd (ThreatRadar, modeled directly on the existing ui/boss_edge_indicator.gd pattern) that draws glow blobs at the screen edge. A small refactor pulls the aim-targeting logic that was previously duplicated in tools/soak/soak_run.gd and main.gd into AutoPlayer itself.

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

  • No changes to /sim — this is entirely ai/ (controller) and ui/ (render) code. No determinism re-pin expected; verify the pinned assertion in tests/test_determinism_survival.gd is unchanged at the end.
  • HUD label stays exactly "● AUTOPILOT ACTIVE" — do not rename it or any class. “Ace” is a conversational nickname only (Chris is considering a real player-facing autopilot feature later and wants the name free).
  • ai/auto_player.gd (AutoPlayer) behavior must stay identical for every method NOT explicitly listed as changing in this plan — it’s load-bearing for coverage soaks and must not regress.
  • Full bh-dev-chunk-style gate at the end: full GUT suite, scripts/check-test-count.sh, a headless boot smoke (godot --headless --path . --quit-after 120, grep for SCRIPT ERROR).
  • Every new file/method needs GUT tests before being considered done — this project is TDD’d throughout.

Task 1: ThreatMap — shared threat-scoring utility

Section titled “Task 1: ThreatMap — shared threat-scoring utility”

Files:

  • Create: ai/threat_map.gd
  • Test: tests/test_threat_map.gd

Interfaces:

  • Produces: ThreatMap.SECTORS: int (const, 16). ThreatMap.compute(sim: Sim, pilot: PlayerState, sectors: int = SECTORS) -> Array[Dictionary] — one dict per sector: {"score": float, "angle": float, "target_pos": Vector2, "has_target": bool} (plus an internal "_best_weight" key callers should ignore). ThreatMap.best(slices: Array[Dictionary]) -> Dictionary — the single highest-scoring slice, or {"score": 0.0, "has_target": false} if every slice is empty.

  • Step 1: Write the failing tests

Create tests/test_threat_map.gd:

extends GutTest
# ThreatMap buckets every alive enemy into an angular sector around a pilot and scores each
# sector by count + elite/boss + remaining-HP-fraction. Shared by SkilledAutoPlayer (movement/
# dash/decoy/aim decisions) and ThreatRadar (what to draw) so both agree on "where's the danger."
func _content() -> ContentDB:
return SimContentFixture.db()
func _only_nonzero_slice(slices: Array) -> Dictionary:
for slice in slices:
if float(slice["score"]) > 0.0:
return slice
return {}
func test_sectors_count_matches_constant() -> void:
var sim := Sim.new(1, _content())
assert_eq(ThreatMap.compute(sim, sim.player).size(), ThreatMap.SECTORS)
func test_empty_arena_all_slices_zero() -> void:
var sim := Sim.new(1, _content())
var slices := ThreatMap.compute(sim, sim.player)
for slice in slices:
assert_eq(float(slice["score"]), 0.0)
assert_false(bool(slice["has_target"]))
var top := ThreatMap.best(slices)
assert_eq(float(top["score"]), 0.0)
assert_false(bool(top["has_target"]))
func test_single_normal_enemy_scores_base_plus_full_hp() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 50), Vector2.ZERO, 14.0, 20.0) # full HP at spawn
var hit := _only_nonzero_slice(ThreatMap.compute(sim, sim.player))
assert_true(bool(hit["has_target"]), "exactly one slice should carry the enemy")
assert_almost_eq(float(hit["score"]), 2.0, 0.001, "1.0 base + 1.0 full-HP fraction, no elite/boss bonus")
assert_eq(hit["target_pos"], Vector2(100, 50))
func test_elite_enemy_gets_elite_bonus() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 50), Vector2.ZERO, 14.0, 20.0)
sim.enemies.is_elite[0] = 1
var hit := _only_nonzero_slice(ThreatMap.compute(sim, sim.player))
assert_almost_eq(float(hit["score"]), 4.0, 0.001, "1.0 base + 2.0 elite bonus + 1.0 full-HP fraction")
func test_boss_type_enemy_gets_boss_bonus_not_elite_bonus() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 50), Vector2.ZERO, 40.0, 500.0,
0.0, 70.0, 12.0, 1.0, EnemyPool.TYPE_BOSS)
var hit := _only_nonzero_slice(ThreatMap.compute(sim, sim.player))
assert_almost_eq(float(hit["score"]), 8.0, 0.001, "1.0 base + 6.0 boss bonus + 1.0 full-HP fraction, no elite bonus")
func test_partial_hp_contributes_less_than_full_hp() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 50), Vector2.ZERO, 14.0, 20.0)
sim.enemies.data[0] = 10.0 # half of max_hp (20.0)
var hit := _only_nonzero_slice(ThreatMap.compute(sim, sim.player))
assert_almost_eq(float(hit["score"]), 1.5, 0.001, "1.0 base + 0.5 half-HP fraction")
func test_two_well_separated_enemies_land_in_different_slices() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0)
sim.enemies.add(Vector2(-100, 0), Vector2.ZERO, 14.0, 20.0)
var slices := ThreatMap.compute(sim, sim.player)
var nonzero := 0
for slice in slices:
if float(slice["score"]) > 0.0:
nonzero += 1
assert_eq(nonzero, 2, "two enemies on opposite sides of the player must land in two different sectors")
func test_best_picks_the_higher_scoring_cluster() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0) # weak, east
sim.enemies.add(Vector2(-100, 0), Vector2.ZERO, 14.0, 20.0) # west
sim.enemies.is_elite[1] = 1 # west enemy is elite -> higher score
var top := ThreatMap.best(ThreatMap.compute(sim, sim.player))
assert_true(bool(top["has_target"]))
assert_eq(top["target_pos"], Vector2(-100, 0), "the elite-weighted west cluster outranks the plain east one")
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_map -gexit Expected: FAIL — ThreatMap class does not exist yet.

  • Step 3: Write the implementation

Create ai/threat_map.gd:

class_name ThreatMap
extends RefCounted
# Buckets every enemy into an angular sector around a pilot and scores danger per sector
# (count + elite + boss + remaining-HP-fraction). A pure, stateless utility -- every call
# recomputes from current Sim state. Shared by SkilledAutoPlayer (movement/dash/decoy/aim
# decisions) and ThreatRadar (what to draw), so both are provably looking at the same picture.
const SECTORS: int = 16
const ELITE_BONUS: float = 2.0
const BOSS_BONUS: float = 6.0
const HP_LIVE_WEIGHT: float = 1.0 # scales the 0..1 remaining-HP-fraction term
# Slice index increases with Vector2.angle() (atan2(y,x)), starting at due east; since screen Y
# points down this sweeps clockwise on screen. Callers never need to know this -- they only ever
# read a slice's own "angle" field back, never assume meaning from its array index.
static func compute(sim: Sim, pilot: PlayerState, sectors: int = SECTORS) -> Array[Dictionary]:
var slices: Array[Dictionary] = []
var slice_width := TAU / float(sectors)
for s in range(sectors):
slices.append({
"score": 0.0,
"angle": (float(s) + 0.5) * slice_width, # slice CENTER angle, for edge projection
"target_pos": Vector2.ZERO,
"has_target": false,
"_best_weight": 0.0,
})
for i in range(sim.enemies.count):
var d: Vector2 = sim.enemies.pos[i] - pilot.pos
var ang: float = fposmod(d.angle(), TAU)
var idx: int = clampi(int(ang / slice_width), 0, sectors - 1)
var weight := 1.0
if sim.enemies.is_elite[i] == 1:
weight += ELITE_BONUS
if sim.boss_rotation.is_boss_type(sim.enemies.type_id[i]):
weight += BOSS_BONUS
var max_hp: float = maxf(sim.enemies.max_hp[i], 1.0)
weight += (sim.enemies.data[i] / max_hp) * HP_LIVE_WEIGHT
var slice: Dictionary = slices[idx]
slice["score"] = float(slice["score"]) + weight
if weight > float(slice["_best_weight"]):
slice["_best_weight"] = weight
slice["target_pos"] = sim.enemies.pos[i]
slice["has_target"] = true
return slices
# The single highest-scoring slice, or {"score": 0.0, "has_target": false} if the arena is empty
# (every slice score is 0). Convenience for callers that only care about the ONE worst direction.
static func best(slices: Array[Dictionary]) -> Dictionary:
var top: Dictionary = {"score": 0.0, "has_target": false}
for slice in slices:
if float(slice["score"]) > float(top["score"]):
top = slice
return top
  • Step 4: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_map -gexit Expected: PASS — all 7 tests green.

  • Step 5: Commit
Terminal window
git add ai/threat_map.gd tests/test_threat_map.gd
git commit -m "feat(ai): add ThreatMap, shared enemy-cluster danger scoring"

Task 2: AutoPlayer.compute_aim_for() — de-duplicate aim targeting

Section titled “Task 2: AutoPlayer.compute_aim_for() — de-duplicate aim targeting”

Files:

  • Modify: ai/auto_player.gd
  • Modify: tools/soak/soak_run.gd:87-93 (the _input_for function)
  • Modify: main.gd (attract-mode input block added in the previous commit, c5e8167)
  • Test: tests/test_auto_player.gd

Interfaces:

  • Consumes: none new.

  • Produces: AutoPlayer.AIM_RADIUS: float (const, 400.0). AutoPlayer.compute_aim_for(sim: Sim, pilot: PlayerState) -> Vector2 — nearest enemy within AIM_RADIUS, or Vector2.ZERO. SkilledAutoPlayer overrides this in Task 4; this task only touches the base class and its two call sites.

  • Step 1: Write the failing tests

Add to tests/test_auto_player.gd (append at the end of the file):

func test_compute_aim_for_nearest_enemy_in_range() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 5.0)
sim.hash.rebuild(sim.enemies)
var aim := AutoPlayer.new().compute_aim_for(sim, sim.player)
assert_almost_eq(aim.x, 1.0, 0.001, "aims directly at the only enemy in range")
assert_almost_eq(aim.y, 0.0, 0.001)
func test_compute_aim_for_returns_zero_when_nothing_in_range() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(1000, 0), Vector2.ZERO, 14.0, 5.0) # outside AIM_RADIUS (400)
sim.hash.rebuild(sim.enemies)
var aim := AutoPlayer.new().compute_aim_for(sim, sim.player)
assert_eq(aim, Vector2.ZERO)
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_auto_player -gexit Expected: FAIL — compute_aim_for does not exist on AutoPlayer yet.

  • Step 3: Add compute_aim_for to ai/auto_player.gd

Add this constant near the top of the file, alongside the existing DASH_PANIC_RADIUS/DECOY_THREAT_RADIUS consts:

const AIM_RADIUS: float = 400.0 # how far the manual/aim weapon will track an enemy

Add this method, placed after wants_decoy (end of the file):

# Aim direction for the manual/aim weapon: the nearest enemy within AIM_RADIUS, or ZERO if
# nothing's in range. Pulled out of tools/soak/soak_run.gd and main.gd's attract-mode input
# block, which both inlined this identically. SkilledAutoPlayer overrides it with a threat-aware
# version and falls back to this when nothing dangerous is in range.
func compute_aim_for(sim: Sim, pilot: PlayerState) -> Vector2:
var near := sim.hash.query_circle(pilot.pos, AIM_RADIUS, sim.enemies)
if near.is_empty():
return Vector2.ZERO
return (sim.enemies.pos[near[0]] - pilot.pos).normalized()
  • Step 4: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_auto_player -gexit Expected: PASS.

  • Step 5: Update tools/soak/soak_run.gd’s call site

Find this exact block (currently lines 87-93):

func _input_for(pilot: PlayerState) -> InputState:
if pilot == null or pilot.hp <= 0.0:
return InputState.new()
var move := auto.compute_move_for(sim, pilot)
# Aim at the nearest enemy so the manual right-stick weapon is exercised.
var aim := Vector2.ZERO
var near := sim.hash.query_circle(pilot.pos, 400.0, sim.enemies)
if not near.is_empty():
aim = (sim.enemies.pos[near[0]] - pilot.pos).normalized()
return InputState.new(move, aim, auto.wants_decoy(sim, pilot), auto.wants_dash(sim, pilot))

Replace with:

func _input_for(pilot: PlayerState) -> InputState:
if pilot == null or pilot.hp <= 0.0:
return InputState.new()
var move := auto.compute_move_for(sim, pilot)
var aim := auto.compute_aim_for(sim, pilot)
return InputState.new(move, aim, auto.wants_decoy(sim, pilot), auto.wants_dash(sim, pilot))
  • Step 6: Update main.gd’s attract-mode input block

Find this exact block (added in commit c5e8167, inside _physics_process):

var input := human
if _auto:
# Mirror tools/soak/soak_run.gd's _input_for: aim + dash + decoy, not just movement --
# a move-only InputState meant the attract-mode demo could never dash or deploy drones,
# no matter how capable the underlying AutoPlayer was.
var auto_move := auto_player.compute_move(sim)
var auto_aim := Vector2.ZERO
var auto_near := sim.hash.query_circle(sim.player.pos, 400.0, sim.enemies)
if not auto_near.is_empty():
auto_aim = (sim.enemies.pos[auto_near[0]] - sim.player.pos).normalized()
input = InputState.new(auto_move, auto_aim,
auto_player.wants_decoy(sim, sim.player), auto_player.wants_dash(sim, sim.player))

Replace with:

var input := human
if _auto:
# Mirror tools/soak/soak_run.gd's _input_for: aim + dash + decoy, not just movement --
# a move-only InputState meant the attract-mode demo could never dash or deploy drones,
# no matter how capable the underlying AutoPlayer was.
var auto_move := auto_player.compute_move(sim)
var auto_aim := auto_player.compute_aim_for(sim, sim.player)
input = InputState.new(auto_move, auto_aim,
auto_player.wants_decoy(sim, sim.player), auto_player.wants_dash(sim, sim.player))
  • Step 7: Run the full suite to check for regressions

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: same pass count as before this task (2140 passing, 4 pre-existing risky/pending) plus the 2 new tests from Step 1 (2142 passing).

  • Step 8: Commit
Terminal window
git add ai/auto_player.gd tools/soak/soak_run.gd main.gd tests/test_auto_player.gd
git commit -m "refactor(ai): pull duplicated aim-targeting into AutoPlayer.compute_aim_for"

Task 3: SkilledAutoPlayer — engage movement, proactive dash, always-deploy drones

Section titled “Task 3: SkilledAutoPlayer — engage movement, proactive dash, always-deploy drones”

Files:

  • Modify: ai/skilled_auto_player.gd
  • Modify: tests/test_skilled_auto_player.gd

Interfaces:

  • Consumes: ThreatMap.compute/ThreatMap.best (Task 1). AutoPlayer.wants_dash/AutoPlayer.DASH_PANIC_RADIUS (existing, via super).

  • Produces: SkilledAutoPlayer.ENGAGE_SEEK_WEIGHT, ENGAGE_DASH_MIN_RANGE, ENGAGE_DASH_MAX_RANGE (new consts). Overridden compute_move_for, wants_dash, wants_decoy (Task 4 will add a compute_aim_for override in this same class).

  • Step 1: Update the existing dash test’s stale docstring

tests/test_skilled_auto_player.gd currently has:

func test_inherits_dash_behavior_from_auto_player() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 5.0) # inside DASH_PANIC_RADIUS
sim.hash.rebuild(sim.enemies)
assert_true(SkilledAutoPlayer.new().wants_dash(sim, sim.player),
"dash/decoy timing is unchanged from AutoPlayer for v1 (no evidence yet it's a bottleneck)")

Its docstring is about to become false (this task adds a real proactive dash layer and a changed decoy policy). Replace the whole function with:

func test_panic_dash_still_wins_with_proactive_layer() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 5.0) # inside DASH_PANIC_RADIUS
sim.hash.rebuild(sim.enemies)
assert_true(SkilledAutoPlayer.new().wants_dash(sim, sim.player),
"point-blank panic dash still fires even though a new proactive-dash layer exists")
  • Step 2: Write the new failing tests

Append to tests/test_skilled_auto_player.gd:

func test_proactive_dash_toward_midrange_cluster() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(250, 0), Vector2.ZERO, 14.0, 5.0) # inside the engage-dash window
sim.hash.rebuild(sim.enemies)
assert_true(SkilledAutoPlayer.new().wants_dash(sim, sim.player),
"dashes toward a mid-range cluster to close distance, not just away from a point-blank one")
func test_no_proactive_dash_when_cooldown_active() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.player.dash_cd = 1.0
sim.enemies.add(Vector2(250, 0), Vector2.ZERO, 14.0, 5.0)
sim.hash.rebuild(sim.enemies)
assert_false(SkilledAutoPlayer.new().wants_dash(sim, sim.player),
"still on cooldown -> no proactive dash request")
func test_no_proactive_dash_when_target_too_far() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(800, 0), Vector2.ZERO, 14.0, 5.0) # beyond ENGAGE_DASH_MAX_RANGE
sim.hash.rebuild(sim.enemies)
assert_false(SkilledAutoPlayer.new().wants_dash(sim, sim.player))
func test_no_dash_when_arena_empty() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
assert_false(SkilledAutoPlayer.new().wants_dash(sim, sim.player))
func test_wants_decoy_ignores_proximity_when_charged() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.player.drone_charge = 1.0
# no enemies anywhere -- base AutoPlayer would withhold (needs a nearby threat too), Ace deploys anyway
assert_true(SkilledAutoPlayer.new().wants_decoy(sim, sim.player),
"deploys the instant it's charged, regardless of nearby danger")
func test_wants_decoy_withheld_until_fully_charged() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.player.drone_charge = 0.99
assert_false(SkilledAutoPlayer.new().wants_decoy(sim, sim.player))
func test_engage_force_pulls_toward_distant_cluster() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(500, 0), Vector2.ZERO, 14.0, 5.0) # far east, outside THREAT_RADIUS (300)
sim.hash.rebuild(sim.enemies)
var move := SkilledAutoPlayer.new().compute_move_for(sim, sim.player)
assert_gt(move.x, 0.0, "moves toward the distant cluster instead of standing still")
func test_flee_still_dominates_at_close_range() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(40, 0), Vector2.ZERO, 14.0, 5.0) # deep inside THREAT_RADIUS
sim.hash.rebuild(sim.enemies)
var move := SkilledAutoPlayer.new().compute_move_for(sim, sim.player)
assert_lt(move.x, 0.0, "close range: still flees away, the engage force doesn't override survival")
  • Step 3: Run tests to verify the new ones fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit Expected: the renamed panic test still passes (behavior unchanged so far); the 7 new tests FAIL (no engage force, no proactive dash, decoy still proximity-gated).

  • Step 4: Rewrite ai/skilled_auto_player.gd

Replace the whole file with:

class_name SkilledAutoPlayer
extends AutoPlayer
# A more capable soak-testing AI than the base AutoPlayer, used ONLY for balance-signal runs and
# the attract-mode demo (never coverage soaks -- AutoPlayer's deliberate simplicity there is
# untouched by this file). 2026-07-17: Chris watched the demo play too passively (only reactive
# flee, dash/drones barely used) and asked for active hunting -- fly toward the swarm as soon as
# it arrives, dash offensively through clusters (using the existing i-frame window to avoid
# damage while weapons do the killing), and deploy drones the instant they're charged.
const ELITE_DANGER_WEIGHT: float = 1.6 # elites are steered away from harder than trash
# How strongly the "engage the worst threat cluster" force pulls, relative to the existing flee
# force's peak strength of 1.0 (at point-blank range, decaying to 0 at THREAT_RADIUS=300 away).
# 0.5 means flee still wins inside ~90px of an enemy (near DASH_PANIC_RADIUS=70, so panic-dash and
# "still fleeing" agree at true close range) while engage wins at any longer range -- including
# far outside THREAT_RADIUS entirely, where flee contributes nothing at all. Only the ANGLE of the
# final normalized move direction is affected by this weight, not overall movement speed.
const ENGAGE_SEEK_WEIGHT: float = 0.5
const ENGAGE_DASH_MIN_RANGE: float = 150.0 # closer than this, panic-dash (if triggered) already covers it
const ENGAGE_DASH_MAX_RANGE: float = 400.0 # farther than this, dashing wouldn't meaningfully close distance
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
var seek := Vector2.ZERO
var best := -1
var best_d2 := INF
for gi in range(sim.gems.count):
var gd2 := p.distance_squared_to(sim.gems.pos[gi])
if gd2 < best_d2:
best_d2 = gd2
best = gi
if best != -1:
seek = (sim.gems.pos[best] - p).normalized() * GEM_SEEK_WEIGHT
var wall := Vector2.ZERO
var lim := Sim_Const.ARENA_HALF - WALL_MARGIN
if p.x > lim: wall.x -= (p.x - lim) / WALL_MARGIN
if p.x < -lim: wall.x += (-lim - p.x) / WALL_MARGIN
if p.y > lim: wall.y -= (p.y - lim) / WALL_MARGIN
if p.y < -lim: wall.y += (-lim - p.y) / WALL_MARGIN
var engage := Vector2.ZERO
var top := ThreatMap.best(ThreatMap.compute(sim, pilot))
if bool(top["has_target"]):
var target_pos: Vector2 = top["target_pos"]
var to_target: Vector2 = target_pos - p
if to_target.length() > 0.001:
engage = to_target.normalized() * ENGAGE_SEEK_WEIGHT
var dir := flee + seek + wall * WALL_WEIGHT + engage
if dir.length() < 0.01:
return Vector2.ZERO
return dir.normalized()
# Score offered choices instead of AutoPlayer's "first weapon, else first" — id
# prefixes are a stable contract (sim/upgrade_system.gd apply_upgrade): "weapon:"
# grants a new slot, "evolve:" evolves an owned weapon, "wm:"/"aim:" are
# per-weapon mods, anything else is a generic content-db stat/transformative pick.
func pick_upgrade(choice_ids: Array) -> String:
if choice_ids.is_empty():
return ""
var best_id := String(choice_ids[0])
var best_score := -1
for raw in choice_ids:
var id := String(raw)
var score := 0
if id.begins_with("weapon:"):
score = 3
elif id.begins_with("evolve:"):
score = 2
elif id.begins_with("wm:") or id.begins_with("aim:"):
score = 1
if score > best_score:
best_score = score
best_id = id
return best_id
# Panic (point-blank, inherited from AutoPlayer) always wins. Otherwise, dash TOWARD the worst
# threat cluster when it's at a range dashing would meaningfully close (compute_move_for's engage
# force already points move_dir at this same target, so the dash carries the pilot toward it with
# no separate direction calculation needed) -- using the existing i-frame window to punch through
# the leading edge of a cluster instead of only ever dashing away from one.
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)
return dist >= ENGAGE_DASH_MIN_RANGE and dist <= ENGAGE_DASH_MAX_RANGE
# Deploy the instant it's charged, regardless of nearby danger -- drones deal real AoE damage (and
# some classes heal), so there's no reason to hold a full charge waiting for a threat to appear.
func wants_decoy(sim: Sim, pilot: PlayerState) -> bool:
return pilot.drone_charge >= 1.0
  • Step 5: 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 tests (the renamed panic test + 7 new ones + the pre-existing pick_upgrade/elite-weighting tests) green.

  • Step 6: Run the full suite to check for regressions

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: same pass count as after Task 2, plus these new tests.

  • Step 7: Commit
Terminal window
git add ai/skilled_auto_player.gd tests/test_skilled_auto_player.gd
git commit -m "feat(ai): SkilledAutoPlayer actively engages threats, dashes offensively, spams drones"

Task 4: SkilledAutoPlayer.compute_aim_for() — threat-aware manual weapon targeting

Section titled “Task 4: SkilledAutoPlayer.compute_aim_for() — threat-aware manual weapon targeting”

Files:

  • Modify: ai/skilled_auto_player.gd
  • Modify: tests/test_skilled_auto_player.gd

Interfaces:

  • Consumes: ThreatMap.compute/ThreatMap.best (Task 1), AutoPlayer.compute_aim_for/AutoPlayer.AIM_RADIUS (Task 2, via super).

  • Produces: overridden SkilledAutoPlayer.compute_aim_for(sim, pilot) -> Vector2.

  • Step 1: Write the failing tests

Append to tests/test_skilled_auto_player.gd:

func test_aim_targets_the_worst_threat_in_range() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 5.0) # plain, east, closer
sim.enemies.add(Vector2(0, 200), Vector2.ZERO, 14.0, 5.0) # elite, south, farther but worse
sim.enemies.is_elite[1] = 1
sim.hash.rebuild(sim.enemies)
var aim := SkilledAutoPlayer.new().compute_aim_for(sim, sim.player)
assert_almost_eq(aim.y, 1.0, 0.001, "aims south at the elite even though the plain east enemy is closer")
assert_almost_eq(aim.x, 0.0, 0.001)
func test_aim_falls_back_to_nearest_when_worst_threat_out_of_range() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 5.0) # plain, in aim range
sim.enemies.add(Vector2(0, 900), Vector2.ZERO, 14.0, 5.0) # elite, worst overall but far out of aim range
sim.enemies.is_elite[1] = 1
sim.hash.rebuild(sim.enemies)
var aim := SkilledAutoPlayer.new().compute_aim_for(sim, sim.player)
assert_almost_eq(aim.x, 1.0, 0.001, "falls back to the in-range plain enemy, not the far-off elite")
assert_almost_eq(aim.y, 0.0, 0.001)
func test_aim_returns_zero_when_nothing_anywhere() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
assert_eq(SkilledAutoPlayer.new().compute_aim_for(sim, sim.player), Vector2.ZERO)
  • 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 3 new tests FAIL — compute_aim_for still resolves to the inherited nearest-only version.

  • Step 3: Add the override to ai/skilled_auto_player.gd

Append this method at the end of the file (after wants_decoy):

# Aim at the worst threat cluster if it's within aim range; otherwise fall back to the base
# class's nearest-in-range behavior so the manual weapon still tracks SOMETHING when the worst
# cluster is too far away to hit.
func compute_aim_for(sim: Sim, pilot: PlayerState) -> Vector2:
var top := ThreatMap.best(ThreatMap.compute(sim, pilot))
if bool(top["has_target"]):
var target_pos: Vector2 = top["target_pos"]
var to_target: Vector2 = target_pos - pilot.pos
if to_target.length() > 0.001 and to_target.length() <= AIM_RADIUS:
return to_target.normalized()
return super.compute_aim_for(sim, pilot)
  • 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 tests green.

  • Step 5: Run the full suite to check for regressions

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: same pass count as after Task 3, plus 3 new tests.

  • Step 6: Commit
Terminal window
git add ai/skilled_auto_player.gd tests/test_skilled_auto_player.gd
git commit -m "feat(ai): SkilledAutoPlayer aims the manual weapon at the worst threat"

Task 5: ThreatRadar — always-on screen-edge danger glow

Section titled “Task 5: ThreatRadar — always-on screen-edge danger glow”

Files:

  • Create: ui/threat_radar.gd
  • Modify: main.gd
  • Test: tests/test_threat_radar.gd

Interfaces:

  • Consumes: ThreatMap.compute (Task 1).

  • Produces: ThreatRadar.MARGIN/SCORE_NORMALIZE/MIN_RADIUS/MAX_RADIUS/MAX_ALPHA/DANGER_COLOR (consts). ThreatRadar.edge_point(dir: Vector2, vp: Vector2, margin: float) -> Vector2 (static, pure). ThreatRadar.update_radar(sim: Sim, pilot: PlayerState) -> void (instance method, called once per frame). ThreatRadar.blob_count() -> int (test seam).

  • Step 1: Write the failing tests

Create tests/test_threat_radar.gd:

extends GutTest
# ThreatRadar: an always-on screen-edge glow showing where the worst enemy threat is, in every
# direction at once (unlike BossEdgeIndicator, which points at one specific off-camera boss and
# hides once it's visible). 2026-07-17, Chris: wants a permanent player feature, not a debug
# overlay -- "I was thinking of doing something like this anyway."
#
# Same split as tests/test_boss_edge_indicator.gd: the pure edge-projection math is tested
# directly against ThreatRadar.edge_point() (no camera/viewport dependency); update_radar() is
# only checked for transform-independent behaviour (how many blobs get staged).
const VP := Vector2(1280, 720) # project.godot window/size/viewport_width|height
const MARGIN := ThreatRadar.MARGIN
func _content() -> ContentDB:
return SimContentFixture.db()
func _radar() -> ThreatRadar:
var r := ThreatRadar.new()
add_child_autofree(r)
return r
func _ready_radar() -> ThreatRadar:
var r := _radar()
await get_tree().process_frame
return r
# ── Pure edge geometry ──────────────────────────────────────────────────────
func test_edge_point_due_east() -> void:
var p := ThreatRadar.edge_point(Vector2.RIGHT, VP, MARGIN)
assert_almost_eq(p.x, VP.x - MARGIN, 0.01)
assert_almost_eq(p.y, VP.y * 0.5, 0.01)
func test_edge_point_due_south() -> void:
var p := ThreatRadar.edge_point(Vector2.DOWN, VP, MARGIN)
assert_almost_eq(p.y, VP.y - MARGIN, 0.01)
assert_almost_eq(p.x, VP.x * 0.5, 0.01)
func test_edge_point_due_west() -> void:
var p := ThreatRadar.edge_point(Vector2.LEFT, VP, MARGIN)
assert_almost_eq(p.x, MARGIN, 0.01)
func test_edge_point_due_north() -> void:
var p := ThreatRadar.edge_point(Vector2.UP, VP, MARGIN)
assert_almost_eq(p.y, MARGIN, 0.01)
func test_edge_point_diagonal_stays_within_box() -> void:
var p := ThreatRadar.edge_point(Vector2(1, 1).normalized(), VP, MARGIN)
assert_true(p.x <= VP.x - MARGIN + 0.5 and p.x >= MARGIN - 0.5)
assert_true(p.y <= VP.y - MARGIN + 0.5 and p.y >= MARGIN - 0.5)
# ── update_radar() — transform-independent behaviours only ────────────────
func test_no_blobs_when_arena_empty() -> void:
var sim := Sim.new(1, _content())
var r := await _ready_radar()
r.update_radar(sim, sim.player)
assert_eq(r.blob_count(), 0, "no enemies -> nothing to glow")
func test_one_blob_when_one_enemy_present() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 5.0)
var r := await _ready_radar()
r.update_radar(sim, sim.player)
assert_eq(r.blob_count(), 1, "one enemy -> exactly one glowing sector")
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit Expected: FAIL — ThreatRadar class does not exist yet.

  • Step 3: Write ui/threat_radar.gd
class_name ThreatRadar
extends CanvasLayer
# An always-on screen-edge glow toward the worst enemy threat, in every direction the swarm is
# in. Same render pattern as ui/boss_edge_indicator.gd (a CanvasLayer + inner Control doing
# custom _draw(), pure static geometry kept separate from any viewport dependency) but always
# visible everywhere at once, rather than a single pop-in arrow toward one specific target.
# 2026-07-17, Chris: a real permanent player feature, not a debug overlay.
const MARGIN: float = 24.0
const SCORE_NORMALIZE: float = 8.0 # a sector's score at/above this renders at full intensity
const MIN_RADIUS: float = 10.0
const MAX_RADIUS: float = 46.0
const MAX_ALPHA: float = 0.5
const DANGER_COLOR := Color(1.0, 0.35, 0.1) # distinct from the low-HP vignette's Color(1.0, 0.1, 0.15)
class _Radar extends Control:
var blobs: Array[Dictionary] = [] # {"pos": Vector2, "radius": float, "alpha": float}
func _draw() -> void:
for b in blobs:
var pos: Vector2 = b["pos"]
var r: float = b["radius"]
var a: float = b["alpha"]
draw_circle(pos, r, Color(DANGER_COLOR.r, DANGER_COLOR.g, DANGER_COLOR.b, a * 0.35))
draw_circle(pos, r * 0.6, Color(DANGER_COLOR.r, DANGER_COLOR.g, DANGER_COLOR.b, a * 0.6))
draw_circle(pos, r * 0.3, Color(DANGER_COLOR.r, DANGER_COLOR.g, DANGER_COLOR.b, a))
var _radar: _Radar
func _ready() -> void:
layer = 5 # same tier as Hud/BossEdgeIndicator
_radar = _Radar.new()
_radar.mouse_filter = Control.MOUSE_FILTER_IGNORE
_radar.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(_radar)
# Pure: given a direction and viewport size, the point on the inset edge box along that ray from
# screen-centre. Same ray-box math as BossEdgeIndicator.clamp_to_edge, minus its on-screen check
# (the radar always renders at the edge for every sector, regardless of what's actually visible).
static func edge_point(dir: Vector2, vp: Vector2, margin: float) -> Vector2:
var half := Vector2(vp.x * 0.5 - margin, vp.y * 0.5 - margin)
var tx: float = (half.x / absf(dir.x)) if absf(dir.x) > 0.0001 else INF
var ty: float = (half.y / absf(dir.y)) if absf(dir.y) > 0.0001 else INF
return vp * 0.5 + dir * minf(tx, ty)
func update_radar(sim: Sim, pilot: PlayerState) -> void:
var slices := ThreatMap.compute(sim, pilot)
var vp := get_viewport().get_visible_rect().size
var blobs: Array[Dictionary] = []
for slice in slices:
var score: float = slice["score"]
if score <= 0.0:
continue
var intensity := clampf(score / SCORE_NORMALIZE, 0.0, 1.0)
var angle: float = slice["angle"]
var dir := Vector2(cos(angle), sin(angle))
var pos := edge_point(dir, vp, MARGIN)
blobs.append({
"pos": pos,
"radius": lerpf(MIN_RADIUS, MAX_RADIUS, intensity),
"alpha": intensity * MAX_ALPHA,
})
_radar.blobs = blobs
_radar.queue_redraw()
# Test seam.
func blob_count() -> int:
return _radar.blobs.size() if _radar != null else 0
  • Step 4: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit Expected: PASS — all 7 tests green.

  • Step 5: Wire ThreatRadar into main.gd

Add the field declaration near the existing boss_edge_indicator declaration (main.gd:85):

var boss_edge_indicator: BossEdgeIndicator # points at the active boss when it's off-camera
var threat_radar: ThreatRadar # always-on screen-edge glow toward the worst enemy threat

Add instantiation right after boss_edge_indicator’s (main.gd:323-324):

boss_edge_indicator = BossEdgeIndicator.new()
add_child(boss_edge_indicator)
threat_radar = ThreatRadar.new()
add_child(threat_radar)

Add threat_radar to BOTH persistent-node exclusion lists (_return_to_menu() around line 494, and _new_run() around line 558) — find each occurrence of this exact line:

or c == weapon_panel or c == drone_dock or c == boss_edge_indicator \

Replace EACH of the two occurrences with:

or c == weapon_panel or c == drone_dock or c == boss_edge_indicator or c == threat_radar \

Hide it alongside boss_edge_indicator in _return_to_menu() (this exact line, around 502):

boss_edge_indicator.visible = false

Replace with:

boss_edge_indicator.visible = false
threat_radar.visible = false

Update it once per frame right after the existing boss indicator update (this exact line, around 1429):

boss_edge_indicator.update_indicator(hud_boss_info)

Replace with:

boss_edge_indicator.update_indicator(hud_boss_info)
threat_radar.update_radar(sim, sim.player)

(P1-only for v1, matching the existing single-pilot-only pattern of several other HUD elements in this codebase, e.g. the drone bay — extending to co-op’s second pilot is a separate, later decision, not part of this plan.)

  • Step 6: Run the full suite to check for regressions

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: same pass count as after Task 4, plus 7 new test_threat_radar tests.

  • Step 7: Commit
Terminal window
git add ui/threat_radar.gd main.gd tests/test_threat_radar.gd
git commit -m "feat(hud): add ThreatRadar, an always-on screen-edge glow toward the worst threat"

Files: none (verification only).

Interfaces: none.

  • Step 1: Full suite

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: all scripts run, 0 failing (pre-existing 4 risky/pending unchanged; total passing count should be the Task 1 baseline + 2 (Task 2) + 10 (Task 3) + 3 (Task 4) + 7 (Task 5) + 7 (Task 1 itself) new tests).

  • Step 2: Count guard

Run: bash scripts/check-test-count.sh Expected: test-count guard OK with the new total script count (238 — 236 existing + test_threat_map.gd + test_threat_radar.gd).

  • Step 3: Boot smoke

Run: godot --headless --path . --quit-after 120 Expected: no SCRIPT ERROR lines in the output (teardown noise like leaked RIDs/ERROR: N resources still in use at exit is expected and pre-existing).

  • Step 4: Determinism baseline unaffected

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit Expected: PASS with the SAME pinned hash/checksum as before this plan (this feature never touches /sim, so no re-pin should be needed — if this fails, stop and investigate before touching the pinned values, per bh-determinism-repin).

  • Step 5: Re-run the instrumented dash/decoy measurement to confirm the fix landed

This mirrors the ad-hoc measurement done earlier in this session (which found dash wanted 246x but fired only 8x, and drone charge topping out at 46%) — confirming the new behavior actually moves those numbers, not just that the code compiles.

Terminal window
cat > /tmp/ace_check.gd << 'EOF'
extends SceneTree
func _init() -> void:
var defs: ContentDB = ContentLoader.load_from_path("res://data/bible.json")
var sim: Sim = Sim.new(2000, defs)
sim.enable_survival()
var meta := MetaState.new()
ShipBonuses.apply_base("cobalt", sim, meta)
ShipBonuses.apply_to("cobalt", sim.player, sim.mods)
var auto: SkilledAutoPlayer = SkilledAutoPlayer.new()
var dash_wanted := 0
var dash_fired := 0
var decoy_wanted := 0
var decoy_fired := 0
var t := 0
while t < 36000 and not sim.game_over:
var pilot: PlayerState = sim.pilots[0]
var move: Vector2 = auto.compute_move_for(sim, pilot)
var aim: Vector2 = auto.compute_aim_for(sim, pilot)
var wd: bool = auto.wants_dash(sim, pilot)
var wc: bool = auto.wants_decoy(sim, pilot)
if wd: dash_wanted += 1
if wc: decoy_wanted += 1
var pre_cd: float = pilot.dash_cd
var pre_charge: float = pilot.drone_charge
sim.tick_single(InputState.new(move, aim, wc, wd))
if wd and pre_cd <= 0.0: dash_fired += 1
if wc and pre_charge >= 1.0: decoy_fired += 1
t += 1
if sim.pending_levelups > 0:
var ids: Array = sim.upgrade_system.roll_upgrade_choices(sim, pilot, 3)
sim.upgrade_system.apply_upgrade(sim, pilot, auto.pick_upgrade(ids))
print("ticks=%d died=%s dash_wanted=%d dash_fired=%d decoy_wanted=%d decoy_fired=%d hp=%.1f drone_charge=%.2f" \
% [t, sim.game_over, dash_wanted, dash_fired, decoy_wanted, decoy_fired, sim.pilots[0].hp, sim.pilots[0].drone_charge])
quit()
EOF
godot --headless --path . -s /tmp/ace_check.gd 2>&1 | grep -v "^Godot Engine\|^Vulkan\|^WARNING\|^ at:"
rm -f /tmp/ace_check.gd

Expected: dash_fired and decoy_fired counts are meaningfully non-trivial across the run (not a strict pass/fail assertion — this is a sanity read, not a unit test — but if decoy_fired is still 0-1 or dash_fired is still single digits over a full 600-tick run, something in Tasks 3/4 isn’t wired the way this plan intends; investigate before moving on).

  • Step 6: Soak sanity check

Run: bash scripts/soak.sh --runs=4 --ticks=3600 --ships=cobalt --coop=solo --player=skilled --seed0=3000 Expected: clean JSON report under reports/, no invariant failures, no anomalies flagged.

  • Step 7: Export a fresh macOS build and hand off
Terminal window
godot --headless --path . --export-release "macOS" builds/macos/BulletHeaven.app
git diff --stat project.godot

Expected: export succeeds; project.godot diff is empty (headless export never touches it — confirm anyway per this project’s standing gotcha about windowed runs corrupting it). Then open builds/macos/BulletHeaven.app so Chris can watch Ace play with the new behavior and the threat radar live.

  • Step 8: Push
Terminal window
git push origin main