Skip to content

Per-Enemy Threat Radar Mist Implementation Plan

Per-Enemy Threat Radar Mist Implementation Plan

Section titled “Per-Enemy Threat Radar Mist 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: Replace the 8-sector-blob ThreatRadar with a per-enemy screen-edge light plus a downres-SubViewport mist layer, so dense enemy clusters visibly merge into soft wide glow while isolated enemies stay small distinct dots.

Architecture: Extract ThreatMap’s existing per-enemy weight formula into its own function (shared by Ace’s unchanged decision-making and the new radar). ThreatRadar sweeps every enemy once per frame into a pure build_light_data() function, then uploads that data to two MultiMeshInstance2D layers — one drawn directly (sharp small dots), one rendered into a low-resolution SubViewport and displayed back upscaled with bilinear filtering (the blur that produces genuine merging), both reusing SwarmRenderer’s existing MultiMesh + GlowTexture + additive-blend pattern.

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

  • No changes to /sim — this is entirely ai/threat_map.gd (one behavior-preserving extraction) and ui/threat_radar.gd (full internal rewrite). No determinism re-pin expected; verify the pinned assertion in tests/test_determinism_survival.gd is unchanged at the end.
  • No changes to SkilledAutoPlayer’s decision-making (compute_move_for/wants_dash/ compute_aim_for, all consumers of ThreatMap.compute()/best()) — already tuned and fixed earlier tonight (the engage-vs-flee regression). The Task 1 extraction must be behavior- preserving: same formula, same results, just factored into its own function.
  • No changes to main.gd — ThreatRadar’s external interface (update_radar(sim, pilot), the visible self-repair, the field declaration, the persistence/hide wiring) is completely unchanged; only the class’s internals change.
  • Reuse render/glow_texture.gd’s GlowTexture.shared() — do not create a new texture asset.
  • 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), a real visual capture via the _shot.gd harness before considering the render task done.

Task 1: ThreatMap.enemy_weight() — extract the shared per-enemy formula

Section titled “Task 1: ThreatMap.enemy_weight() — extract the shared per-enemy formula”

Files:

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

Interfaces:

  • Produces: ThreatMap.enemy_weight(sim: Sim, pilot: PlayerState, i: int) -> float — the exact formula currently inlined in compute()’s loop (base 1.0 + ELITE_BONUS if elite + BOSS_BONUS if boss-type + HP-fraction × HP_LIVE_WEIGHT, all × the Gaussian distance falloff using DIST_SIGMA). compute() itself is modified to call this instead of inlining the formula — behavior must be byte-identical (same constants, same math), verified by the existing test suite passing unchanged.

  • Step 1: Write the failing test for the extracted function

Add to tests/test_threat_map.gd (after the existing _only_nonzero_slice helper):

func test_enemy_weight_matches_the_base_plus_full_hp_formula() -> 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 w := ThreatMap.enemy_weight(sim, sim.player, 0)
assert_almost_eq(w, 2.0 * _DIST_100_50_FALLOFF, 0.0001,
"1.0 base + 1.0 full-HP fraction, no elite/boss bonus, distance-discounted -- same formula compute() already tests")
func test_enemy_weight_matches_the_elite_bonus_formula() -> 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 w := ThreatMap.enemy_weight(sim, sim.player, 0)
assert_almost_eq(w, 4.0 * _DIST_100_50_FALLOFF, 0.0001)
func test_enemy_weight_decays_with_distance() -> 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(1500, 0), Vector2.ZERO, 14.0, 20.0)
var near_w := ThreatMap.enemy_weight(sim, sim.player, 0)
var far_w := ThreatMap.enemy_weight(sim, sim.player, 1)
assert_gt(near_w, far_w, "an otherwise-identical enemy weighs less the farther away it is")

