Skip to content

Threat Radar + Aggressive AI Pilot — Design

Threat Radar + Aggressive AI Pilot — Design

Section titled “Threat Radar + Aggressive AI Pilot — Design”

Date: 2026-07-17 Status: Approved (Chris, 2026-07-17, live in chat — informal review per this project’s established convention for AI-tooling specs, see 2026-07-16-skilled-autoplayer-balance-signal-design.md’s own “Approval note”) Origin: Chris watched the freshly-exported macOS build’s attract-mode autopilot play and found it too passive — it only reacted defensively (fled when something got close) instead of actively hunting the swarm, rarely got to use dash (despite wanting to constantly — see the instrumented measurement below) and almost never charged a drone fully before dying. He asked for three things: a much more aggressive/proactive AI, dash used offensively (“fly through them… so as not to take dmg but help it kill them”) not just defensively, and a permanent on-screen radar showing where the worst danger is — something he’d already been thinking about adding as a real player feature, independent of the AI work.

Two separate but related gaps, both surfaced by actually watching the exported build play (the “live gameplay feel” verification gap named in docs/ai-collaboration-capability-map.md):

  1. ai/skilled_auto_player.gd (SkilledAutoPlayer) is purely reactive. Its movement is flee-from-nearby + seek-nearest-gem + avoid-walls — there is no attraction toward danger at all, so it never closes distance on a spawning wave; it just drifts until something gets close enough to flee from. wants_dash()/wants_decoy() are inherited unchanged from the base AutoPlayer and are both narrowly reactive: dash only fires at 70px (panic range) and decoy only deploys if something is already within 220px of a full charge. An instrumented 90-second run (seed 2000, cobalt ship) confirmed this in practice: dash was wanted 246 times but only fired 8 (cooldown-starved because it’s only ever requested at the last possible moment), and drone charge topped out at 46% before death — it never once reached a full charge.
  2. The manual/aim weapon’s targeting logic is duplicated and dumb. Neither AutoPlayer nor SkilledAutoPlayer owns an aim decision at all — tools/soak/soak_run.gd’s _input_for() and main.gd’s attract-mode input block (added earlier tonight) each independently inline the same “aim at the nearest enemy within 400px” logic. It works, but it’s copy-pasted and not threat-aware — the manual weapon points at whatever’s closest, not whatever’s most dangerous.
  3. There is no shared idea of “where is the danger” anywhere in the codebase, for either the AI or a human player. The only existing off-screen indicator, ui/boss_edge_indicator.gd, points at exactly one specific boss instance it’s fed directly — it has no concept of the swarm at large.
  • No new sim mechanics. Dash still doesn’t deal contact damage — “fly through them” means using dash’s existing i-frame window to safely close distance so the already-firing weapons (auto weapons + the manual aim weapon) get a clean pass through the cluster. Nothing in /sim changes; this is entirely an AI-controller (ai/) and render (ui/) feature, so it’s determinism-neutral by construction — no re-pin expected.
  • No changes to human input. InputRouter / real player controls are untouched. The radar is visible to everyone (it’s a permanent feature, not a debug overlay — see below), but only the AI pilot’s behavior changes.
  • Boss threat is already covered separately. Bosses spawn into the same sim.enemies pool as regular enemies (confirmed: boss_warden.gd/eye.gd/graviton.gd/funzo.gd all call sim.enemies.add(...)), so ThreatMap naturally picks them up too (see the boss-bonus term below) — this isn’t a duplicate of BossEdgeIndicator, it’s a second, independent signal that happens to agree with it. No code coupling between the two.
  • No dedicated “hide for clean screenshots” toggle in v1. The existing Y/V hotkey only toggles the weapon/drone dock panel (_toggle_tactical_hud), not the always-on HUD — the radar follows the main HUD’s own lifetime (visible whenever a run is active), matching how the HP bar and boss indicator already behave. A capture-specific hide can be added later if the marketing pipeline needs it; not blocking here.
  • Naming: “Ace” is a conversational nickname only, not a code or UI change. Chris is considering a real player-facing “autopilot” upgrade later, so the HUD label stays exactly “● AUTOPILOT ACTIVE” and the class stays SkilledAutoPlayer — no rename. We’ll just say “Ace” in chat/docs when talking about this AI pilot specifically.
  • sim/entity_pool.gd’s generic data: PackedFloat32Array field is enemy current HP (confirmed via elemental_system.gd’s damage_enemy()); EnemyPool.max_hp is spawn/max HP. There is no separate hp field name.
  • sim/enemy_pool.gd has is_elite: PackedInt32Array (1/0) already used by SkilledAutoPlayer’s existing flee-danger weighting.
  • sim/boss_rotation.gd’s is_boss_type(tid: int) -> bool is the existing, robust way to detect a boss by type_id (TYPE_BOSS/TYPE_BOSS2/TYPE_FUNZO/TYPE_GRAVITON/TYPE_EYE) — bosses do NOT set is_elite, so there’s no double-counting between the elite and boss bonus terms.
  • sim/drone_director.gd confirms drones deal real AoE pulse damage (and some classes heal) — a “decoy” is not purely defensive, so deploying the instant it’s charged is a straightforward value-maximizing policy, not a risk/reward tradeoff.
  • ui/boss_edge_indicator.gd already establishes the exact render pattern to follow: a CanvasLayer, a small inner Control subclass doing custom _draw(), and a pure, static, unit-testable geometry helper (clamp_to_edge) kept separate from anything touching get_viewport() — so the math can be tested without a real camera/viewport.
  • sim.hash.query_circle(...) (used throughout AutoPlayer) is a radius query and isn’t the right tool for “score every enemy into one of N angular sectors” — ThreatMap does a plain for i in range(sim.enemies.count) sweep instead, same pattern AutoPlayer.compute_move_for’s gem-seek loop already uses. Enemy counts here are the same order of magnitude the renderer and elemental_system already sweep every tick, so this is not a new performance concern.

