Skip to content

Cheap Threat Radar 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 per-enemy threat radar (which correlates with a real, measured FPS regression on Apple TV) with a fixed-cost design: an analytic sector-glow shader (no SubViewport, verified visually in a throwaway prototype) plus a small, bounded top-K sharp-dot layer for the single worst individual threats.

Architecture: Rewrite ui/threat_radar.gd in place. Remove the SubViewport-based mist layer entirely. Add a pure select_top_k() function (bounded selection over all enemies, reusing ThreatMap.enemy_weight()) and a pure sector-uniform-prep function feeding ThreatMap.compute()’s already-existing per-sector data into a new analytic shader (the same ShaderMaterial-on-ColorRect pattern fx/screen_feedback.gd already uses). No /sim changes.

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

  • No /sim changes — ThreatMap.compute()’s per-sector score/angle and ThreatMap.enemy_weight()’s per-enemy O(1) cost already exist and are reused as-is.
  • The aggregate-glow shader uses the VERIFIED prototype formula and constant (KERNEL_WIDTH = 0.6, tuned against two real visual scenarios — see docs/research/2026-07-18-threat-radar-cost-redesign-research.md and the design spec at docs/superpowers/specs/2026-07-18-cheap-threat-radar-design.md) — do not re-guess this value without re-running a real visual check.
  • Total cost ceiling: ~2 draw calls, ≤TOP_K+SECTORS quads, ZERO SubViewport passes — completely independent of live enemy count. If any change in this plan reintroduces a per-enemy-scaling cost, that’s a regression against the whole point of this plan.
  • fx/screen_feedback.gd’s shader-authoring pattern (shader code as a const String, applied via ShaderMaterial on a ColorRect, render_mode blend_add, parameters via set_shader_parameter()) is the established convention to follow — don’t invent a different shader-wiring style.
  • Godot 4.6 canvas_item shaders DO support fixed-size array uniforms (uniform float x[8];) — already empirically confirmed working in this exact engine version via the throwaway visual prototype earlier this session, not a risk to re-verify from scratch.

Task 1: Pure functions — top-K selection + sector-uniform prep

Section titled “Task 1: Pure functions — top-K selection + sector-uniform prep”

Files:

  • Modify: ui/threat_radar.gd (add new pure functions, keep edge_point() unchanged)
  • Modify: tests/test_threat_radar.gd (replace the build_light_data()-specific tests with tests for the new functions; keep the edge_point() tests unchanged)

Interfaces:

  • Produces: ThreatRadar.select_top_k(sim: Sim, pilot: PlayerState, k: int) -> Array[Dictionary] — each entry {"pos": Vector2, "alpha": float} (same shape the old sharp-light array used), containing only the k heaviest-weighted enemies (by ThreatMap.enemy_weight()), sorted descending by weight is NOT required (rendering doesn’t care about order).

  • Produces: ThreatRadar.sector_uniform_arrays(slices: Array[Dictionary], normalize: float) -> Dictionary — returns {"scores": PackedFloat32Array, "angles": PackedFloat32Array} from ThreatMap.compute()’s slice array, with each score divided by normalize (reusing SCORE_NORMALIZE-style scaling so shader intensity stays in a sane 0..~1+ range).

  • Step 1: Write the failing tests

Read the current tests/test_threat_radar.gd in full first (quoted above in this plan’s context — verify it still matches before editing). REMOVE these 6 existing tests entirely (they test the now-deleted build_light_data()/mist-scaling behavior, obsolete under this redesign): test_build_light_data_empty_arena_is_empty, test_build_light_data_one_enemy_produces_one_light_in_each_layer, test_build_light_data_mist_position_is_the_sharp_position_scaled, test_build_light_data_alpha_matches_normalized_enemy_weight, test_build_light_data_elite_has_higher_alpha_than_plain_at_the_same_distance, test_build_light_data_negligible_far_enemy_is_excluded, test_build_light_data_two_enemies_produce_two_lights. KEEP the 5 edge_point() tests and the 3 update_radar()-level tests (test_no_lights_when_arena_empty, test_one_light_when_one_enemy_present, test_update_radar_self_repairs_visibility) unchanged for now — Task 2 will verify they still pass, not this task.

