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.
Problem
Section titled “Problem”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):
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 baseAutoPlayerand 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.- The manual/aim weapon’s targeting logic is duplicated and dumb. Neither
AutoPlayernorSkilledAutoPlayerowns an aim decision at all —tools/soak/soak_run.gd’s_input_for()andmain.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. - 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.
Non-goals
Section titled “Non-goals”- 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
/simchanges; 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.enemiespool as regular enemies (confirmed:boss_warden.gd/eye.gd/graviton.gd/funzo.gdall callsim.enemies.add(...)), soThreatMapnaturally picks them up too (see the boss-bonus term below) — this isn’t a duplicate ofBossEdgeIndicator, 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.
Key facts that shape the design
Section titled “Key facts that shape the design”sim/entity_pool.gd’s genericdata: PackedFloat32Arrayfield is enemy current HP (confirmed viaelemental_system.gd’sdamage_enemy());EnemyPool.max_hpis spawn/max HP. There is no separatehpfield name.sim/enemy_pool.gdhasis_elite: PackedInt32Array(1/0) already used bySkilledAutoPlayer’s existing flee-danger weighting.sim/boss_rotation.gd’sis_boss_type(tid: int) -> boolis the existing, robust way to detect a boss bytype_id(TYPE_BOSS/TYPE_BOSS2/TYPE_FUNZO/TYPE_GRAVITON/TYPE_EYE) — bosses do NOT setis_elite, so there’s no double-counting between the elite and boss bonus terms.sim/drone_director.gdconfirms 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.gdalready establishes the exact render pattern to follow: aCanvasLayer, a small innerControlsubclass doing custom_draw(), and a pure, static, unit-testable geometry helper (clamp_to_edge) kept separate from anything touchingget_viewport()— so the math can be tested without a real camera/viewport.sim.hash.query_circle(...)(used throughoutAutoPlayer) is a radius query and isn’t the right tool for “score every enemy into one of N angular sectors” —ThreatMapdoes a plainfor i in range(sim.enemies.count)sweep instead, same patternAutoPlayer.compute_move_for’s gem-seek loop already uses. Enemy counts here are the same order of magnitude the renderer andelemental_systemalready sweep every tick, so this is not a new performance concern.
Architecture
Section titled “Architecture”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 ThreatMapextends RefCounted
const SECTORS: int = 16const ELITE_BONUS: float = 2.0const BOSS_BONUS: float = 6.0const 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 top2. 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, computevar threat := ThreatMap.compute(sim, pilot)andvar top := ThreatMap.best(threat). Iftop.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 thatfleedominates (enemies now insideTHREAT_RADIUS), the existing flee term naturally takes back over — no explicit state machine needed, it falls out of vector summation the same waywallalready blends withflee/seek. - Proactive engage-dash, new
wants_dash()override: first check the existing panic condition (callsuper.wants_dash(sim, pilot)— anything withinDASH_PANIC_RADIUS, i.e. already point-blank) and returntrueimmediately if so — panic always wins. Otherwise, recomputeThreatMap.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 owndash_cd <= 0.0, request a dash too. Sincecompute_move_for()already blends the engage force intomove_dirat this range, the dash (which fires in whatevermove_dircurrently 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) gainscompute_aim_for(sim: Sim, pilot: PlayerState) -> Vector2: the existing “nearest enemy withinAIM_RADIUS(400.0, new const)” logic, moved out of the two call sites verbatim (behavior-preserving for the base class).SkilledAutoPlayeroverrides it: ifThreatMap.best()has a target withinAIM_RADIUS, aim at it; otherwise fall back tosuper.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:93andmain.gd’s attract-mode input block both replace their inline nearest-enemy scan with a call toauto.compute_aim_for(sim, pilot)— same behavior for the baseAutoPlayer(used by coverage soaks/never touched otherwise), smarter forSkilledAutoPlayer(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.0const SCORE_NORMALIZE: float = 8.0 # score at/above this renders at full intensityconst MIN_RADIUS: float = 10.0const MAX_RADIUS: float = 46.0const MAX_ALPHA: float = 0.5const 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.
Testing
Section titled “Testing”ThreatMap.compute()/best(): pure functions, straightforward GUT unit tests with a realSim+ synthetic enemies viasim.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 existingBossEdgeIndicator.clamp_to_edgetests (tests/test_boss_edge_indicator.gdis 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-chunkritual; no determinism re-pin expected (this never touches/sim).
Approval note
Section titled “Approval note”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.