Cheap Threat Radar Implementation Plan
Cheap Threat Radar Implementation Plan
Section titled “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.
Global Constraints
Section titled “Global Constraints”- No
/simchanges —ThreatMap.compute()’s per-sectorscore/angleandThreatMap.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 — seedocs/research/2026-07-18-threat-radar-cost-redesign-research.mdand the design spec atdocs/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+SECTORSquads, 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 aconst String, applied viaShaderMaterialon aColorRect,render_mode blend_add, parameters viaset_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, keepedge_point()unchanged) - Modify:
tests/test_threat_radar.gd(replace thebuild_light_data()-specific tests with tests for the new functions; keep theedge_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 thekheaviest-weighted enemies (byThreatMap.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}fromThreatMap.compute()’s slice array, with eachscoredivided bynormalize(reusingSCORE_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
git add ui/threat_radar.gd tests/test_threat_radar.gdgit 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 hereconst 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 redesignconst 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.SECTORSconst 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_displayvariables and all code that creates or updates them. -
Keep
_sharp_mmbut resize itsMultiMesh.instance_counttoTOP_Kinstead ofMAX_LIGHTS(via_make_multimesh(), updated to take a capacity param or just hardcodeTOP_Ksince only one MultiMesh remains). -
Add a new
_aggregate_glow: ColorRectwith aShaderMaterialwrappingAGGREGATE_GLOW_SHADER, followingfx/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 thevisible = trueself-repair line first (unchanged behavior, already covered bytest_update_radar_self_repairs_visibility). Then:- Call
select_top_k(sim, pilot, TOP_K)using the REAL viewport size (get_viewport().get_visible_rect().size, replacing Task 1’s placeholder hardcodedVector2(1280, 720)— sinceselect_top_k()is a static function without Node access, the real viewport size must be threaded through as a parameter; adjustselect_top_k()’s signature to acceptvp: Vector2explicitly if it doesn’t already, matchingedge_point()’s own established pattern of takingvpas an explicit param rather than reading a live viewport itself). - Upload the result to
_sharp_mmvia the EXISTING_upload()function (unchanged — it already takes an arbitrary lights array and a capacity-bounded MultiMesh). - Call
ThreatMap.compute(sim, pilot), pass the slices throughsector_uniform_arrays(), and set the shader’ssector_score/sector_angleuniform arrays via_aggregate_glow.material.set_shader_parameter("sector_score", scores)(and same for angles) — confirm Godot 4.6’sset_shader_parameter()accepts aPackedFloat32Arraydirectly 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).
- Call
-
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
git add ui/threat_radar.gdgit 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
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.