Add these new tests in place of the removed ones:

# ── select_top_k() — bounded selection over ALL enemies ───────────────────────────────────
func test_select_top_k_returns_all_when_fewer_enemies_than_k() -> 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 top := ThreatRadar.select_top_k(sim, sim.player, 24)
assert_eq(top.size(), 2, "fewer enemies than k -> all of them returned")
func test_select_top_k_caps_at_k_even_with_more_enemies() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
for i in range(10):
sim.enemies.add(Vector2(100 + i, 0), Vector2.ZERO, 14.0, 20.0)
var top := ThreatRadar.select_top_k(sim, sim.player, 3)
assert_eq(top.size(), 3, "more enemies than k -> capped at k")
func test_select_top_k_keeps_the_heaviest_not_arbitrary_ones() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
# A close plain enemy (heavier weight, per ThreatMap's distance falloff) and a far plain
# enemy (much lighter weight) -- with k=1, only the CLOSE one should survive.
var close_pos := Vector2(50, 0)
var far_pos := Vector2(1900, 0)
sim.enemies.add(close_pos, Vector2.ZERO, 14.0, 20.0)
sim.enemies.add(far_pos, Vector2.ZERO, 14.0, 20.0)
var top := ThreatRadar.select_top_k(sim, sim.player, 1)
assert_eq(top.size(), 1)
var kept_pos: Vector2 = top[0]["pos"]
var expected_pos := ThreatRadar.edge_point((close_pos - sim.player.pos).normalized(), VP, ThreatRadar.MARGIN)
assert_almost_eq(kept_pos.x, expected_pos.x, 1.0, "k=1 keeps the CLOSE (heavier-weighted) enemy, not the far one")
func test_select_top_k_empty_arena_returns_empty() -> void:
var sim := Sim.new(1, _content())
var top := ThreatRadar.select_top_k(sim, sim.player, 24)
assert_eq(top.size(), 0)
func test_select_top_k_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 top := ThreatRadar.select_top_k(sim, sim.player, 24)
assert_almost_eq(top[0]["alpha"], expected, 0.0001)
# ── sector_uniform_arrays() — feeds ThreatMap.compute()'s existing data to the shader ──────
func test_sector_uniform_arrays_lengths_match_slice_count() -> void:
var sim := Sim.new(1, _content())
var slices := ThreatMap.compute(sim, sim.player)
var arrays := ThreatRadar.sector_uniform_arrays(slices, ThreatRadar.SCORE_NORMALIZE)
assert_eq((arrays["scores"] as PackedFloat32Array).size(), slices.size())
assert_eq((arrays["angles"] as PackedFloat32Array).size(), slices.size())
func test_sector_uniform_arrays_scores_are_normalized() -> 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 slices := ThreatMap.compute(sim, sim.player)
var arrays := ThreatRadar.sector_uniform_arrays(slices, ThreatRadar.SCORE_NORMALIZE)
var scores: PackedFloat32Array = arrays["scores"]
var raw_max := 0.0
for s in slices:
raw_max = maxf(raw_max, float(s["score"]))
var expected := raw_max / ThreatRadar.SCORE_NORMALIZE
var actual_max := 0.0
for v in scores:
actual_max = maxf(actual_max, v)
assert_almost_eq(actual_max, expected, 0.001, "the heaviest sector's score, divided by the normalize constant, must appear in the output array")
func test_sector_uniform_arrays_angles_pass_through_unchanged() -> void:
var sim := Sim.new(1, _content())
var slices := ThreatMap.compute(sim, sim.player)
var arrays := ThreatRadar.sector_uniform_arrays(slices, ThreatRadar.SCORE_NORMALIZE)
var angles: PackedFloat32Array = arrays["angles"]
for i in range(slices.size()):
assert_almost_eq(angles[i], float(slices[i]["angle"]), 0.0001)
  • 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: the new tests FAIL (select_top_k/sector_uniform_arrays don’t exist yet); the edge_point() and update_radar()-level tests still PASS (untouched by this step).

  • Step 3: Write the implementation

