Explorable Areas (Aurora via wormhole) Implementation Plan
Explorable Areas (Aurora via wormhole) Implementation Plan
Section titled “Explorable Areas (Aurora via wormhole) Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax. Each task = onebh-dev-chunkcycle (TDD →--importif a new class_name → boot-check → full GUT suite →scripts/check-test-count.sh→ determinism).
Goal: Add a second area “Aurora” reached by flying through a wormhole that spawns after a boss kill — a harder stage with its own backdrop and better rewards, carrying your run; two-way (Aurora’s boss spawns a return wormhole to Home).
Architecture: An Area is light data (difficulty mult, reward mult, background name). Sim gains current_area + area_difficulty_mult + area_reward_mult (all default to Home/1.0 → determinism byte-identical). A boss kill spawns a pickup-like Sim.wormholes entity whose destination is the other area; flying over it emits an area_transition event. Render-side (main) consumes it: freeze the sim (like the level-up grace), play a short warp effect, call Sim.enter_area(dest) (swaps mults + clears the field + resets the wave phase), and swap the background. New WormholeRenderer + an aurora ArenaBackground variant.
Tech Stack: Godot 4.6 typed GDScript; /sim is pure (RefCounted, no Node/Engine/Input/Time/OS/File/JSON); GUT 9.6. Branch: feat/drone-system (current working line — already has the biomass spawner + drones; areas build directly on it).
Global Constraints
Section titled “Global Constraints”- Determinism byte-identical — survival
1405185210/3122397125, crystals91572468/1173256610(pinned intests/test_determinism_checksum.gd+tests/test_determinism_crystals.gd). Everything here is opt-in: area mults default to exactly 1.0 (IEEE identities),enter_areais never called in the baseline, andSim.wormholesis empty + spawns only on a boss kill (run_time ≫ the <10s baseline window). New columns/arrays/fields MUST stay out ofstate_checksum()/snapshot_string(). Re-check each task; a moved baseline = a leak → STOP, don’t re-pin. /simpurity preserved. Render/UI only READ sim state. The warp transition freezes the sim render-side (mirrors the existingRESUME_GRACElevel-up freeze).- All tunables (difficulty/reward mults, wormhole radius, warp duration) are consts/data — retune later from playtest.
File Structure
Section titled “File Structure”- Create
sim/area_defs.gd—AreaDefs: pure static area table (home, aurora) → {name, difficulty_mult, reward_mult, background}. - Modify
sim/sim.gd— area state +enter_area(); difficulty/reward mults folded into spawn + gold/xp;wormholesarray + boss-kill spawn +_update_wormholes(player-overlap →area_transitionevent). - Create
render/wormhole_renderer.gd—WormholeRenderer: pulsing swirl at each wormhole pos. - Modify
render/arena_background.gd— addVARIANT_AURORA. - Modify
main.gd— create the wormhole renderer; consume thearea_transitionevent → warp freeze +enter_area+ background swap. - Tests:
tests/test_areas.gd(area state/mults/enter_area),tests/test_wormhole.gd(spawn-on-boss + transition event).
Task 1: Area state + difficulty/reward mults + enter_area
Section titled “Task 1: Area state + difficulty/reward mults + enter_area”Files:
- Create:
sim/area_defs.gd - Modify:
sim/sim.gd(area fields;enter_area;_spawn_onedifficulty; gold/xp reward mult) - Test:
tests/test_areas.gd
Interfaces:
-
Produces:
AreaDefs.HOME := "home",AreaDefs.AURORA := "aurora",AreaDefs.get_def(id: String) -> Dictionary(keysname,difficulty_mult,reward_mult,background; unknown id → the home def).Sim.current_area: String,Sim.area_difficulty_mult: float,Sim.area_reward_mult: float,Sim.enter_area(id: String) -> void(sets current_area + both mults from AreaDefs, clears the field, resets the wave phase to WAVES, clears wormholes).Sim.other_area() -> String(home↔aurora). -
Step 1: Write
sim/area_defs.gd.
class_name AreaDefsextends RefCounted
# Light, pure area table. v1 has two areas; an area is a difficulty + reward + backdrop on the SAME# roster (no bespoke enemies yet). Adding an area = a table entry. Lives in /sim (pure data).const HOME := "home"const AURORA := "aurora"
const _DEFS := { "home": {"name": "Home", "difficulty_mult": 1.0, "reward_mult": 1.0, "background": "home"}, "aurora": {"name": "Aurora", "difficulty_mult": 1.6, "reward_mult": 1.5, "background": "aurora"},}
static func get_def(id: String) -> Dictionary: return _DEFS.get(id, _DEFS["home"])
# The "other" area for the two-way wormhole (home↔aurora). Extend when more areas land.static func other(id: String) -> String: return AURORA if id == HOME else HOME- Step 2: Write the failing test
tests/test_areas.gd.
extends GutTest
func _sim() -> Sim: return Sim.new(99, SimContentFixture.db())
func test_defaults_to_home() -> void: var s := _sim() assert_eq(s.current_area, "home", "a fresh run starts in Home") assert_almost_eq(s.area_difficulty_mult, 1.0, 0.001, "Home difficulty mult is exactly 1.0") assert_almost_eq(s.area_reward_mult, 1.0, 0.001, "Home reward mult is exactly 1.0")
func test_enter_area_sets_mults() -> void: var s := _sim() s.enter_area("aurora") assert_eq(s.current_area, "aurora") assert_gt(s.area_difficulty_mult, 1.0, "Aurora is harder") assert_gt(s.area_reward_mult, 1.0, "Aurora rewards more")
func test_enter_area_clears_the_field() -> void: var s := _sim() s.enemies.add(Vector2(10, 0), Vector2.ZERO, 14.0, 50.0, 0.0, 70.0, 12.0, 1.0) s.enter_area("aurora") assert_eq(s.enemies.count, 0, "entering an area clears the hostiles")
func test_other_area_is_two_way() -> void: assert_eq(AreaDefs.other("home"), "aurora") assert_eq(AreaDefs.other("aurora"), "home")-
Step 3: Run it — fails (
current_area/enter_areaundefined): Run:godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_areas.gd -gexitExpected: FAIL —s.current_areanot defined. -
Step 4: Add the area fields to
sim/sim.gd(near other run-state vars, e.g. byvar run_gold):
var current_area: String = "home" # explorable-areas: which area the run is in (default Home)var area_difficulty_mult: float = 1.0 # area-applied enemy HP/contact scale (1.0 = Home → baseline-safe)var area_reward_mult: float = 1.0 # area-applied gold/XP scale (1.0 = Home → baseline-safe)- Step 5: Add
enter_area+other_areatosim/sim.gd. Reuse the existing field-clear helperdev_clear_enemies()(drains enemies/enemy_proj + clears bombs/missiles/zones/webs/funzones/powerups); reset the wave phase toPHASE_WAVESvia_set_spawn_phase(PHASE_WAVES).
# Switch the run into another area: swap difficulty/reward, wipe the current field, restart the wave# loop. Called render-side after the warp transition (deterministic state change; never run in the# no-area baseline). Carries the player's run (build/level/HP) untouched.func enter_area(id: String) -> void: var def := AreaDefs.get_def(id) current_area = id area_difficulty_mult = float(def["difficulty_mult"]) area_reward_mult = float(def["reward_mult"]) dev_clear_enemies() wormholes.clear() _set_spawn_phase(PHASE_WAVES)
func other_area() -> String: return AreaDefs.other(current_area)(wormholes is declared in Task 2; if implementing Task 1 alone, declare var wormholes: Array = [] now — Task 2 reuses it.)
- Step 6: Fold
area_difficulty_multinto_spawn_one. Change the difficulty line (currentlyvar diff := spawner.difficulty_mult(run_time)):
var diff := spawner.difficulty_mult(run_time) * area_difficulty_mult(Default 1.0 → x * 1.0 IEEE identity → baseline byte-identical.)
- Step 7: Fold
area_reward_multinto the XP + gold awards. At the XP award (player.xp += amount):
player.xp += amount * area_reward_multAt each boss/kill gold line, scale by the mult, e.g. run_gold += GOLD_PER_KILL → run_gold += int(round(float(GOLD_PER_KILL) * area_reward_mult)) and run_gold += BOSS_GOLD → run_gold += int(round(float(BOSS_GOLD) * area_reward_mult)). (Default 1.0 → round(x*1.0)=x; gold isn’t in the checksum, XP is but mult is exactly 1.0 in the baseline → byte-identical.)
-
Step 8: Run the test — passes. Then
godot --headless --path . --import(newAreaDefsclass). Run:godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_areas.gd -gexit→ PASS. -
Step 9: Full gates + commit. Boot-check,
bash scripts/check-test-count.sh, determinism unchanged.
git add sim/area_defs.gd sim/sim.gd tests/test_areas.gdgit commit -m "feat(areas): area state + difficulty/reward mults + enter_area"Task 2: Wormhole entity — boss-kill spawn + player-contact transition
Section titled “Task 2: Wormhole entity — boss-kill spawn + player-contact transition”Files:
- Modify:
sim/sim.gd(wormholesarray + consts; spawn on boss death in_sweep_dead;_update_wormholes;area_transitionevent) - Test:
tests/test_wormhole.gd
Interfaces:
-
Consumes:
Sim.enter_area,Sim.other_area,AreaDefs(Task 1). -
Produces:
Sim.wormholes: Array(entries{pos: Vector2, dest: String}),Sim.area_events: Array(per-tick render signals, EXCLUDED from checksum/snapshot — likefx_events),WORMHOLE_RADIUS. A boss death spawns ≤1 wormhole; player overlap appends{"kind":"warp","dest":<id>}toarea_eventsand clearswormholes. -
Step 1: Write the failing test
tests/test_wormhole.gd.
extends GutTest
func _sim() -> Sim: var s := Sim.new(7, SimContentFixture.db()) s.player.pos = Vector2.ZERO return s
func test_no_wormhole_by_default() -> void: assert_eq(_sim().wormholes.size(), 0, "no wormhole until a boss dies")
func test_boss_death_spawns_one_wormhole_to_the_other_area() -> void: var s := _sim() s._spawn_area_wormhole(Vector2(200, 0)) assert_eq(s.wormholes.size(), 1, "a boss death spawns a wormhole") assert_eq(String(s.wormholes[0]["dest"]), "aurora", "from Home it leads to Aurora") s._spawn_area_wormhole(Vector2(50, 0)) # a second boss before you travel assert_eq(s.wormholes.size(), 1, "only ever one wormhole at a time")
func test_flying_over_emits_warp_and_clears() -> void: var s := _sim() s._spawn_area_wormhole(Vector2(0, 0)) # on the player s.area_events.clear() s._update_wormholes() assert_eq(s.wormholes.size(), 0, "the wormhole is consumed on contact") assert_eq(s.area_events.size(), 1, "a warp event is emitted") assert_eq(String(s.area_events[0]["dest"]), "aurora")
func test_far_from_wormhole_no_warp() -> void: var s := _sim() s._spawn_area_wormhole(Vector2(9000, 0)) # far away s.area_events.clear() s._update_wormholes() assert_eq(s.wormholes.size(), 1, "not collected from afar") assert_eq(s.area_events.size(), 0, "no warp event")-
Step 2: Run — fails (
wormholes/area_events/_spawn_area_wormholeundefined). Run:godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_wormhole.gd -gexit→ FAIL. -
Step 3: Declare the state + const in
sim/sim.gd(ifwormholeswasn’t added in Task 1):
var wormholes: Array = [] # explorable-areas: {pos, dest}; ≤1 at a time, spawned on a boss killvar area_events: Array = [] # per-tick render signals ({kind:"warp", dest}); NOT in the checksumconst WORMHOLE_RADIUS: float = 40.0 # contact radius to enter (player.radius added in the check)Clear area_events at the top of tick() next to where fx_events is cleared.
- Step 4: Add the spawn + update helpers to
sim/sim.gd:
# Spawn the area-gateway wormhole at a boss's death spot — ≤1 at a time, destination = the other area.# Boss deaths only happen long after the determinism baseline window, so this never runs in it.func _spawn_area_wormhole(pos: Vector2) -> void: if not wormholes.is_empty(): return wormholes.append({"pos": pos, "dest": other_area()})
# Player flies over the wormhole → emit a warp event (render orchestrates the transition + enter_area)# and consume it. Iterates an empty array in the baseline → no-op.func _update_wormholes() -> void: var r := WORMHOLE_RADIUS + player.radius for w in wormholes: if player.pos.distance_squared_to(w["pos"]) <= r * r: area_events.append({"kind": "warp", "dest": String(w["dest"])}) wormholes.clear() return- Step 5: Spawn on boss death in
_sweep_dead. Set a localvar boss_died_at := Vector2.INFwhen any boss-death branch fires (recorddead_pos), and after the sweep loop call once:
if boss_died_at != Vector2.INF: _spawn_area_wormhole(boss_died_at)Call _update_wormholes() once per tick in tick() (after _collect_powerups() / the pickup phase).
-
Step 6: Run the test — passes. Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_wormhole.gd -gexit→ PASS. -
Step 7: Full gates + commit. Boot, count guard, determinism unchanged (wormholes empty + boss-gated;
area_eventsexcluded from the checksum).
git add sim/sim.gd tests/test_wormhole.gdgit commit -m "feat(areas): wormhole entity — boss-kill spawn + player-contact warp event"Task 3: WormholeRenderer + warp transition + aurora background
Section titled “Task 3: WormholeRenderer + warp transition + aurora background”Files:
- Create:
render/wormhole_renderer.gd - Modify:
render/arena_background.gd(addVARIANT_AURORA) - Modify:
main.gd(create the renderer; consumearea_events→ warp freeze →enter_area→ background swap)
Interfaces:
-
Consumes:
Sim.wormholes,Sim.area_events,Sim.enter_area,AreaDefs.get_def(id).background,ArenaBackground.set_variant. -
Produces:
WormholeRenderer.update_wormholes(wormholes: Array);ArenaBackground.VARIANT_AURORA; a render-side warp state inmainthat freezes ticking briefly then switches area + background. -
Step 1: Write
render/wormhole_renderer.gd— aNode2Ddrawing a pulsing swirl per wormhole (additive, animated via_processclock). Render-only.
class_name WormholeRendererextends Node2D
var _wormholes: Array = []var _t := 0.0
func _init() -> void: var mat := CanvasItemMaterial.new() mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD material = mat z_index = 2
func update_wormholes(wormholes: Array) -> void: _wormholes = wormholes queue_redraw()
func _process(dt: float) -> void: if not _wormholes.is_empty(): _t += dt queue_redraw()
func _draw() -> void: for w in _wormholes: var p: Vector2 = w["pos"] var pulse := 0.7 + 0.3 * sin(_t * 3.0) for ring in range(4): var rr := (18.0 + ring * 12.0) * pulse var a := (0.5 - ring * 0.1) draw_arc(p, rr, _t * 1.5 + ring, _t * 1.5 + ring + TAU * 0.8, 24, Color(0.6, 0.4, 1.0, a), 3.0, true) draw_circle(p, 10.0 * pulse, Color(0.9, 0.8, 1.0, 0.9)) # bright core- Step 2: Add
VARIANT_AURORAtorender/arena_background.gd. BumpVARIANT_COUNTaccounting for it being area-selected (NOT in the random Home pool — Home keeps grid/starfield/nebula). Add anauroradraw branch (green/violet aurora bands). Keep aset_variant_by_name(name: String)that maps"aurora"→ the aurora variant and"home"→ a random pick of the existing three.
const VARIANT_AURORA := 3# In set_variant_by_name: "aurora" → set_variant(VARIANT_AURORA); else → random home pool (0..2).Implement the aurora _draw (a few translucent additive sine-wave bands in green→violet, animated by _t). Render-only; no determinism impact.
- Step 3: Wire
main.gd. Createwormhole_renderernext to the other renderers (in_new_runsetup) +add_child. Each frame (wheredecoy_renderer.update_drones(...)is called) addwormhole_renderer.update_wormholes(sim.wormholes). After the per-tick block, consumesim.area_events:
for ev in sim.area_events: if String(ev.get("kind", "")) == "warp": _begin_warp(String(ev["dest"])) sim.area_events.clear()Add the warp orchestration (mirror the RESUME_GRACE freeze pattern): a _warping flag + timer that freezes _physics_process ticking, then on completion calls sim.enter_area(dest) and arena_bg.set_variant_by_name(AreaDefs.get_def(dest)["background"]), then resumes with a brief i-frame grace. Gate _physics_process early-return on _warping (alongside _paused_for_menu).
-
Step 4:
--import(newWormholeRendererclass) + boot-check. Run:godot --headless --path . --importthengodot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"→ empty. -
Step 5: Full suite + count + determinism. All render/main → determinism untouched (the test never enters an area).
bash scripts/check-test-count.sh. -
Step 6: Commit.
git add render/wormhole_renderer.gd render/arena_background.gd main.gdgit commit -m "feat(areas): wormhole renderer + warp transition + aurora background"Task 4: Whole-feature review + deploy
Section titled “Task 4: Whole-feature review + deploy”Files: none (review + deploy).
- Step 1: Whole-feature review. Re-read the Task 1–3 diffs for: determinism parity (mults default exactly 1.0;
enter_area/wormholes never run in the baseline;area_events/wormholesexcluded fromstate_checksum/snapshot_string),/simpurity (AreaDefs is pure; the warp freeze +enter_areacall are render-side), the invisible-entity rule (thearea_events“warp” kind is consumed inmain;wormholeshas theWormholeRenderer), and that two-way works (Aurora boss → return wormhole, dest = home). Optionally dispatch acode-reviewersubagent on the area commit range. Fix any Critical/Important findings. - Step 2: Confirm the determinism baseline is byte-identical (no re-pin expected). If it moved, STOP — a leak into the no-area path.
- Step 3: Deploy (exclusive tvOS lane — coordinate so the other agent holds off; see the deploy-race lesson). Bump
Sim_Const.BUILD, sync the gameplay surface →~/Claude/bullet-heaven-tvos, verify (import/boot/suite/count), export.pck+ xcodebuild Release + devicectl install/launch. Then updatebullet-heaven-roadmap.md+ flag for Chris to playtest (defeat a boss → fly into the wormhole → arrive in Aurora; defeat Aurora’s boss → return wormhole home).
Self-Review
Section titled “Self-Review”- Spec coverage: wormhole after boss kill ✅ (T2); warp transition ~1–1.5s render-side freeze ✅ (T3); arrive in Aurora — new background ✅ (T3) + higher difficulty ✅ (T1) + better rewards ✅ (T1); carry the run ✅ (enter_area touches only area state, not player build/level/HP); two-way return wormhole ✅ (dest =
other_area, T2); data-driven area model ✅ (AreaDefs, T1 — a const table for v1, can migrate tobible.jsonlater as the spec notes); determinism gated ✅ (all tasks). - Type consistency:
current_area/area_difficulty_mult/area_reward_mult/enter_area/other_area(T1) used consistently in T2/T3;wormholesentries{pos, dest}andarea_eventsentries{kind:"warp", dest}consistent across T2/T3;AreaDefs.get_def/othersignatures stable. - Placeholder scan: none — every code step shows full code; run steps show the command + expected result.
- Deferred (noted, per spec “long-term”): the system-map screen, per-area collectibles, bespoke Aurora enemies, and migrating
AreaDefsintobible.jsonare explicitly out of v1.