1. ai/threat_map.gd (new) — class_name ThreatMap extends RefCounted

Section titled “1. ai/threat_map.gd (new) — class_name ThreatMap extends RefCounted”

A pure, stateless utility with no held state — every call recomputes from current Sim state. Shared by the AI pilot (movement/dash/aim decisions) and the radar (what to draw), so the two are provably looking at the same picture instead of two independently-guessed heuristics.

class_name ThreatMap
extends RefCounted
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
# Buckets every alive enemy into one of `sectors` angular slices around `pilot`, by direction
# only (not distance, not on/off-screen). Each slice accumulates a danger score and remembers
# the position of its single highest-weighted enemy (what the AI/radar should point at if that
# slice is picked). Returns an Array of exactly `sectors` Dictionaries, one per slice. 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 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 += weight
if weight > 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 the AI pilot, which only cares about the ONE worst
# direction, not the full 16-slice breakdown the radar needs.
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

2. ai/skilled_auto_player.gd — engage/dash/decoy/aim behavior

Section titled “2. ai/skilled_auto_player.gd — engage/dash/decoy/aim behavior”

Four additive changes, layered on the existing flee+seek+wall model (which stays — it’s still what keeps the pilot alive at close range):

  • Engage force in compute_move_for(): after the existing flee/seek/wall calculation, compute var threat := ThreatMap.compute(sim, pilot) and var top := ThreatMap.best(threat). If top.has_target, add (top.target_pos - pilot.pos).normalized() * ENGAGE_SEEK_WEIGHT (new const, 1.0) into the summed direction before normalizing. At long range this pulls the pilot toward the worst cluster; once close enough that flee dominates (enemies now inside THREAT_RADIUS), the existing flee term naturally takes back over — no explicit state machine needed, it falls out of vector summation the same way wall already blends with flee/seek.
  • Proactive engage-dash, new wants_dash() override: first check the existing panic condition (call super.wants_dash(sim, pilot) — anything within DASH_PANIC_RADIUS, i.e. already point-blank) and return true immediately if so — panic always wins. Otherwise, recompute ThreatMap.best() and, if there’s a target within [ENGAGE_DASH_MIN_RANGE, ENGAGE_DASH_MAX_RANGE] (new consts, 150.0/400.0) and the pilot’s own dash_cd <= 0.0, request a dash too. Since compute_move_for() already blends the engage force into move_dir at this range, the dash (which fires in whatever move_dir currently is) naturally carries the pilot toward the cluster — no separate dash-direction calculation needed.
  • Drone deploy policy, new wants_decoy() override: return pilot.drone_charge >= 1.0 — drop the base class’s proximity gate entirely. No reason to hold a full charge waiting for danger when deploying immediately maximizes drone uptime (confirmed drones are a real damage/heal source, not a pure defensive cooldown to hoard).
  • Aim targeting — new method on both classes, replacing the duplicated inline logic:
    • AutoPlayer (base) gains compute_aim_for(sim: Sim, pilot: PlayerState) -> Vector2: the existing “nearest enemy within AIM_RADIUS (400.0, new const)” logic, moved out of the two call sites verbatim (behavior-preserving for the base class).
    • SkilledAutoPlayer overrides it: if ThreatMap.best() has a target within AIM_RADIUS, aim at it; otherwise fall back to super.compute_aim_for(sim, pilot) (nearest-in-range), so the manual weapon still tracks something even when the worst cluster is out of aim range.
    • tools/soak/soak_run.gd:93 and main.gd’s attract-mode input block both replace their inline nearest-enemy scan with a call to auto.compute_aim_for(sim, pilot) — same behavior for the base AutoPlayer (used by coverage soaks/never touched otherwise), smarter for SkilledAutoPlayer (used by balance-signal soaks and, as of the last commit, attract mode).