Read the current ui/threat_radar.gd in full (quoted in this plan’s own context above — verify it still matches; if it’s drifted, adapt to the real current content rather than assuming). Add these two pure static functions, placed near the existing edge_point()/build_light_data():

# Bounded selection: only the k HEAVIEST-weighted enemies get their own sharp marker, capped
# at a small fixed constant regardless of live swarm size (was: every enemy above a negligible-
# alpha threshold, up to MAX_LIGHTS=400 -- the primitive-count-scales-with-enemy-count problem
# this whole redesign exists to fix). O(enemy count * k) for small k -- no full sort needed.
static func select_top_k(sim: Sim, pilot: PlayerState, k: int) -> Array[Dictionary]:
var kept: Array[Dictionary] = [] # sorted ascending by weight; kept[0] is the weakest kept
for i in range(sim.enemies.count):
var d: Vector2 = sim.enemies.pos[i] - pilot.pos
if d.length() < 0.001:
continue
var weight := ThreatMap.enemy_weight(sim, pilot, i)
var alpha := clampf(weight / SCORE_NORMALIZE, 0.0, 1.0)
if alpha <= 0.001:
continue
if kept.size() < k:
kept.append({"weight": weight, "pos": edge_point(d.normalized(), Vector2(1280, 720), MARGIN), "alpha": alpha})
kept.sort_custom(func(a, b): return a.weight < b.weight)
elif weight > float(kept[0].weight):
kept[0] = {"weight": weight, "pos": edge_point(d.normalized(), Vector2(1280, 720), MARGIN), "alpha": alpha}
kept.sort_custom(func(a, b): return a.weight < b.weight)
var out: Array[Dictionary] = []
for entry in kept:
out.append({"pos": entry.pos, "alpha": entry.alpha})
return out
# Feeds ThreatMap.compute()'s already-existing per-sector score/angle straight into the
# aggregate-glow shader's uniform arrays -- zero new sim-side computation.
static func sector_uniform_arrays(slices: Array[Dictionary], normalize: float) -> Dictionary:
var scores := PackedFloat32Array()
var angles := PackedFloat32Array()
for s in slices:
scores.append(float(s["score"]) / normalize)
angles.append(float(s["angle"]))
return {"scores": scores, "angles": angles}

Note: select_top_k()’s hardcoded Vector2(1280, 720) viewport size mirrors this plan’s test usage (matching project.godot’s configured window/viewport size, same constant the existing test file’s own VP constant uses) — Task 2 will replace this with the REAL viewport size read from the live get_viewport() call once this function is invoked from update_radar(), since select_top_k() itself has no Node/viewport access as a static pure function (same pattern edge_point() already established: pure geometry takes vp: Vector2 as an explicit param, not a live viewport). Re-read this function’s real call site in Task 2 before assuming the hardcoded value here is final — it’s a placeholder for Task 1’s pure-function testing only.

  • 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 on the new tests. The update_radar()-level tests will likely still pass too (they don’t touch the new functions), but don’t worry if they don’t yet — Task 2 rewrites update_radar() itself and is responsible for those passing at the end.

  • Step 5: Commit
Terminal window
git add ui/threat_radar.gd tests/test_threat_radar.gd
git commit -m "feat(ui): ThreatRadar top-K selection + sector-uniform prep (pure functions)"

Task 2: Rendering — analytic shader + top-K sharp layer, remove the SubViewport

Section titled “Task 2: Rendering — analytic shader + top-K sharp layer, remove the SubViewport”

Files:

  • Modify: ui/threat_radar.gd

Interfaces:

  • Consumes: select_top_k(), sector_uniform_arrays() (Task 1).

  • Produces: update_radar(sim: Sim, pilot: PlayerState) -> void (signature unchanged — no caller-side changes needed, matching the original per-enemy version’s own design constraint).

  • Step 1: Read the current file’s rendering section in full

Confirm the current _ready(), _make_multimesh(), update_radar(), _upload(), light_count() still match what’s quoted in this plan’s context (Task 1 will have added the two new pure functions above them, nothing else should have changed).

  • Step 2: Replace the consts

Change:

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)

to:

# 2026-07-18: replaced the per-enemy MultiMesh + SubViewport mist (correlated with a real,
# telemetry-measured FPS regression that scaled with live enemy count -- see
# docs/research/2026-07-18-threat-radar-cost-redesign-research.md) with a fixed-cost design: a
# top-K sharp layer (this const) + an analytic sector-glow shader (no SubViewport, no per-enemy
# cost at all). Verified visually in a throwaway prototype before committing to these numbers.
const TOP_K: int = 24 # a small fixed cap, NOT scaled to swarm size -- the whole point of this redesign
const KERNEL_WIDTH: float = 0.6 # tuned against two real visual test scenarios (dense cluster + lone threat vs noise)
  • Step 3: Add the aggregate-glow shader constant

Add, near the top of the file alongside the other consts (following fx/screen_feedback.gd’s exact shader-authoring convention: shader code as a const String, shader_type canvas_item, render_mode blend_add):

# Analytic per-sector angular-Gaussian sum -- extends fx/screen_feedback.gd's proven edge-distance
# smoothstep-falloff technique (BORDER_SHADER) from LINEAR distance to ANGULAR distance around
# the screen center, fed by ThreatMap.compute()'s already-existing per-sector score/angle data.
# Genuine continuous blending between adjacent sectors (no discrete flat wedges, no visible
# seams) with ZERO additional render pass and ZERO per-enemy cost -- verified visually in a
# throwaway prototype: a dense cluster spanning multiple sectors reads as one continuous glow,
# and a single real threat among scattered background noise reads at ~15:1 contrast, clearly
# distinguishable (not washed to sameness, the specific failure this whole rework traces back to).
const SECTOR_COUNT := 8 # must match ThreatMap.SECTORS
const AGGREGATE_GLOW_SHADER := "
shader_type canvas_item;
render_mode blend_add;
uniform float sector_score[8];
uniform float sector_angle[8];
uniform vec3 glow_color = vec3(1.0, 0.35, 0.1);
const float TAU = 6.283185307;
const float KERNEL_WIDTH = 0.6;
void fragment() {
vec2 centered = UV - vec2(0.5);
float pixel_angle = atan(centered.y, centered.x);
if (pixel_angle < 0.0) pixel_angle += TAU;
float total_glow = 0.0;
for (int i = 0; i < 8; i++) {
float diff = abs(pixel_angle - sector_angle[i]);
diff = min(diff, TAU - diff); // shortest angular distance, wrapping past 0/TAU
total_glow += sector_score[i] * exp(-(diff * diff) / (KERNEL_WIDTH * KERNEL_WIDTH));
}
float a = clamp(total_glow, 0.0, 1.0);
COLOR = vec4(glow_color * a, a);
}
"

(This mesh should be rendered as a ring/band around the screen edge, not a full-screen tint — match whatever framing the Task 2 visual-verification step below confirms looks right; the formula’s atan-based angle computation works the same regardless of whether the ColorRect is a full-screen quad with a ring mask or a purpose-shaped ring mesh — settle this specific presentation detail during this task’s own visual check, don’t assume the prototype’s exact framing choice was final, since the prototype agent’s own report flagged this as untested.)

  • Step 4: Rewrite _ready(), update_radar(), remove the mist SubViewport