(_DIST_100_50_FALLOFF already exists in this file from the earlier distance-falloff work — reuse it, don’t redefine it.)

  • 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 — enemy_weight does not exist on ThreatMap yet.

  • Step 3: Extract the function in ai/threat_map.gd

Find this exact block (the current compute() function):

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 dist: float = d.length()
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
weight *= exp(-(dist * dist) / (DIST_SIGMA * DIST_SIGMA))
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

Replace with:

# Per-enemy danger weight: count + elite + boss + remaining-HP-fraction, discounted by distance
# (Gaussian falloff, DIST_SIGMA). Shared by compute()'s sector accumulation (below) and
# ui/threat_radar.gd's per-enemy light rendering -- one formula, two consumers.
static func enemy_weight(sim: Sim, pilot: PlayerState, i: int) -> float:
var dist: float = sim.enemies.pos[i].distance_to(pilot.pos)
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
weight *= exp(-(dist * dist) / (DIST_SIGMA * DIST_SIGMA))
return weight
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 := enemy_weight(sim, pilot, i)
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
  • 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 tests green, INCLUDING every pre-existing test in this file (the extraction must not change compute()’s observable behavior at all).

  • 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 before this task, plus the 3 new tests. Also re-run test_skilled_auto_player and test_threat_radar specifically (both consume ThreatMap) to confirm zero behavior change: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit

  • Step 6: Commit
Terminal window
git add ai/threat_map.gd tests/test_threat_map.gd
git commit -m "refactor(ai): extract ThreatMap.enemy_weight(), shared by compute() and the new per-enemy radar"

Task 2: ThreatRadar.build_light_data() — pure per-enemy sweep

Section titled “Task 2: ThreatRadar.build_light_data() — pure per-enemy sweep”

Files:

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

Interfaces:

  • Consumes: ThreatMap.enemy_weight(sim, pilot, i) (Task 1).
  • Produces: ThreatRadar.SCORE_NORMALIZE: float (const, 8.0, unchanged from the sector version — a single enemy’s own weight at/above this now renders at full intensity, which is meaningfully reachable for one elite/boss up close). ThreatRadar.build_light_data(sim: Sim, pilot: PlayerState, vp: Vector2, margin: float, mist_scale: float) -> Dictionary — pure, static, no Node/viewport access. Returns {"sharp": Array[Dictionary], "mist": Array[Dictionary]}, each entry {"pos": Vector2, "alpha": float}; mist entries have pos pre-multiplied by mist_scale. ThreatRadar.edge_point(dir, vp, margin) -> Vector2 (existing, unchanged, reused by build_light_data).

This task REPLACES the sector-based update_radar()/blob-drawing internals with the new pure sweep, but does NOT yet touch the actual rendering nodes (_ready(), the _Radar inner class, MultiMesh) — that’s Task 3. For this task, delete the old _Radar inner class, _ready() body, and the old update_radar()/blob_count() entirely; the file temporarily has no working _ready()/update_radar() (Task 3 adds them back) — this is fine, nothing else in the repo calls ThreatRadar methods except main.gd at runtime, which isn’t exercised by this task’s tests.

Files still create: tests/test_threat_radar.gd keeps its existing edge_point() geometry tests (test_edge_point_due_east/south/west/north, test_edge_point_diagonal_stays_within_box) verbatim — do not touch those 5 tests. Everything below # ── update_radar() in that file is being replaced.

  • Step 1: Write the failing tests

In tests/test_threat_radar.gd, replace everything from the # ── update_radar() comment to the end of the file with:

# ── build_light_data() — pure per-enemy sweep, no Node/viewport access ────────────────────
const _MIST_SCALE := 0.18
func test_build_light_data_empty_arena_is_empty() -> void:
var sim := Sim.new(1, _content())
var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE)
assert_eq((data["sharp"] as Array).size(), 0)
assert_eq((data["mist"] as Array).size(), 0)
func test_build_light_data_one_enemy_produces_one_light_in_each_layer() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0)
var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE)
assert_eq((data["sharp"] as Array).size(), 1)
assert_eq((data["mist"] as Array).size(), 1)
func test_build_light_data_mist_position_is_the_sharp_position_scaled() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0)
var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE)
var sharp_pos: Vector2 = (data["sharp"] as Array)[0]["pos"]
var mist_pos: Vector2 = (data["mist"] as Array)[0]["pos"]
assert_almost_eq(mist_pos.x, sharp_pos.x * _MIST_SCALE, 0.01)
assert_almost_eq(mist_pos.y, sharp_pos.y * _MIST_SCALE, 0.01)
func test_build_light_data_alpha_matches_normalized_enemy_weight() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0)
var expected := clampf(ThreatMap.enemy_weight(sim, sim.player, 0) / ThreatRadar.SCORE_NORMALIZE, 0.0, 1.0)
var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE)
assert_almost_eq((data["sharp"] as Array)[0]["alpha"], expected, 0.0001)
func test_build_light_data_elite_has_higher_alpha_than_plain_at_the_same_distance() -> 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(0, 100), Vector2.ZERO, 14.0, 20.0)
sim.enemies.is_elite[1] = 1
var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE)
var lights: Array = data["sharp"]
# both enemies are at distance 100 -- find each by its x/y position sign to compare alphas.
var plain_alpha := 0.0
var elite_alpha := 0.0
for l in lights:
var p: Vector2 = l["pos"]
if absf(p.x - ThreatRadar.edge_point(Vector2.RIGHT, VP, ThreatRadar.MARGIN).x) < 1.0:
plain_alpha = l["alpha"]
if absf(p.y - ThreatRadar.edge_point(Vector2.DOWN, VP, ThreatRadar.MARGIN).y) < 1.0:
elite_alpha = l["alpha"]
assert_gt(elite_alpha, plain_alpha, "the elite enemy's light is brighter than the plain one at the same distance")
func test_build_light_data_negligible_far_enemy_is_excluded() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(2000, 0), Vector2.ZERO, 14.0, 20.0) # arena-edge distance, near-zero weight
var data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE)
assert_eq((data["sharp"] as Array).size(), 0, "negligible weight -- not worth a light or a MultiMesh slot")
func test_build_light_data_two_enemies_produce_two_lights() -> 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 data := ThreatRadar.build_light_data(sim, sim.player, VP, ThreatRadar.MARGIN, _MIST_SCALE)
assert_eq((data["sharp"] as Array).size(), 2)
  • 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 — build_light_data and SCORE_NORMALIZE don’t exist yet in this shape (the file still has the old sector-based implementation).

  • Step 3: Replace ui/threat_radar.gd’s content from the const block down through the old update_radar()/blob_count()

Replace the WHOLE file with:

class_name ThreatRadar
extends CanvasLayer
# An always-on screen-edge glow toward danger, one light PER ENEMY (not per sector), with a
# density-based "mist" layer so clustered enemies visibly merge into a soft wide glow while
# isolated enemies stay small distinct dots. REWRITTEN 2026-07-17 (Chris, after playing the
# original 8-sector-blob version): "make every enemy have a radar threat light and an average
# light effect that connects above them all so if there are tons in one area there will be a
# gentle wide mist above all the tiny lights." See
# docs/superpowers/specs/2026-07-17-per-enemy-threat-radar-mist-design.md for the full research
# + architecture. Rendering nodes are wired up in a later task -- this task only carries the pure
# geometry/data functions (edge_point, build_light_data), testable with no Node/viewport at all.
const MARGIN: float = 24.0
const SCORE_NORMALIZE: float = 8.0 # a single enemy's own weight at/above this renders at full intensity
const RADIUS_MIN: float = 5.0
const RADIUS_MAX: float = 14.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(0.7, 0.06, 0.06)
const MIST_RESOLUTION_SCALE: float = 0.18 # deliberately far lower than nebula_bg.gd's 0.5 -- the blur IS the point here
const MAX_LIGHTS: int = 400 # generous headroom above this game's own peak_enemies telemetry (200-345)
# Pure: given a direction and viewport size, the point on the inset edge box along that ray from
# screen-centre. Same ray-box math the original sector version used.
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)
# Pure: sweeps every enemy once, returns the per-instance data both render layers need. Skips
# enemies whose weight is negligible (alpha <= 0.001) -- nothing meaningful to draw or upload.
# "mist" entries carry the SAME light, position pre-scaled by mist_scale (the low-res SubViewport
# the mist layer renders into has a proportionally smaller coordinate space).
static func build_light_data(sim: Sim, pilot: PlayerState, vp: Vector2, margin: float, mist_scale: float) -> Dictionary:
var sharp: Array[Dictionary] = []
var mist: Array[Dictionary] = []
for i in range(sim.enemies.count):
var d: Vector2 = sim.enemies.pos[i] - pilot.pos
if d.length() < 0.001:
continue
var dir := d.normalized()
var weight := ThreatMap.enemy_weight(sim, pilot, i)
var alpha := clampf(weight / SCORE_NORMALIZE, 0.0, 1.0)
if alpha <= 0.001:
continue
var pos := edge_point(dir, vp, margin)
sharp.append({"pos": pos, "alpha": alpha})
mist.append({"pos": pos * mist_scale, "alpha": alpha})
return {"sharp": sharp, "mist": mist}
  • 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 12 tests green (5 pre-existing edge_point tests + 7 new build_light_data tests). The old test_no_blobs_when_arena_empty/test_one_blob_when_one_enemy_present/ test_update_radar_self_repairs_visibility tests are gone (deleted in Step 1) — Task 3 adds their replacements once the render nodes exist again.

  • Step 5: Commit
Terminal window
git add ui/threat_radar.gd tests/test_threat_radar.gd
git commit -m "refactor(hud): ThreatRadar.build_light_data() -- pure per-enemy sweep, no rendering yet"

Task 3: Wire up the two MultiMesh render layers + SubViewport mist

Section titled “Task 3: Wire up the two MultiMesh render layers + SubViewport mist”

Files:

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

Interfaces:

  • Consumes: ThreatRadar.build_light_data() (Task 2), render/glow_texture.gd’s GlowTexture.shared() -> Texture2D (existing, unchanged).

  • Produces: ThreatRadar.update_radar(sim: Sim, pilot: PlayerState) -> void (same external signature main.gd already calls — no caller-side changes anywhere). ThreatRadar.light_count() -> int (test seam, replaces the old blob_count()).

  • Step 1: Write the failing tests

Append to tests/test_threat_radar.gd. NOTE: _radar() and _ready_radar() helpers already exist earlier in this file (defined before the “Pure edge geometry” section, so untouched by Task 2’s replacement of everything from # ── update_radar() onward) — do NOT redefine them here, just use them directly (redefining would be a duplicate-function error):

# ── update_radar() — node-level behaviour ──────────────────────────────────────────────────
func test_no_lights_when_arena_empty() -> void:
var sim := Sim.new(1, _content())
var r := await _ready_radar()
r.update_radar(sim, sim.player)
assert_eq(r.light_count(), 0, "no enemies -> nothing to light up")
func test_one_light_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, 20.0)
var r := await _ready_radar()
r.update_radar(sim, sim.player)
assert_eq(r.light_count(), 1)
func test_update_radar_self_repairs_visibility() -> void:
var sim := Sim.new(1, _content())
var r := await _ready_radar()
r.visible = false
r.update_radar(sim, sim.player)
assert_true(r.visible, "update_radar always re-shows the radar, mirroring BossEdgeIndicator's self-repair")
  • 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 — update_radar/light_count don’t exist yet (Task 2 left the file without rendering nodes).

  • Step 3: Append the rendering nodes and orchestration to ui/threat_radar.gd