3. ui/threat_radar.gd (new) — class_name ThreatRadar extends CanvasLayer

Section titled “3. ui/threat_radar.gd (new) — class_name ThreatRadar extends CanvasLayer”

Follows ui/boss_edge_indicator.gd’s structure exactly: a CanvasLayer holding one custom Control subclass that does all the drawing, plus a pure static geometry helper kept separate from any viewport/camera dependency so it’s unit-testable the same way clamp_to_edge is.

const MARGIN: float = 24.0
const SCORE_NORMALIZE: float = 8.0 # 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)
# 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)

update_radar(sim: Sim, pilot: PlayerState) (called once per frame from main.gd, alongside the existing boss_edge_indicator.update_indicator(...) call): runs ThreatMap.compute(sim, pilot), and for each of the 16 slices computes edge_point(Vector2(cos(angle), sin(angle)), vp, MARGIN) and an intensity clampf(score / SCORE_NORMALIZE, 0.0, 1.0), then hands the inner _Radar control an array of {pos, radius: lerpf(MIN_RADIUS, MAX_RADIUS, intensity), alpha: intensity * MAX_ALPHA}. _Radar._draw() renders each as 2-3 concentric draw_circle calls with falling alpha (same soft-glow technique BossEdgeIndicator._Arrow already uses for its pulse halo) in DANGER_COLOR. A zero-intensity slice draws nothing (no wasted draw calls for empty directions).

main.gd wiring: instantiate threat_radar = ThreatRadar.new() and add_child next to the existing boss_edge_indicator setup; call threat_radar.update_radar(sim, sim.player) in the same per-frame block that already updates the boss indicator. Visible whenever sim != null (same lifetime as the rest of the HUD) — no separate show/hide flag.

  • ThreatMap.compute()/best(): pure functions, straightforward GUT unit tests with a real Sim + synthetic enemies via sim.enemies.add(...) (fixture pattern already used elsewhere) — sector boundary placement, elite/boss/HP weighting each independently, empty-arena returns all-zero slices, best() picks the correct slice.
  • ThreatRadar.edge_point(): pure geometry, unit-testable exactly like the existing BossEdgeIndicator.clamp_to_edge tests (tests/test_boss_edge_indicator.gd is the template).
  • SkilledAutoPlayer’s new forces/overrides: unit tests asserting the returned direction/bool for synthetic sim states — e.g. “one elite cluster east, nothing else nearby → move direction has strong positive x”, “enemy at 250px with dash off cooldown → wants_dash() true”, “drone charge at 1.0 regardless of nearby enemies → wants_decoy() true”, “no enemies in range but one at 600px → compute_aim_for() falls back to zero vector, not a stale/wrong target.”
  • AutoPlayer.compute_aim_for(): one regression test confirming it’s behavior-identical to the inline logic it replaced (nearest enemy within 400px).
  • Full suite + count guard + boot smoke per the standard bh-dev-chunk ritual; no determinism re-pin expected (this never touches /sim).

Chris approved this design live in chat across several exchanges (glow-marker scope → threat scoring formula → always-on-vs-pop-in behavior → the drone/aim additions → the naming/HUD-label clarification) rather than a single written-spec review pass, matching how the previous AI-tooling spec (2026-07-16-skilled-autoplayer-balance-signal-design.md) was handled. Proceeding straight to writing-plans.