Replace the file’s node-setup and per-tick update logic. The exact structure:

  • Remove _mist_viewport, _mist_mm, _mist_display variables and all code that creates or updates them.

  • Keep _sharp_mm but resize its MultiMesh.instance_count to TOP_K instead of MAX_LIGHTS (via _make_multimesh(), updated to take a capacity param or just hardcode TOP_K since only one MultiMesh remains).

  • Add a new _aggregate_glow: ColorRect with a ShaderMaterial wrapping AGGREGATE_GLOW_SHADER, following fx/screen_feedback.gd’s exact _ready() pattern (Shader .new(); sh.code = AGGREGATE_GLOW_SHADER; mat.shader = sh; set_anchors_preset(PRESET_FULL_RECT)).

  • update_radar(sim, pilot): keep the visible = true self-repair line first (unchanged behavior, already covered by test_update_radar_self_repairs_visibility). Then:

    1. Call select_top_k(sim, pilot, TOP_K) using the REAL viewport size (get_viewport().get_visible_rect().size, replacing Task 1’s placeholder hardcoded Vector2(1280, 720) — since select_top_k() is a static function without Node access, the real viewport size must be threaded through as a parameter; adjust select_top_k()’s signature to accept vp: Vector2 explicitly if it doesn’t already, matching edge_point()’s own established pattern of taking vp as an explicit param rather than reading a live viewport itself).
    2. Upload the result to _sharp_mm via the EXISTING _upload() function (unchanged — it already takes an arbitrary lights array and a capacity-bounded MultiMesh).
    3. Call ThreatMap.compute(sim, pilot), pass the slices through sector_uniform_arrays(), and set the shader’s sector_score/sector_angle uniform arrays via _aggregate_glow.material.set_shader_parameter("sector_score", scores) (and same for angles) — confirm Godot 4.6’s set_shader_parameter() accepts a PackedFloat32Array directly for a fixed-size array uniform (this exact call shape was used successfully in the throwaway visual prototype earlier this session — not a new risk).
  • Step 5: Real visual verification (not just “the code looks right”)

Per this project’s own established practice (every prior rendering change this session — the original per-enemy mist, this session’s earlier work — got a REAL capture + direct visual inspection, never just a described/assumed result): export or run a windowed build, spawn a realistic enemy cluster (reuse whatever capture harness pattern tools/ship_preview or a similar existing tool already provides, or adapt the throwaway prototype’s approach from earlier this session if useful as a reference), and DIRECTLY LOOK at the resulting radar via the Read tool on a captured screenshot. Confirm: the aggregate glow reads as smooth/continuous (not seamed), the top-K sharp dots are visible and correctly positioned, and the overall look is recognizably in the spirit of what shipped in BUILD 173 (a screen-edge glow toward danger) — Chris should be able to look at this and recognize it as the same FEATURE, just cheaper, not something visually broken or unrecognizable.

  • Step 6: Run the full threat_radar test file

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit Expected: PASS on all tests, including the 3 pre-existing update_radar()-level tests (test_no_lights_when_arena_empty, test_one_light_when_one_enemy_present, test_update_radar_self_repairs_visibility) — these test light_count() (_sharp_mm.multimesh.visible_instance_count), which should behave identically under the new top-K-capped sharp layer for these specific low-enemy-count scenarios (0 or 1 enemies is always well under TOP_K=24, so behavior is unchanged in these specific cases).

  • Step 7: Commit
Terminal window
git add ui/threat_radar.gd
git commit -m "feat(ui): ThreatRadar — analytic sector-glow shader + top-K sharp dots, remove SubViewport mist"

Task 3: Verification, soak sanity, and push

Section titled “Task 3: Verification, soak sanity, and push”

Files: none (verification only).

  • Step 1: Full suite

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: 0 unexpected failures, same 4 pre-existing risky/pending.

  • Step 2: Count guard

Run: bash scripts/check-test-count.sh Expected: test-count guard OK.

  • Step 3: Boot smoke

Run: godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR" Expected: no output — this is the step that would catch a shader compile error (a malformed AGGREGATE_GLOW_SHADER string) if Task 2’s Step 5 visual check somehow missed it.

  • Step 4: Determinism baseline

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit Expected: PASS unchanged — this plan never touches /sim.

  • Step 5: Soak sanity

Run: bash scripts/soak.sh --runs=10 --ticks=36000 --player=skilled --seed0=14000 Expected: 0 invariant failures, 0 anomalies. This is a UI-only change so soak’s own gameplay outcomes shouldn’t shift meaningfully — the real acceptance test for THIS plan’s actual goal (lower render cost) is on-device telemetry after the next deploy, not soak.

  • Step 6: Push
Terminal window
git push origin main
  • Step 7: Report to Chris

This plan does not include a device deploy. Tell Chris explicitly: the code is ready, but the REAL acceptance test — whether Apple TV’s FPS/draws/prims telemetry actually improves — can only be confirmed after the next real device deploy and some real play sessions feeding the samples table, the same way the regression was originally found. Recommend comparing the next several builds’ samples data against this session’s build 172-174 baseline once deployed.