Add this to the end of the file (after build_light_data):

var _sharp_mm: MultiMeshInstance2D
var _mist_viewport: SubViewport
var _mist_mm: MultiMeshInstance2D
var _mist_display: TextureRect
func _ready() -> void:
layer = 5 # same tier as Hud/BossEdgeIndicator
_sharp_mm = MultiMeshInstance2D.new()
_sharp_mm.multimesh = _make_multimesh()
_sharp_mm.texture = GlowTexture.shared()
var sharp_mat := CanvasItemMaterial.new()
sharp_mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD
_sharp_mm.material = sharp_mat
add_child(_sharp_mm)
_mist_viewport = SubViewport.new()
_mist_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_mist_viewport.transparent_bg = true
_mist_mm = MultiMeshInstance2D.new()
_mist_mm.multimesh = _make_multimesh()
_mist_mm.texture = GlowTexture.shared()
var mist_mat := CanvasItemMaterial.new()
mist_mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD
_mist_mm.material = mist_mat
_mist_viewport.add_child(_mist_mm)
add_child(_mist_viewport)
_mist_display = TextureRect.new()
_mist_display.set_anchors_preset(Control.PRESET_FULL_RECT)
_mist_display.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mist_display.stretch_mode = TextureRect.STRETCH_SCALE
_mist_display.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR # the upscale IS the blur
var display_mat := CanvasItemMaterial.new()
display_mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD
_mist_display.material = display_mat
_mist_display.texture = _mist_viewport.get_texture()
add_child(_mist_display)
static func _make_multimesh() -> MultiMesh:
var quad := QuadMesh.new()
quad.size = Vector2(RADIUS_MAX * 2.0, RADIUS_MAX * 2.0)
var mm := MultiMesh.new()
mm.mesh = quad
mm.transform_format = MultiMesh.TRANSFORM_2D
mm.use_colors = true
mm.instance_count = MAX_LIGHTS
mm.visible_instance_count = 0
return mm
func update_radar(sim: Sim, pilot: PlayerState) -> void:
visible = true
var vp := get_viewport().get_visible_rect().size
var mist_size := Vector2i((vp * MIST_RESOLUTION_SCALE).round())
if _mist_viewport.size != mist_size:
_mist_viewport.size = mist_size
var data := build_light_data(sim, pilot, vp, MARGIN, MIST_RESOLUTION_SCALE)
_upload(_sharp_mm.multimesh, data["sharp"])
_upload(_mist_mm.multimesh, data["mist"])
func _upload(mm: MultiMesh, lights: Array) -> void:
var n: int = mini(lights.size(), MAX_LIGHTS)
for i in range(n):
var light: Dictionary = lights[i]
var pos: Vector2 = light["pos"]
var alpha: float = light["alpha"]
var r: float = lerpf(RADIUS_MIN, RADIUS_MAX, alpha) / RADIUS_MAX
mm.set_instance_transform_2d(i, Transform2D(Vector2(r, 0.0), Vector2(0.0, r), pos))
mm.set_instance_color(i, Color(DANGER_COLOR.r, DANGER_COLOR.g, DANGER_COLOR.b, alpha * MAX_ALPHA))
mm.visible_instance_count = n
# Test seam.
func light_count() -> int:
return _sharp_mm.multimesh.visible_instance_count if _sharp_mm != 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 15 tests green (5 edge_point + 7 build_light_data + 3 new node-level).

  • Step 5: Verify with a real capture — do NOT skip this step

Write a temporary capture script _shot.gd in the repo root:

extends SceneTree
func _init() -> void:
await process_frame
var bg := ColorRect.new()
bg.color = Color(0.03, 0.03, 0.06)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
root.add_child(bg)
var defs: ContentDB = ContentLoader.load_from_path("res://data/bible.json")
var sim: Sim = Sim.new(1, defs)
sim.player.pos = Vector2.ZERO
# A loose scatter (should stay small distinct dots) plus a genuinely dense cluster
# (should visibly merge into wide mist) plus one far, near-zero-weight straggler.
sim.enemies.add(Vector2(150, 20), Vector2.ZERO, 14.0, 20.0)
sim.enemies.add(Vector2(-300, 100), Vector2.ZERO, 14.0, 20.0)
sim.enemies.add(Vector2(0, -250), Vector2.ZERO, 14.0, 20.0)
sim.enemies.is_elite[2] = 1
for i in range(25):
var a := float(i) / 25.0 * 0.6 - 0.3
sim.enemies.add(Vector2(200, 200) + Vector2(cos(a), sin(a)) * 30.0, Vector2.ZERO, 14.0, 20.0)
sim.enemies.add(Vector2(1900, 0), Vector2.ZERO, 14.0, 20.0)
var radar: ThreatRadar = ThreatRadar.new()
root.add_child(radar)
await process_frame
await process_frame
radar.update_radar(sim, sim.player)
await process_frame
await process_frame
var img: Image = root.get_texture().get_image()
img.save_png("/tmp/per_enemy_radar_shot.png")
print("SHOT_SAVED")
quit()

Run NON-headless (headless returns blank textures): godot --path . -s res://_shot.gd

Expected stdout: SHOT_SAVED. Then delete the temp script: rm -f _shot.gd _shot.gd.uid.

Look at /tmp/per_enemy_radar_shot.png (Read tool). Confirm, by eye: the 3 loose/isolated enemies (including the elite, which should read brighter/larger than the plain ones) show as small distinct dots; the 25-enemy dense cluster shows as one wide, soft, genuinely blurred glow region — NOT 25 visible overlapping circles with hard edges; the far straggler is barely or not visible. If the cluster still reads as distinct overlapping dots rather than merged mist, MIST_RESOLUTION_SCALE needs to go lower (more blur) — adjust the constant, re-run this step, and do not proceed to Step 6 until the capture genuinely shows merged mist, not just brighter dots. This is a visual effect; numeric test passes are not sufficient proof by themselves (see bullet-heaven-ui-look memory’s “looks plausible isn’t proof” lesson) — the capture is the actual acceptance check for this task.

  • 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 3 new tests.

  • Step 7: Commit
Terminal window
git add ui/threat_radar.gd tests/test_threat_radar.gd
git commit -m "feat(hud): per-enemy threat radar with a downres-SubViewport mist merge layer"

(If Step 5’s capture required adjusting MIST_RESOLUTION_SCALE, include that in this same commit — it’s part of making the task’s actual deliverable correct, not a separate change.)


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, same 4 pre-existing risky/pending.

  • Step 2: Count guard

Run: bash scripts/check-test-count.sh Expected: test-count guard OK (240 scripts — no new test files were created, only existing ones extended in this plan).

  • Step 3: Boot smoke

Run: godot --headless --path . --quit-after 120 Expected: no SCRIPT ERROR lines (leaked-RID/teardown noise 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).

  • Step 5: Soak sanity check

Run: bash scripts/soak.sh --runs=4 --ticks=3600 --ships=cobalt --coop=solo --player=skilled --seed0=7000 Expected: clean JSON report, no invariant failures, no anomalies. (This exercises ThreatMap.enemy_weight()/compute() through SkilledAutoPlayer’s real decision paths, catching any accidental behavior change from the Task 1 extraction that the unit tests didn’t.)

  • Step 6: Export a fresh macOS build and hand off
Terminal window
godot --headless --path . --import
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"
godot --headless --path . --export-release "macOS" builds/macos/BulletHeaven.app
git diff --stat project.godot

Expected: export succeeds; project.godot diff is empty. Then open builds/macos/BulletHeaven.app so Chris can watch the new radar live.

  • Step 7: Push
Terminal window
git push origin main