Skip to content

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 = one bh-dev-chunk cycle (TDD → --import if 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).

  • Determinism byte-identical — survival 1405185210/3122397125, crystals 91572468/1173256610 (pinned in tests/test_determinism_checksum.gd + tests/test_determinism_crystals.gd). Everything here is opt-in: area mults default to exactly 1.0 (IEEE identities), enter_area is never called in the baseline, and Sim.wormholes is empty + spawns only on a boss kill (run_time ≫ the <10s baseline window). New columns/arrays/fields MUST stay out of state_checksum()/snapshot_string(). Re-check each task; a moved baseline = a leak → STOP, don’t re-pin.
  • /sim purity preserved. Render/UI only READ sim state. The warp transition freezes the sim render-side (mirrors the existing RESUME_GRACE level-up freeze).
  • All tunables (difficulty/reward mults, wormhole radius, warp duration) are consts/data — retune later from playtest.
  • Create sim/area_defs.gdAreaDefs: 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; wormholes array + boss-kill spawn + _update_wormholes (player-overlap → area_transition event).
  • Create render/wormhole_renderer.gdWormholeRenderer: pulsing swirl at each wormhole pos.
  • Modify render/arena_background.gd — add VARIANT_AURORA.
  • Modify main.gd — create the wormhole renderer; consume the area_transition event → 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_one difficulty; gold/xp reward mult)
  • Test: tests/test_areas.gd

Interfaces:

  • Produces: AreaDefs.HOME := "home", AreaDefs.AURORA := "aurora", AreaDefs.get_def(id: String) -> Dictionary (keys name,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 AreaDefs
extends 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_area undefined): Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_areas.gd -gexit Expected: FAIL — s.current_area not defined.

  • Step 4: Add the area fields to sim/sim.gd (near other run-state vars, e.g. by var 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_area to sim/sim.gd. Reuse the existing field-clear helper dev_clear_enemies() (drains enemies/enemy_proj + clears bombs/missiles/zones/webs/funzones/powerups); reset the wave phase to PHASE_WAVES via _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_mult into _spawn_one. Change the difficulty line (currently var 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_mult into the XP + gold awards. At the XP award (player.xp += amount):
player.xp += amount * area_reward_mult

At each boss/kill gold line, scale by the mult, e.g. run_gold += GOLD_PER_KILLrun_gold += int(round(float(GOLD_PER_KILL) * area_reward_mult)) and run_gold += BOSS_GOLDrun_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 (new AreaDefs class). 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.

Terminal window
git add sim/area_defs.gd sim/sim.gd tests/test_areas.gd
git 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 (wormholes array + consts; spawn on boss death in _sweep_dead; _update_wormholes; area_transition event)
  • 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 — like fx_events), WORMHOLE_RADIUS. A boss death spawns ≤1 wormhole; player overlap appends {"kind":"warp","dest":<id>} to area_events and clears wormholes.

  • 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_wormhole undefined). 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 (if wormholes wasn’t added in Task 1):

var wormholes: Array = [] # explorable-areas: {pos, dest}; ≤1 at a time, spawned on a boss kill
var area_events: Array = [] # per-tick render signals ({kind:"warp", dest}); NOT in the checksum
const 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 local var boss_died_at := Vector2.INF when any boss-death branch fires (record dead_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_events excluded from the checksum).

Terminal window
git add sim/sim.gd tests/test_wormhole.gd
git 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 (add VARIANT_AURORA)
  • Modify: main.gd (create the renderer; consume area_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 in main that freezes ticking briefly then switches area + background.

  • Step 1: Write render/wormhole_renderer.gd — a Node2D drawing a pulsing swirl per wormhole (additive, animated via _process clock). Render-only.

class_name WormholeRenderer
extends 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_AURORA to render/arena_background.gd. Bump VARIANT_COUNT accounting for it being area-selected (NOT in the random Home pool — Home keeps grid/starfield/nebula). Add an aurora draw branch (green/violet aurora bands). Keep a set_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. Create wormhole_renderer next to the other renderers (in _new_run setup) + add_child. Each frame (where decoy_renderer.update_drones(...) is called) add wormhole_renderer.update_wormholes(sim.wormholes). After the per-tick block, consume sim.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 (new WormholeRenderer class) + boot-check. Run: godot --headless --path . --import then godot --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.

Terminal window
git add render/wormhole_renderer.gd render/arena_background.gd main.gd
git commit -m "feat(areas): wormhole renderer + warp transition + aurora background"

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/wormholes excluded from state_checksum/snapshot_string), /sim purity (AreaDefs is pure; the warp freeze + enter_area call are render-side), the invisible-entity rule (the area_events “warp” kind is consumed in main; wormholes has the WormholeRenderer), and that two-way works (Aurora boss → return wormhole, dest = home). Optionally dispatch a code-reviewer subagent 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 update bullet-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).

  • 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 to bible.json later 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; wormholes entries {pos, dest} and area_events entries {kind:"warp", dest} consistent across T2/T3; AreaDefs.get_def/other signatures 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 AreaDefs into bible.json are explicitly out of v1.