Elemental Dimensions Implementation Plan
Elemental Dimensions Implementation Plan
Section titled “Elemental Dimensions 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: Evolve the existing, currently-locked area/wormhole system into 3 simultaneous elemental-dimension portals that open after a boss kill, each on-theme (enemies, background, boss, environmental hazard), then unlock the whole system for real play.
Architecture: AreaDefs gains 3 Dimension entries carrying an element + which
existing boss archetype represents it + which existing enemy types are on-theme for it.
Sim._spawn_area_wormhole opens one wormhole per Dimension instead of one total;
Sim.portals_open pauses weapon/drone ticking while any are open. Enemy spawning
(SpawnTable.pick) and boss spawning (Graviton/FunZo/Eye – the actual live
“sole boss” pool _spawn_phase_boss rotates through, whose element was previously
hardcoded) both gain a dimension-aware filter/parameter. No new enemy or boss
types are authored — every Dimension reuses existing content, re-themed.
Tech Stack: Godot 4.6.3 GDScript, GUT 9.6.0 for tests.
Global Constraints
Section titled “Global Constraints”- Every new spawn/rng path here is reachable only after a boss death — this never
happens inside the pinned determinism baseline window (a fixed 600-tick/seed-1234 run
that never reaches a boss). Re-verify
tests/test_determinism_checksum.gd+tests/test_determinism_crystals.gdafter every task; the baseline (snapshot_string().hash()=2730172591,state_checksum()=4075578713) must hold. SpawnTable.pick’s existing invariant — always draw exactly onerng.randf()per call, even when the filtered candidate set is empty — must be preserved by any change to it (this is what stops an all-locked/all-filtered window from desyncing the rng stream; see the existing comment atsim/spawn_table.gd’spick()).- Full suite currently: 189 scripts / 1347+ tests (grows as this plan adds tests), all
green. Run
bash scripts/check-test-count.shafter every task. - Boot-check after every task:
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"must be empty. - No new
EnemyPool.TYPE_*, no new bossclass_name— every Dimension’s content reuses existing types (see the spec’s Decision 1/2 tables). docs/superpowers/specs/2026-07-02-elemental-dimensions-design.mdis the source spec; read it for the full reasoning behind every naming/scoping call below.
Naming correction from the spec (fix applied here, not yet in the spec file)
Section titled “Naming correction from the spec (fix applied here, not yet in the spec file)”The spec’s first draft named the fire dimension “Ember” — but AreaDefs.EMBER_REACH
already exists (id ember_reach, display name “Nebula”, NOT fire-themed) and the
near-collision would confuse future readers. This plan uses Pyre (fire), Null
(void, id null_dim — avoiding the bare word null as an identifier), and Drift
(aether) instead. Task 1’s Step 7 amends the spec file to match before implementation
starts, so the spec and the code never disagree.
Task 1: AreaDefs — 3 Dimension entries + lookup helpers
Section titled “Task 1: AreaDefs — 3 Dimension entries + lookup helpers”Files:
- Modify:
sim/area_defs.gd - Test:
tests/test_areas.gd
Interfaces:
-
Produces:
AreaDefs.PYRE := "pyre",AreaDefs.NULL_DIM := "null_dim",AreaDefs.DRIFT := "drift",AreaDefs.DIMENSION_IDS: Array[String](exactly these 3, in this order),AreaDefs.is_dimension(id: String) -> bool,AreaDefs.element_for(id: String) -> String(bible element id,""for a non-Dimension area),AreaDefs.boss_for(id: String) -> String("graviton"/"funzo"/"eye",""for non-Dimension),AreaDefs.enemy_types_for(id: String) -> Array(anArrayofEnemyPool.TYPE_*ints,[]for non-Dimension). -
Consumes: nothing from other tasks (pure data, first task).
-
Step 1: Write the failing tests
Add to tests/test_areas.gd:
func test_three_dimension_ids_exist_and_are_distinct_from_existing_areas() -> void: assert_eq(AreaDefs.DIMENSION_IDS, [AreaDefs.PYRE, AreaDefs.NULL_DIM, AreaDefs.DRIFT]) for id in AreaDefs.DIMENSION_IDS: assert_false(id in [AreaDefs.HOME, AreaDefs.AURORA, AreaDefs.EMBER_REACH], "a Dimension id must not collide with an existing area id")
func test_is_dimension() -> void: for id in AreaDefs.DIMENSION_IDS: assert_true(AreaDefs.is_dimension(id), "%s should be a Dimension" % id) for id in [AreaDefs.HOME, AreaDefs.AURORA, AreaDefs.EMBER_REACH]: assert_false(AreaDefs.is_dimension(id), "%s is not a Dimension" % id)
func test_dimension_elements() -> void: assert_eq(AreaDefs.element_for(AreaDefs.PYRE), "fire") assert_eq(AreaDefs.element_for(AreaDefs.NULL_DIM), "void") assert_eq(AreaDefs.element_for(AreaDefs.DRIFT), "aether") assert_eq(AreaDefs.element_for(AreaDefs.HOME), "", "non-Dimension areas have no element")
func test_dimension_bosses() -> void: assert_eq(AreaDefs.boss_for(AreaDefs.PYRE), "graviton") assert_eq(AreaDefs.boss_for(AreaDefs.NULL_DIM), "funzo") assert_eq(AreaDefs.boss_for(AreaDefs.DRIFT), "eye") assert_eq(AreaDefs.boss_for(AreaDefs.HOME), "")
func test_dimension_enemy_types_are_all_on_theme() -> void: assert_eq(AreaDefs.enemy_types_for(AreaDefs.PYRE), [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_TANK, EnemyPool.TYPE_BOMBER, EnemyPool.TYPE_ACCUMULATOR]) assert_eq(AreaDefs.enemy_types_for(AreaDefs.NULL_DIM), [EnemyPool.TYPE_ELITE, EnemyPool.TYPE_GHOST]) assert_eq(AreaDefs.enemy_types_for(AreaDefs.DRIFT), [EnemyPool.TYPE_SKIRMISHER, EnemyPool.TYPE_RUSHER]) assert_eq(AreaDefs.enemy_types_for(AreaDefs.HOME), [])(TYPE_TANK_MISSILE is deliberately NOT in Pyre’s list — it’s fired by a live
TYPE_TANK’s own ranged attack, never picked directly by SpawnTable/SpawnDirector,
so it doesn’t belong in a type-selection allow-list.)
- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_areas.gd -gexit
Expected: FAIL — AreaDefs.PYRE/DIMENSION_IDS/etc. don’t exist yet.
- Step 3: Add the Dimension entries and constants
Replace sim/area_defs.gd’s _DEFS and cycle-related section (the whole file except the
class header comment) with:
class_name AreaDefsextends RefCounted
# Light, pure area table for explorable areas. An area is a difficulty + reward + backdrop# applied to the SAME enemy roster (no bespoke enemies). A DIMENSION (see below) additionally# restricts the roster to on-theme types and assigns an on-theme boss. Lives in /sim (pure# data — no Node/Engine/File APIs); `background` is a name string the render side maps to an# ArenaBackground variant (so /sim never touches rendering).const HOME := "home"const AURORA := "aurora"const EMBER_REACH := "ember_reach" # display name "Nebula" — id kept distinct from the unrelated # ArenaBackground VARIANT_NEBULA (soft glow clouds, Home pool)
# Elemental Dimensions (2026-07-02/03): reached via the 3-portal choice after a boss kill,# NOT via the plain other()-cycle above. Each reuses an EXISTING boss archetype re-themed# to the dimension's element (Graviton/Warden's element was previously hardcoded to# "void"; Eye already reads its element from a Sim field) and an EXISTING subset of the# enemy roster whose bible.json base_element already matches — no new content authored.const PYRE := "pyre" # fireconst NULL_DIM := "null_dim" # void — "null_dim" avoids the bare identifier `null`const DRIFT := "drift" # aether
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"}, "ember_reach": {"name": "Nebula", "difficulty_mult": 1.3, "reward_mult": 1.25, "background": "ember_reach"}, "pyre": { "name": "Pyre", "difficulty_mult": 1.4, "reward_mult": 1.4, "background": "aurora", "element": "fire", "boss": "graviton", "enemy_types": [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_TANK, EnemyPool.TYPE_BOMBER, EnemyPool.TYPE_ACCUMULATOR], }, "null_dim": { "name": "Null", "difficulty_mult": 1.5, "reward_mult": 1.45, "background": "ember_reach", "element": "void", "boss": "funzo", "enemy_types": [EnemyPool.TYPE_ELITE, EnemyPool.TYPE_GHOST], }, "drift": { "name": "Drift", "difficulty_mult": 1.45, "reward_mult": 1.4, "background": "home", "element": "aether", "boss": "eye", "enemy_types": [EnemyPool.TYPE_SKIRMISHER, EnemyPool.TYPE_RUSHER], },}
# Wormhole-destination order for the plain other()-cycle (Home/Aurora/Nebula only —# Dimensions are reached exclusively via the 3-portal boss-kill choice, never this cycle).const _CYCLE := [HOME, AURORA, EMBER_REACH]
# The 3 Dimensions a boss-kill portal choice always offers (extend this array, and add a# matching _DEFS entry, when a 4th+ Dimension lands — same extension pattern as _CYCLE).const DIMENSION_IDS: Array[String] = [PYRE, NULL_DIM, DRIFT]
static func get_def(id: String) -> Dictionary: return _DEFS.get(id, _DEFS["home"])
static func is_dimension(id: String) -> bool: return id in DIMENSION_IDS
static func element_for(id: String) -> String: return String(get_def(id).get("element", ""))
static func boss_for(id: String) -> String: return String(get_def(id).get("boss", ""))
static func enemy_types_for(id: String) -> Array: return get_def(id).get("enemy_types", [])
# Deterministic wormhole-destination cycle: Home -> Aurora -> Ember Reach -> Home -> ...# An unrecognized id falls back to Home's successor (Aurora). Unrelated to Dimensions.static func other(id: String) -> String: var idx := _CYCLE.find(id) if idx == -1: idx = 0 return _CYCLE[(idx + 1) % _CYCLE.size()](Reused existing background variant names for the 3 Dimensions rather than inventing 3
more ArenaBackground variants sight-unseen overnight — Pyre borrows “aurora”’s warmer
palette, Null borrows “ember_reach”’s starker one, Drift stays on “home”. This is a
pragmatic placeholder Chris can retune the moment he sees it live; flag it as such in the
final summary.)
- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_areas.gd -gexit
Expected: PASS.
- Step 5: Run the full suite, determinism, and boot check
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"- Step 6: Grep
tests/test_wormhole.gdfor any 2-way-assumption regression
This repo’s own CLAUDE.md flags that a prior 2-way→N-way generalization of
AreaDefs.other() broke an assumption in tests/test_wormhole.gd that wasn’t caught
until a full-suite run. Run grep -n "other(\|_CYCLE\|DIMENSION" tests/test_wormhole.gd
now — if it asserts anything about exactly 2 or 3 areas total (not Dimensions
specifically, since Dimensions didn’t exist before this task), confirm the full-suite run
in Step 5 still passed; if it didn’t, fix the test’s assumption before moving on.
- Step 7: Amend the spec’s naming, and commit
Open docs/superpowers/specs/2026-07-02-elemental-dimensions-design.md and replace every
occurrence of “Ember” (the dimension name) with “Pyre”, and “Null”/null_dim naming
stays as already written there (it already used null_dim as the id). Add one line
under “Decision 2” noting: “Renamed Ember → Pyre during plan-writing to avoid confusion
with the pre-existing, unrelated ember_reach/‘Nebula’ area.”
git add sim/area_defs.gd tests/test_areas.gd docs/superpowers/specs/2026-07-02-elemental-dimensions-design.mdgit commit -m "feat(areas): 3 elemental Dimension defs (Pyre/Null/Drift)
Pure data: each Dimension pairs a bible.json element with an existingboss archetype (re-themed, not new) and the subset of the existingenemy roster that already carries that element. Renamed the firedimension Ember -> Pyre to avoid confusion with the pre-existingunrelated ember_reach/Nebula area."Task 2: Dimension-aware enemy spawning
Section titled “Task 2: Dimension-aware enemy spawning”Files:
- Modify:
sim/spawn_table.gd,sim/sim.gd - Test:
tests/test_spawn_rework.gd(or whereverSpawnTable.pickis currently tested — grepSpawnTable.pick(acrosstests/to find the right file; add tests there)
Interfaces:
-
Consumes:
AreaDefs.is_dimension/enemy_types_for(Task 1). -
Produces:
SpawnTable.pick(t: float, rng: SeededRng, wave: int = 9999, allowed: Dictionary = {}) -> int(new 4th param, empty = no restriction, back-compat for every existing call site that doesn’t pass it),Sim._dimension_allowed_types() -> Dictionary(a{type_id: true}set for the current Dimension,{}ifcurrent_areaisn’t one). -
Step 1: Write the failing tests
Find the test file covering SpawnTable.pick (grep -rn "SpawnTable.pick(" tests/) and
add:
func test_pick_restricted_to_an_allowed_set_never_returns_outside_it() -> void: var rng := SeededRng.new(1234) var allowed := {EnemyPool.TYPE_SWARMER: true, EnemyPool.TYPE_TANK: true} for _i in range(200): var tid := SpawnTable.pick(150.0, rng, 9999, allowed) assert_true(allowed.has(tid), "picked type %d must be in the allowed set" % tid)
func test_pick_still_draws_exactly_one_rng_value_when_allowed_excludes_everything() -> void: var rng_a := SeededRng.new(5) var rng_b := SeededRng.new(5) SpawnTable.pick(60.0, rng_a, 9999, {}) # no restriction, one draw SpawnTable.pick(60.0, rng_b, 9999, {-999: true}) # restricted to a type that can't occur # Both rngs must have advanced by exactly one draw -- compare their next value. assert_eq(rng_a.randf(), rng_b.randf(), "both paths draw exactly one rng.randf() internally")
func test_pick_unrestricted_default_matches_prior_behavior() -> void: var rng_a := SeededRng.new(42) var rng_b := SeededRng.new(42) var a := SpawnTable.pick(90.0, rng_a, 9999) var b := SpawnTable.pick(90.0, rng_b, 9999, {}) assert_eq(a, b, "omitting `allowed` must behave identically to passing an empty dict")Add to tests/test_sim.gd (or tests/test_spawn_rework.gd if Sim unit tests for
spawning already live there — grep class_name Sim usage in both to pick the right one):
func test_dimension_allowed_types_empty_outside_a_dimension() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) assert_eq(sim._dimension_allowed_types(), {}, "home is not a Dimension -> no restriction")
func test_dimension_allowed_types_matches_pyre() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.PYRE) var allowed := sim._dimension_allowed_types() for tid in AreaDefs.enemy_types_for(AreaDefs.PYRE): assert_true(allowed.has(tid)) assert_eq(allowed.size(), AreaDefs.enemy_types_for(AreaDefs.PYRE).size())- Step 2: Run tests to verify they fail
Run the two GUT test files above with -gtest=.... Expected: FAIL — allowed param and
_dimension_allowed_types don’t exist yet.
- Step 3: Add the
allowedfilter toSpawnTable.pick
In sim/spawn_table.gd, replace:
static func pick(t: float, rng: SeededRng, wave: int = 9999) -> int: var w := weights_at(t) var unlocked := types_unlocked(wave) var fw: Dictionary = {} for k in w: if unlocked.has(int(k)): fw[k] = w[k]with:
static func pick(t: float, rng: SeededRng, wave: int = 9999, allowed: Dictionary = {}) -> int: var w := weights_at(t) var unlocked := types_unlocked(wave) var fw: Dictionary = {} for k in w: if not unlocked.has(int(k)): continue if not allowed.is_empty() and not allowed.has(int(k)): continue # Dimension restriction: only on-theme types may be picked here fw[k] = w[k](The rest of the function — the single rng.randf() draw, the weighted-roll loop, the
EnemyPool.TYPE_SWARMER fallback when fw is empty — is unchanged, which is exactly
what preserves the “always exactly one draw” invariant Step 1’s second test checks.)
- Step 4: Add
Sim._dimension_allowed_types()and wire it into_spawn_wave
In sim/sim.gd, add near enter_area/other_area (around line 969):
# {type_id: true} for the current Dimension's on-theme roster, or {} outside a Dimension# (meaning "no restriction" to SpawnTable.pick — every existing area behaves unchanged).func _dimension_allowed_types() -> Dictionary: if not AreaDefs.is_dimension(current_area): return {} var out: Dictionary = {} for tid in AreaDefs.enemy_types_for(current_area): out[int(tid)] = true return outFind _spawn_wave()’s call to SpawnTable.pick (currently
_spawn_one(SpawnTable.pick(run_time, rng, wave_number), _spawn_point())) and change it
to:
_spawn_one(SpawnTable.pick(run_time, rng, wave_number, _dimension_allowed_types()), _spawn_point())- Step 5: Deterministically remap
pick_type/pick_boss_add_typeresults too
_spawn_boss_adds/_spawn_swarm_burst use spawner.pick_type/pick_boss_add_type
(a different, simpler time-based selector, not SpawnTable) — these must ALSO stay
on-theme inside a Dimension, but without an extra rng draw (which would desync the
stream vs. the non-Dimension path). Add, near _dimension_allowed_types:
# If tid isn't on-theme for the current Dimension, deterministically remap it into the# allowed set via modulo (NO extra rng draw -- the input tid already came from one).# No-op (returns tid unchanged) outside a Dimension.func _remap_to_dimension(tid: int) -> int: var allowed := _dimension_allowed_types() if allowed.is_empty() or allowed.has(tid): return tid var types := allowed.keys() types.sort() return int(types[tid % types.size()])In _spawn_boss_adds, change var tid := spawner.pick_boss_add_type(rng) to
var tid := _remap_to_dimension(spawner.pick_boss_add_type(rng)). In
_spawn_swarm_burst, change var tid := spawner.pick_type(run_time, rng) to
var tid := _remap_to_dimension(spawner.pick_type(run_time, rng)).
- Step 6: Run all new/changed tests to verify they pass
Run the SpawnTable.pick test file and tests/test_sim.gd (or wherever Task’s Step 1
tests landed) with -gtest=.... Expected: PASS.
- Step 7: Run the full suite, determinism, and boot check
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexitgodot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"- Step 8: Commit
git add sim/spawn_table.gd sim/sim.gd tests/git commit -m "feat(areas): restrict enemy spawning to on-theme types inside a Dimension
SpawnTable.pick gains an optional allowed-set filter (empty = unrestricted,back-compat); the two non-SpawnTable spawn paths (_spawn_boss_adds,_spawn_swarm_burst) deterministically remap an off-theme pick into theDimension's roster via modulo instead of drawing an extra rng value, sothe determinism baseline is unaffected either way."Task 3: Dimension-themed boss — parameterized element + dispatch
Section titled “Task 3: Dimension-themed boss — parameterized element + dispatch”⚠️ CORRECTED 2026-07-03, before dispatch — read this before anything else in this
task. The version of this task originally written targeted
sim/boss_rotation.gd’s maybe_spawn_survival_boss (a 5-way Warden/Boss2/FunZo/
Graviton/Eye rotation) as the live boss-spawn trigger. Verified via exhaustive grep
(grep -rn "maybe_spawn_survival_boss" sim/) that this function has zero call sites
anywhere in the current codebase — it’s dead code. The REAL live “one boss, arena
clears, portal should open after” mechanic is Sim._spawn_phase_boss() (sim/sim.gd,
called from _update_spawn_phase’s PHASE_BOSS_PREP → PHASE_BOSS transition), which
rotates only FunZo → Graviton → Eye via _boss_gate_count % 3. Warden and Boss2 are
mid-wave high-biomass “elite” enemies now (_spawn_due_elites, fixed spawn times,
coexist with the swarm), not part of this “sole boss” pool at all. Task 1 has already
been corrected (commit 0cbf062 in this worktree) — AreaDefs.boss_for(AreaDefs.NULL_DIM)
is now "funzo", not "warden". This task’s steps below reflect the corrected design:
Graviton and FunZo get the element_idx override param (not Warden), and the
dispatch hooks into _spawn_phase_boss() directly, not a BossRotation method.
Files:
- Modify:
sim/graviton.gd,sim/funzo.gd,sim/sim.gd - Test:
tests/test_boss_gate.gd(already tests_spawn_phase_boss/_boss_gate_countdirectly — extend it, don’t create a new file)
Interfaces:
-
Consumes:
AreaDefs.is_dimension/boss_for/element_for(Task 1, corrected). -
Produces:
Graviton.spawn(sim, pos, hp_mult=1.0, element_idx=-1),FunZo.spawn(sim, pos, hp_mult=1.0, element_idx=-1)(both:-1= old hardcoded element, unchanged for every existing call site)._spawn_phase_boss()keeps its existing signature (() -> void) — its BODY branches on whethercurrent_areais a Dimension. -
Step 1: Write the failing tests
Add to tests/test_boss_gate.gd (matching its existing _content()/Sim.new(1, _content())
style — do not use ContentLoader.load_from_path, this file’s own pattern is simpler and
already proven):
func test_graviton_spawns_with_an_overridden_element() -> void: var s := Sim.new(1, _content()) var fire_idx := _content().element_index("fire") s.graviton_director.spawn(s, Vector2.ZERO, 1.0, fire_idx) var i := s.boss_rotation.graviton_index(s) assert_eq(s.enemies.aura_element[i], fire_idx, "element override took effect")
func test_graviton_default_element_unchanged() -> void: var s := Sim.new(1, _content()) s.graviton_director.spawn(s, Vector2.ZERO) # no override -> old void default var i := s.boss_rotation.graviton_index(s) assert_eq(s.enemies.aura_element[i], _content().element_index("void"))
func test_funzo_spawns_with_an_overridden_element() -> void: var s := Sim.new(1, _content()) var void_idx := _content().element_index("void") s.funzo_director.spawn(s, Vector2.ZERO, 1.0, void_idx) var i := s.boss_rotation.funzo_index(s) assert_eq(s.enemies.aura_element[i], void_idx, "element override took effect")
func test_funzo_default_element_unchanged() -> void: var s := Sim.new(1, _content()) s.funzo_director.spawn(s, Vector2.ZERO) # no override -> old psychic default var i := s.boss_rotation.funzo_index(s) assert_eq(s.enemies.aura_element[i], _content().element_index("psychic"))
func test_spawn_phase_boss_spawns_the_dimension_owned_boss_inside_a_dimension() -> void: var s := Sim.new(1, _content()) s.enter_area(AreaDefs.PYRE) s._spawn_phase_boss() assert_gte(s.boss_rotation.graviton_index(s), 0, "Pyre's boss is Graviton") var i := s.boss_rotation.graviton_index(s) assert_eq(s.enemies.aura_element[i], _content().element_index("fire"), "Pyre's Graviton is fire-elemental, not the default void")
func test_spawn_phase_boss_inside_a_dimension_never_advances_the_generic_gate_count() -> void: var s := Sim.new(1, _content()) s.enter_area(AreaDefs.NULL_DIM) var before := s._boss_gate_count s._spawn_phase_boss() assert_eq(s._boss_gate_count, before, "a Dimension always spawns its OWN boss -- the generic rotation counter is untouched")
func test_spawn_phase_boss_outside_a_dimension_is_unchanged() -> void: var s := Sim.new(1, _content()) s._boss_gate_count = 0 s._spawn_phase_boss() assert_gte(s.boss_rotation.funzo_index(s), 0, "home still gets the ordinary FunZo-first rotation") assert_eq(s._boss_gate_count, 1, "the generic counter still advances outside a Dimension")- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_boss_gate.gd -gexit
Expected: FAIL — the new 3rd param on Graviton.spawn/FunZo.spawn and the Dimension
branch in _spawn_phase_boss don’t exist yet.
- Step 3: Parameterize
Graviton.spawn
In sim/graviton.gd, replace:
func spawn(sim: Sim, pos: Vector2, hp_mult: float = 1.0) -> void: var hp := GRAVITON_HP * hp_mult sim.enemies.add(pos, Vector2.ZERO, GRAVITON_RADIUS, hp, GRAVITON_ARMOR, GRAVITON_SPEED, GRAVITON_CONTACT_DMG, GRAVITON_XP, EnemyPool.TYPE_GRAVITON, sim.content.element_index("void"), EnemyPool.BEHAVIOR_BOSS)with:
# element_idx: -1 (default) keeps the original hardcoded "void" identity for every# existing call site (generic survival rotation); a Dimension boss dispatch passes its# own element instead so the SAME attack pattern re-themes per Dimension.func spawn(sim: Sim, pos: Vector2, hp_mult: float = 1.0, element_idx: int = -1) -> void: var hp := GRAVITON_HP * hp_mult var el := element_idx if element_idx >= 0 else sim.content.element_index("void") sim.enemies.add(pos, Vector2.ZERO, GRAVITON_RADIUS, hp, GRAVITON_ARMOR, GRAVITON_SPEED, GRAVITON_CONTACT_DMG, GRAVITON_XP, EnemyPool.TYPE_GRAVITON, el, EnemyPool.BEHAVIOR_BOSS)- Step 4: Parameterize
FunZo.spawn
In sim/funzo.gd, replace:
func spawn(sim: Sim, pos: Vector2, hp_mult: float = 1.0) -> void: var hp := FUNZO_HP * hp_mult # Spawn at the FULL-HP (half) radius so there's no one-frame size pop before update(). sim.enemies.add(pos, Vector2.ZERO, FUNZO_RADIUS * FUNZO_START_RAD_MULT, hp, FUNZO_ARMOR, FUNZO_SPEED, FUNZO_CONTACT_DMG, FUNZO_XP, EnemyPool.TYPE_FUNZO, sim.content.element_index("psychic"), EnemyPool.BEHAVIOR_BOSS)with:
func spawn(sim: Sim, pos: Vector2, hp_mult: float = 1.0, element_idx: int = -1) -> void: var hp := FUNZO_HP * hp_mult var el := element_idx if element_idx >= 0 else sim.content.element_index("psychic") # Spawn at the FULL-HP (half) radius so there's no one-frame size pop before update(). sim.enemies.add(pos, Vector2.ZERO, FUNZO_RADIUS * FUNZO_START_RAD_MULT, hp, FUNZO_ARMOR, FUNZO_SPEED, FUNZO_CONTACT_DMG, FUNZO_XP, EnemyPool.TYPE_FUNZO, el, EnemyPool.BEHAVIOR_BOSS)(Eye needs NO change — it already reads sim.eye_element_idx rather than a hardcoded
literal; Step 5 sets that field before dispatch instead, exactly like the generic path
already implicitly relies on whatever eye_element_idx currently holds.)
- Step 5: Branch
_spawn_phase_boss()on whethercurrent_areais a Dimension
In sim/sim.gd, replace:
func _spawn_phase_boss() -> void: var pos := _nearest_pilot(Vector2.ZERO).pos + rng.rand_unit_dir() * 640.0 match _boss_gate_count % 3: 0: funzo_director.spawn(self, pos) 1: graviton_director.spawn(self, pos) 2: eye_director.spawn(self, pos) _boss_gate_count += 1with:
func _spawn_phase_boss() -> void: var pos := _nearest_pilot(Vector2.ZERO).pos + rng.rand_unit_dir() * 640.0 if AreaDefs.is_dimension(current_area): # A Dimension always fights its OWN on-theme boss -- no rotation needed, and the # generic _boss_gate_count is deliberately left untouched so leaving the Dimension # (back to Home/Aurora/Nebula) resumes the ordinary rotation exactly where it left off. var element_idx := content.element_index(AreaDefs.element_for(current_area)) match AreaDefs.boss_for(current_area): "graviton": graviton_director.spawn(self, pos, 1.0, element_idx) "funzo": funzo_director.spawn(self, pos, 1.0, element_idx) "eye": eye_element_idx = element_idx eye_director.spawn(self, pos) return match _boss_gate_count % 3: 0: funzo_director.spawn(self, pos) 1: graviton_director.spawn(self, pos) 2: eye_director.spawn(self, pos) _boss_gate_count += 1- Step 6: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_boss_gate.gd -gexit
Expected: PASS.
- Step 7: Run the full suite, determinism, and boot check
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexitgodot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"- Step 8: Commit
git add sim/graviton.gd sim/funzo.gd sim/sim.gd tests/test_boss_gate.gdgit commit -m "feat(areas): Dimension-themed boss dispatch via the real boss-gate mechanic
Graviton/FunZo's previously-hardcoded element becomes an optionaloverride param (default unchanged for every existing call site); Eyealready read its element from a Sim field so gets no signature change._spawn_phase_boss() -- the actual live 'one boss, arena clears' trigger(_boss_gate_count % 3 rotation) -- now branches: inside a Dimension italways spawns that Dimension's own on-theme boss instead of rotating,leaving _boss_gate_count untouched so the generic rotation resumescorrectly on return to a non-Dimension area."Task 4: Three simultaneous portals + weapons/drones pause while open
Section titled “Task 4: Three simultaneous portals + weapons/drones pause while open”Files:
- Modify:
sim/sim.gd - Test:
tests/test_wormhole.gd
Interfaces:
-
Consumes:
AreaDefs.DIMENSION_IDS/element_for(Task 1). -
Produces:
Sim.portals_open: bool,Sim.PORTAL_LIFETIME: float(const),Sim._portal_timer: float(internal), eachwormholesentry now carriespos,dest,element(bible element idx),name(display string). -
Step 1: Write the failing tests
Add to tests/test_wormhole.gd:
func test_boss_death_opens_three_portals_one_per_dimension() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim._spawn_area_wormhole(Vector2.ZERO) assert_eq(sim.wormholes.size(), 3) var dests: Array = [] for w in sim.wormholes: dests.append(String(w["dest"])) assert_true(w.has("element") and w.has("name"), "each portal carries element+name for the renderer") dests.sort() var expected: Array = AreaDefs.DIMENSION_IDS.duplicate() expected.sort() assert_eq(dests, expected)
func test_portals_open_flag_and_weapon_pause() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) assert_false(sim.portals_open) sim._spawn_area_wormhole(Vector2.ZERO) assert_true(sim.portals_open, "opening portals pauses weapons/drones")
func test_entering_any_portal_closes_the_others_and_clears_portals_open() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim._spawn_area_wormhole(Vector2.ZERO) sim.enter_area(String(sim.wormholes[0]["dest"])) assert_true(sim.wormholes.is_empty()) assert_false(sim.portals_open)
func test_portals_auto_expire_after_their_lifetime_if_ignored() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim._spawn_area_wormhole(Vector2.ZERO) sim._portal_timer = 0.001 # about to expire sim._update_wormholes() # this tick's countdown check assert_true(sim.wormholes.is_empty(), "ignored portals eventually close on their own") assert_false(sim.portals_open, "weapons resume once portals expire, not just on travel")
func test_weapons_do_not_update_while_portals_open() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim._spawn_area_wormhole(Vector2.ZERO) var wid: String = sim.active_weapon_ids[0] var before_cd: float = sim._weapon_by_id[wid].cooldown if sim._weapon_by_id[wid].get("cooldown") != null else 0.0 sim.tick_single(InputState.new()) # The exact assertion here depends on the weapon's own field names -- the important # invariant is that _fire_aim/the active_weapon_ids update loop are skipped entirely. # A simpler, robust check: no projectile/damage-dealing side effect occurred. assert_eq(sim.dmg_dealt_total, 0.0, "no weapon fired while portals were open")(The last test’s exact weapon-internals check is intentionally loose — different weapons
expose different internal cooldown fields. dmg_dealt_total == 0.0 after one tick with
no enemies present and portals open is the robust, implementation-agnostic signal that
nothing fired.)
- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_wormhole.gd -gexit
Expected: FAIL — 3-portal spawn, portals_open, _portal_timer don’t exist yet.
- Step 3: Add
portals_open/PORTAL_LIFETIME/_portal_timerfields
In sim/sim.gd, near the existing var wormholes: Array = [] (around line 240), add:
var portals_open: bool = false # true from the moment a boss's 3 Dimension portals spawn # until the player enters one or they time out -- pauses # weapon/drone ticking so an active buff can't cause an # accidental portal entry (Chris's stated concern).const PORTAL_LIFETIME: float = 45.0var _portal_timer: float = 0.0- Step 4: Spawn 3 portals instead of 1
Replace _spawn_area_wormhole (currently a single-slot gate):
func _spawn_area_wormhole(pos: Vector2) -> void: if not areas_enabled: return # v0.1: the explorable-areas system is gated off if not wormholes.is_empty(): returnwith:
const PORTAL_SPREAD_RADIUS: float = 140.0
func _spawn_area_wormhole(pos: Vector2) -> void: if not areas_enabled: return # v0.1: the explorable-areas system is gated off if not wormholes.is_empty(): return var n := AreaDefs.DIMENSION_IDS.size() for i in range(n): var dest: String = AreaDefs.DIMENSION_IDS[i] var angle := TAU * float(i) / float(n) var portal_pos := pos + Vector2(cos(angle), sin(angle)) * PORTAL_SPREAD_RADIUS wormholes.append({ "pos": portal_pos, "dest": dest, "element": content.element_index(AreaDefs.element_for(dest)), "name": String(AreaDefs.get_def(dest)["name"]), }) portals_open = true _portal_timer = PORTAL_LIFETIME- Step 5: Auto-expire ignored portals + clear
portals_openon travel
Replace _update_wormholes:
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() returnwith:
func _update_wormholes() -> void: if wormholes.is_empty(): return 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 _portal_timer -= Sim_Const.DT if _portal_timer <= 0.0: wormholes.clear() portals_open = false # ignored the choice entirely -- weapons resume regardlessenter_area() already calls wormholes.clear() (line 960) — add portals_open = false
there too, right after it:
dev_clear_enemies() wormholes.clear() portals_open = false- Step 6: Gate weapon/drone ticking on
portals_open
In Sim.tick(), find the per-pilot weapon loop (around line 671-679):
var _pi := 0 for pilot in pilots: if pilot.hp > 0.0: for wid in pilot.arsenal.active_weapon_ids: pilot.arsenal.weapon_by_id[wid].update(self, pilot, dt) var pilot_input: InputState = _pilot_inputs[_pi] if _pi < _pilot_inputs.size() else null if pilot_input != null: _fire_aim(pilot, pilot_input, dt) _pi += 1Wrap the weapon-update and fire-aim calls behind not portals_open (movement/damage
handling elsewhere is untouched — only active weapon firing pauses):
var _pi := 0 for pilot in pilots: if pilot.hp > 0.0 and not portals_open: for wid in pilot.arsenal.active_weapon_ids: pilot.arsenal.weapon_by_id[wid].update(self, pilot, dt) var pilot_input: InputState = _pilot_inputs[_pi] if _pi < _pilot_inputs.size() else null if pilot_input != null: _fire_aim(pilot, pilot_input, dt) _pi += 1And find drone_director.update_drones(self, input, dt) (line 683) — wrap it too:
if not portals_open: drone_director.update_drones(self, input, dt) drone_director.damage_drones_from_enemies(self, dt) # drones stay destroyable even while paused(Movement, contact damage, and gem pickup are all deliberately left un-paused — the player must still be able to walk to a chosen portal; only offense-dealing systems pause, matching Chris’s stated concern about accidentally triggering something with an active buff.)
- Step 7: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_wormhole.gd -gexit
Expected: PASS.
- Step 8: Run the full suite, determinism, and boot check
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"- Step 9: Commit
git add sim/sim.gd tests/test_wormhole.gdgit commit -m "feat(areas): 3 simultaneous Dimension portals, weapons/drones pause while open
A boss kill now opens one portal per Dimension (spread in a smallcircle around the death point) instead of a single cycling wormhole.Sim.portals_open pauses weapon firing and drone updates from themoment they open until the player commits to one OR PORTAL_LIFETIME(45s) elapses -- so ignoring the choice forever can't permanentlylock weapons off."Task 5: WormholeRenderer — per-portal color + label
Section titled “Task 5: WormholeRenderer — per-portal color + label”Files:
- Modify:
render/wormhole_renderer.gd - Test: create
tests/test_wormhole_renderer.gdif no render-side test exists for it yet (greptests/forWormholeRendererfirst — if a test file already exists, add to it instead of creating a new one)
Interfaces:
-
Consumes: each wormhole dict’s
element/name/destkeys (Task 4). -
Produces:
WormholeRenderer.update_wormholes(wormholes: Array)(unchanged signature). -
Step 1: Write the failing test
extends GutTest
func test_each_portal_gets_a_label_matching_its_dimension_name() -> void: var r := WormholeRenderer.new() add_child_autofree(r) r.update_wormholes([ {"pos": Vector2(100, 0), "dest": "pyre", "element": 0, "name": "Pyre"}, {"pos": Vector2(-100, 0), "dest": "null_dim", "element": 1, "name": "Null"}, ]) var labels: Array = [] for c in r.get_children(): if c is Label: labels.append(c.text) labels.sort() assert_eq(labels, ["Null", "Pyre"])
func test_no_wormholes_means_no_leftover_labels() -> void: var r := WormholeRenderer.new() add_child_autofree(r) r.update_wormholes([{"pos": Vector2.ZERO, "dest": "pyre", "element": 0, "name": "Pyre"}]) r.update_wormholes([]) var label_count := 0 for c in r.get_children(): if c is Label: label_count += 1 assert_eq(label_count, 0)- Step 2: Run the test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_wormhole_renderer.gd -gexit
Expected: FAIL — no labels exist yet.
- Step 3: Add per-portal Label children + element tint
Read render/wormhole_renderer.gd in full first (it’s short, ~34 lines) to see the exact
current _draw() swirl-color literals before editing — this step replaces
update_wormholes to also manage a Label child per portal and passes each portal’s
tint into _draw() instead of the two hardcoded swirl colors:
func update_wormholes(wormholes: Array) -> void: _wormholes = wormholes # Rebuild the label children to match (cheap — at most 3 at once). Keyed by array # index since portals never reorder mid-life (only ever removed all together). for c in get_children(): c.queue_free() for w in _wormholes: var accent: Color = ElementPalette.color_for(content, int(w.get("element", -1))) \ if content != null else Color.CYAN var lbl := Label.new() lbl.text = String(w.get("name", "")) lbl.add_theme_font_override("font", NeonTheme.title_font()) lbl.add_theme_font_size_override("font_size", 18) lbl.add_theme_color_override("font_color", accent) lbl.position = Vector2(w["pos"].x - 40, w["pos"].y - 46) lbl.size = Vector2(80, 24) lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER add_child(lbl) queue_redraw()ElementPalette.color_for(content, element_idx) needs a content: ContentDB reference —
add a var content: ContentDB field to WormholeRenderer (set by whichever main.gd
code already constructs it, alongside the existing wormhole_renderer instantiation at
main.gd:607-608 — add wormhole_renderer.content = sim.content right after that line).
Fall back to Color.CYAN when content is null (e.g. in a test that doesn’t set it) so
this never crashes standalone.
_draw() itself needs updating too: currently draws every wormhole with the same two
hardcoded swirl colors — change it to read each wormhole’s own tint via the same
ElementPalette.color_for call (or thread the already-computed label color through if
that’s simpler given the existing _draw() structure — read the file first to decide
which is the smaller diff) so a portal’s swirl matches its label.
- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_wormhole_renderer.gd -gexit
Expected: PASS.
- Step 5: Run the full suite and boot check
bash scripts/check-test-count.shgodot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"- Step 6: Commit
git add render/wormhole_renderer.gd main.gd tests/test_wormhole_renderer.gdgit commit -m "feat(areas): per-portal colour + name label on the wormhole renderer
Each of the 3 simultaneous portals now tints (via ElementPalette,matching every other element-tinted render path in this codebase) andlabels itself with its Dimension's name, so the player can tell themapart before choosing one."Task 6: Environmental phenomena (one per Dimension)
Section titled “Task 6: Environmental phenomena (one per Dimension)”Files:
- Modify:
sim/sim.gd - Test:
tests/test_dimensions.gd(new)
Interfaces:
-
Consumes:
AreaDefs.is_dimension/element_for(Task 1), the existing bomber telegraph mechanism (sim/enemy_attacks.gd’s bomber path — read it before Step 3 to confirm the exact function name/signature to call into) and Graviton’s pull constant (GRAVITON_PULL_STRENGTH, insim/graviton.gd). -
Produces:
Sim.DIMENSION_HAZARD_INTERVAL: float(const),Sim._dimension_hazard_timer: float,Sim._update_dimension_hazard(dt: float) -> void. -
Step 1: Write the failing tests
extends GutTest
func test_hazard_never_fires_outside_a_dimension() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim._dimension_hazard_timer = 0.001 sim._update_dimension_hazard(0.1) # home is not a Dimension -- the timer must not even count down/fire anything visible. assert_eq(sim.fx_events.size(), 0)
func test_hazard_fires_at_most_once_per_interval_in_a_dimension() -> void: # Uses Null (void), NOT Pyre -- _spawn_void_hazard appends an fx_event directly at # spawn time (a one-shot pull + "RIFT" fx). _spawn_fire_hazard does NOT: it only # queues a `bombs` entry, and that entry's own fx_event fires later, when # update_bombs() resolves its delay countdown, not at the moment it's queued -- so # checking fx_events.size() right after firing the Pyre hazard would NOT prove # anything (see Step 4's fire-hazard code: it touches `bombs`, not `fx_events`). var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.NULL_DIM) sim._dimension_hazard_timer = 0.001 sim._update_dimension_hazard(0.1) var count_after_one: int = sim.fx_events.size() assert_gt(count_after_one, 0, "a hazard fired once the timer elapsed") sim._update_dimension_hazard(0.1) # timer just reset -- shouldn't fire again immediately assert_eq(sim.fx_events.size(), count_after_one, "no second hazard until the interval passes again")
func test_fire_hazard_queues_a_telegraphed_bomb_not_an_immediate_fx_event() -> void: # The Pyre-specific case: verify its actual signal (a queued bombs entry), not fx_events. var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.PYRE) sim._dimension_hazard_timer = 0.001 var bombs_before := sim.bombs.size() sim._update_dimension_hazard(0.1) assert_eq(sim.bombs.size(), bombs_before + 1, "the fire hazard queues exactly one telegraphed bomb") var bm: Dictionary = sim.bombs[sim.bombs.size() - 1] assert_true(bm.has("delay") and bm.has("radius") and bm.has("damage"), "the queued bomb has the fields update_bombs()/the renderer actually read")
func test_hazard_does_not_fire_while_a_boss_is_alive() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.NULL_DIM) # Null's boss is FunZo (see Task 3's correction) sim._spawn_phase_boss() assert_ne(sim.boss_rotation.funzo_index(sim), -1, "Null's boss (FunZo) is alive") sim._dimension_hazard_timer = 0.001 sim._update_dimension_hazard(0.1) assert_eq(sim.fx_events.size(), 0, "no environmental hazard while the dimension's boss fight is on")(The third test’s boss-index lookup is defensive about the exact BossRotation method
name for Warden — boss_index is confirmed to exist from Task 3’s ground truth; if a
warden_boss_index alias doesn’t exist, the has_method check safely falls through to
the confirmed boss_index.)
- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: FAIL — _update_dimension_hazard doesn’t exist yet.
- Step 3: Read the bomber telegraph mechanism before wiring Pyre’s hazard
Run grep -n "func.*bomber\|BOMB" sim/enemy_attacks.gd sim/sim.gd to find the exact
telegraphed-AoE function bombers use (per CLAUDE.md, bombs are tracked in Sim.bombs and
resolved by an _update_bombs-style function). Confirm the exact dictionary shape a
Sim.bombs entry needs (position, timer, damage, radius) by reading the surrounding
code, then reuse that SAME shape for the environmental fireball — do not invent a
parallel bomb-like structure.
- Step 4: Add the hazard timer + 3 per-element hazard functions
In sim/sim.gd, add near the other Dimension-related additions:
const DIMENSION_HAZARD_INTERVAL: float = 20.0var _dimension_hazard_timer: float = DIMENSION_HAZARD_INTERVAL
# One generic per-Dimension environmental-hazard timer, dispatching to a small per-# element function rather than 3 bespoke systems. No-op outside a Dimension or while# that Dimension's boss is alive (so a hazard never stacks with an actual boss fight).func _update_dimension_hazard(dt: float) -> void: if not AreaDefs.is_dimension(current_area) or boss_rotation.any_boss_alive(self): return _dimension_hazard_timer -= dt if _dimension_hazard_timer > 0.0: return _dimension_hazard_timer = DIMENSION_HAZARD_INTERVAL match current_area: AreaDefs.PYRE: _spawn_fire_hazard() AreaDefs.NULL_DIM: _spawn_void_hazard() AreaDefs.DRIFT: _spawn_aether_hazard()
# Pyre: a single telegraphed fireball impact at a random point near the player, reusing# the SAME bombs entry shape the bomber enemy's own attack uses (sim/enemy_attacks.gd's# _update_ranged bomber branch: {"pos","delay","radius","damage","max_delay"} -- "delay"# (NOT "timer") is what update_bombs() counts down every tick via# bm["delay"] = float(bm["delay"]) - dt; "max_delay" drives the render-side telegraph# fraction in render/bomb_renderer.gd (optional, defaults to 1.2 if omitted -- included# explicitly here to match both existing call sites' own convention). VERIFIED against# the actual file during plan-writing, not guessed.func _spawn_fire_hazard() -> void: var pos := _nearest_pilot(Vector2.ZERO).pos + rng.rand_unit_dir() * rng.randf_range(80.0, 220.0) bombs.append({"pos": pos, "delay": 1.2, "max_delay": 1.2, "damage": 18.0, "radius": 70.0})
# Null: a brief, weak one-shot gravity pull toward a point near the player -- a much# lower-magnitude, single-tick version of Graviton's own GRAVITON_PULL_STRENGTH ability,# NOT the boss's full mechanic. Applied additively, same as Graviton's own pull, so input# still steers (never a forced, un-escapable displacement).func _spawn_void_hazard() -> void: var pull_center := _nearest_pilot(Vector2.ZERO).pos + rng.rand_unit_dir() * 150.0 var to_center := pull_center - player.pos var d := to_center.length() if d > 1.0: player.pos += to_center / d * minf(40.0, d) fx_events.append({"kind": "reaction", "pos": pull_center, "element": content.element_index("void"), "name": "RIFT"})
# Drift: render-only vision flicker, no damage -- the least mechanically risky of the# three, matches aether's "phase" flavour. Purely an fx_event; main.gd's fx consumer# handles the actual visual (a brief static/flicker), added alongside this task if a# matching FxManager case doesn't already exist -- grep `"kind": "reaction"` handling in# fx/fx_manager.gd first; this may just need a new "phase_flicker" case there.func _spawn_aether_hazard() -> void: fx_events.append({"kind": "phase_flicker", "pos": player.pos, "element": content.element_index("aether")})Wire _update_dimension_hazard(dt) into Sim.tick() — add it near the other per-tick
area-related calls (right after _update_wormholes()/_update_teaser_wormhole()):
_update_wormholes() # fly over a boss-spawned wormhole → emit a warp event _update_teaser_wormhole() # v0.1: fly over the teaser wormhole → emit teaser_event _update_dimension_hazard(dt)- Step 5: Add the
phase_flickerfx case (render-side, if missing)
Run grep -n '"kind"' fx/fx_manager.gd — if no "phase_flicker" case exists, add one
that matches the file’s existing pattern for a short-lived, no-damage visual effect
(follow exactly how an existing purely-cosmetic kind like "pickup" is consumed, since
phase_flicker needs the same “just a flash, no gameplay effect” treatment).
- Step 6: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: PASS.
- Step 7: Run the full suite, determinism, and boot check
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"- Step 8: Commit
git add sim/sim.gd fx/fx_manager.gd tests/test_dimensions.gdgit commit -m "feat(areas): one on-theme environmental hazard per Dimension
Pyre gets a telegraphed fireball impact (reusing the bomber's ownbombs-array mechanism), Null a weak one-shot gravity pull (far belowGraviton's own boss-ability magnitude), Drift a damage-free visionflicker. One shared timer dispatches to a small per-element functionrather than 3 bespoke systems; never fires while that Dimension's bossis alive."Task 7: Crystal bonus-gold + visual burst on a Dimension boss kill
Section titled “Task 7: Crystal bonus-gold + visual burst on a Dimension boss kill”⚠️ CORRECTED 2026-07-03, before dispatch — read this before anything else in this
task. The original version of this task reused the gems entity pool (Sim.gems, a
plain EntityPool, not a subclass — there is no gem_pool.gd file). Verified directly
against sim/sim.gd’s existing gem call site (gems.add(pos, Vector2.ZERO, GEM_RADIUS, v)
in _award_xp) and _collect_gems’s consumption of them: gems are exclusively an
XP-carrying entity — collecting one calls _bank_xp, not a gold award — and per this
project’s own established convention (CLAUDE.md: “gems recoloured white… to read as
loot, not an element”), gems render UNIFORMLY WHITE with no per-instance element tint
support at all. Reusing gems would have (a) granted XP, not the “bonus gold” this task
was scoped to, and (b) produced 10 pickups visually indistinguishable from an ordinary
gem — completely defeating “10 crystals of the dimension’s element” as a visible, distinct
moment. Corrected design below: bonus GOLD (matching the existing BOSS_GOLD-style
run_gold += ... convention, not a new pickup) plus a burst of 10 element-tinted VISUAL
fx events (reusing the "reaction" fx kind with an empty name, which fx/fx_manager.gd
already renders as a pure colored spark+ring with no text — confirmed at
fx/fx_manager.gd:379-389). No new entity pool, no gem-pool changes.
Files:
- Modify:
sim/sim.gd - Test:
tests/test_dimensions.gd
Interfaces:
- Consumes:
AreaDefs.is_dimension/element_for(Task 1), the existing per-boss death branches in_sweep_dead(Task 3’s ground truth). - Produces:
Sim.DIMENSION_CRYSTAL_COUNT: int(const, =10),Sim.CRYSTAL_GOLD_BONUS: int(const),Sim._award_dimension_crystals(pos: Vector2) -> void.
Scoping decision (still holds, corrected mechanism only): this is a themed bonus
reward on top of the existing gold/XP drop, NOT a new persistent currency. The
codebase’s existing “Crystals” name already belongs to RULESET_CRYSTALS, a completely
different upgrade-offering mechanic (sim/upgrade_system.gd) — this task does not touch
that system at all. A real distinct spendable “Dimension Crystal” currency (its own save
field, its own shop track) is a bigger follow-up Chris should sign off on explicitly, not
something to lock in unsupervised overnight.
- Step 1: Write the failing tests
func test_dimension_boss_kill_awards_bonus_gold_and_a_tinted_burst() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.DRIFT) var gold_before := sim.run_gold var fx_before := sim.fx_events.size() sim._award_dimension_crystals(Vector2.ZERO) assert_eq(sim.run_gold, gold_before + Sim.CRYSTAL_GOLD_BONUS, "a flat bonus representing the 10 crystals, on top of the normal BOSS_GOLD/XP drop") var new_fx: int = sim.fx_events.size() - fx_before assert_eq(new_fx, Sim.DIMENSION_CRYSTAL_COUNT, "one visual burst per crystal") for i in range(fx_before, sim.fx_events.size()): var ev: Dictionary = sim.fx_events[i] assert_eq(String(ev.get("kind", "")), "reaction") assert_eq(int(ev.get("element", -1)), content.element_index("aether"), "each burst is tinted with Drift's own element") assert_eq(String(ev.get("name", "x")), "", "a pure visual spark, no text label")
func test_non_dimension_boss_kill_awards_no_bonus() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) # home, not a Dimension var gold_before := sim.run_gold var fx_before := sim.fx_events.size() sim._award_dimension_crystals(Vector2.ZERO) assert_eq(sim.run_gold, gold_before, "no bonus gold outside a Dimension") assert_eq(sim.fx_events.size(), fx_before, "no burst outside a Dimension")- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: FAIL — _award_dimension_crystals/DIMENSION_CRYSTAL_COUNT/CRYSTAL_GOLD_BONUS
don’t exist yet.
- Step 3: Add
_award_dimension_crystalsand call it from each boss death branch
In sim/sim.gd, add:
const DIMENSION_CRYSTAL_COUNT: int = 10const CRYSTAL_GOLD_BONUS: int = 20 # a flat bonus representing the 10 crystals -- matches # the existing BOSS_GOLD-style flat-reward convention, # not scaled per-crystal or by area_reward_mult
# A themed bonus on a Dimension's boss kill, on top of the existing gold/XP drop: flat# bonus gold (see this task's scoping note: NOT a new persistent currency) plus 10# element-tinted visual bursts so the kill genuinely reads as "10 crystals of this# Dimension's element" even though nothing physical needs collecting.func _award_dimension_crystals(pos: Vector2) -> void: if not AreaDefs.is_dimension(current_area): return run_gold += CRYSTAL_GOLD_BONUS var element_idx := content.element_index(AreaDefs.element_for(current_area)) for k in range(DIMENSION_CRYSTAL_COUNT): var ang: float = TAU * float(k) / float(DIMENSION_CRYSTAL_COUNT) var off := Vector2(cos(ang), sin(ang)) * 24.0 fx_events.append({"kind": "reaction", "pos": pos + off, "element": element_idx, "name": ""})Call it from each of the 5 boss-death branches in _sweep_dead (Task 3’s ground truth —
lines ~1503-1534), right after each branch’s existing run_gold += .../reward lines,
e.g. for the TYPE_BOSS branch:
if dead_type == EnemyPool.TYPE_BOSS: run_gold += int(round(float(BOSS_GOLD) * area_reward_mult)) _award_dimension_crystals(dead_pos) _next_boss_time = run_time + BOSS_INTERVAL boss.reset() fx_events.append({"kind": "reaction", "pos": dead_pos, "element": -1, "name": "BOSS DOWN"})Add the identical one-line _award_dimension_crystals(dead_pos) call to the other 4
boss branches (TYPE_BOSS2, TYPE_FUNZO, TYPE_GRAVITON, TYPE_EYE) in the same
position (right after their run_gold line). It is a safe no-op for Boss2 (never
assigned to a Dimension per this plan) since _award_dimension_crystals itself guards on
AreaDefs.is_dimension(current_area). FunZo/Graviton/Eye ARE each a Dimension’s boss
(Task 3), so their branches are where this actually fires in real play.
- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: PASS.
- Step 5: Run the full suite, determinism, and boot check
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexitgodot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"- Step 6: Commit
git add sim/sim.gd tests/test_dimensions.gdgit commit -m "feat(areas): bonus gold + 10 element-tinted crystal bursts on a Dimension boss kill
Corrected during plan review before dispatch: the gems entity pool isexclusively XP-carrying and renders uniformly white with no per-instance element tint, so reusing it (the original plan) would havegranted XP instead of the intended bonus gold and produced pickupsvisually indistinguishable from an ordinary gem -- defeating '10crystals of the dimension's element' as a visible moment entirely.Awards a flat gold bonus (matching the existing BOSS_GOLD convention)plus 10 tinted 'reaction' fx bursts (a pure spark+ring, no text) atthe death position instead. Not a new persistent currency -- see thescoping note."Task 8: Weapon dimension-gating hook (lightweight, unused by any weapon yet)
Section titled “Task 8: Weapon dimension-gating hook (lightweight, unused by any weapon yet)”⚠️ CORRECTED 2026-07-03, before dispatch. Verified directly against sim/content_db.gd:
there is no weapons() -> Array (plural) accessor — but there IS already an exact-fit
singular one, func weapon(id: String) -> Dictionary (line 25, backed by a generic
_by_id(category, id) lookup, returns {} for an unknown id). weapon_available_in below
uses this directly instead of a manual loop over a nonexistent weapons(). Also: my own
first draft of Step 1’s test called ContentDB.weapon_available_in(...) as a STATIC
call — but weapon_available_in needs instance data (_data, via weapon(id)), so it
must be an instance method, called on a constructed content object, not the class
itself (only weapon_matches_dimension is genuinely static — it operates on a raw dict
with no instance state). Both fixed below. Also confirmed tests/test_upgrade_system.gd
doesn’t exist; tests/test_weapon_unlock.gd already tests the exact locked_weapons
gating pattern this task extends, so Step 4’s wiring test goes there instead.
Files:
- Modify:
sim/content_db.gd,sim/upgrade_system.gd - Test:
tests/test_content_db.gd(confirmed exists) andtests/test_weapon_unlock.gd(confirmed exists, already tests thelocked_weaponsgating this task extends)
Interfaces:
-
Produces:
ContentDB.weapon_matches_dimension(weapon_def: Dictionary, dimension_id: String) -> bool(static, pure),ContentDB.weapon_available_in(weapon_id: String, dimension_id: String) -> bool(instance method — true if the weapon has nodimensionfield, or its field matches;dimension_idmay be""/a non-Dimension area, in which case every weapon is available — a dimension-gate only ever restricts INSIDE a specific Dimension). -
Step 1: Write the failing tests
Add to tests/test_content_db.gd:
func test_weapon_with_no_dimension_field_is_available_everywhere() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") for wid in ["pulse", "nova", "orbit", "beam", "turret", "scatter"]: assert_true(content.weapon_available_in(wid, AreaDefs.PYRE), "%s has no dimension restriction in bible.json today" % wid) assert_true(content.weapon_available_in(wid, ""))
func test_a_dimension_restricted_weapon_is_gated_correctly() -> void: # Synthetic fixture -- weapon_matches_dimension reads a raw weapon dict, so this # doesn't need a real bible.json entry to exercise the gating logic itself. var restricted := {"id": "test_weapon", "dimension": "pyre"} assert_true(ContentDB.weapon_matches_dimension(restricted, "pyre")) assert_false(ContentDB.weapon_matches_dimension(restricted, "null_dim")) assert_false(ContentDB.weapon_matches_dimension(restricted, ""), "a dimension-restricted weapon is exclusive to its dimension(s) -- not offered in generic survival either")(The third assertion is assert_false, not assert_true — Chris’s ask was “weapons
should be restricted to a certain subset of the dimensions,” read as exclusive-to-those-
dimensions: a dimension-gated weapon is NOT available in generic survival either, since
it’s meant to be a Dimension-exclusive reward.)
- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_db.gd -gexit
Expected: FAIL — weapon_matches_dimension/weapon_available_in don’t exist yet.
- Step 3: Add the functions to
sim/content_db.gd
Add near the existing weapon(id)/has_weapon(id) functions:
# A weapon's optional "dimension" bible.json field restricts it to that Dimension only# (a hook for future content -- no shipped weapon uses this yet, ALL 6 existing weapons# have no "dimension" field and are available everywhere, including every Dimension).static func weapon_matches_dimension(weapon_def: Dictionary, dimension_id: String) -> bool: var required: String = String(weapon_def.get("dimension", "")) if required == "": return true # unrestricted -- available everywhere, including generic survival return required == dimension_id
func weapon_available_in(weapon_id: String, dimension_id: String) -> bool: var w := weapon(weapon_id) if w.is_empty(): return true # unknown weapon id -- fail open, matches this codebase's other lookups return weapon_matches_dimension(w, dimension_id)- Step 4: Wire the filter into
roll_upgrade_choices’ weapon-grant loop
In sim/upgrade_system.gd, find the weapon-grant loop (confirmed at lines 60-64):
for wid in WEAPON_ORDER: if sim.locked_weapons.has(wid): continue # gated behind a meta unlock until purchased if sim._weapon_by_id.get(wid) != null and not is_weapon_active(sim, wid): weapon_ids.append("weapon:" + wid)add the dimension check alongside the existing locked_weapons check:
for wid in WEAPON_ORDER: if sim.locked_weapons.has(wid): continue # gated behind a meta unlock until purchased if not sim.content.weapon_available_in(wid, sim.current_area): continue # dimension-exclusive weapon, wrong (or no) Dimension if sim._weapon_by_id.get(wid) != null and not is_weapon_active(sim, wid): weapon_ids.append("weapon:" + wid)Add one wiring test to tests/test_weapon_unlock.gd (matching its existing style —
grep its existing tests for the exact Sim/content construction pattern it already
uses, since this codebase’s other tasks tonight have found real value in not guessing
at an existing test file’s setup helper) proving: with all 6 shipped weapons
unrestricted, roll_upgrade_choices behavior is completely unchanged by this task (a
true no-op today) — e.g. that a fresh Sim run’s weapon-grant pool still contains every
un-owned, non-locked_weapons weapon exactly as before.
- Step 5: Run tests to verify they pass
Run both test files with -gtest=.... Expected: PASS.
- Step 6: Run the full suite, determinism, and boot check
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexitgodot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"- Step 7: Commit
git add sim/content_db.gd sim/upgrade_system.gd tests/test_content_db.gd tests/test_weapon_unlock.gdgit commit -m "feat(areas): lightweight weapon-to-dimension gating hook
An optional 'dimension' bible.json field on a weapon restricts itsoffer to that Dimension only. No shipped weapon uses this yet -- all6 existing weapons are unrestricted, so this is a genuine no-op today,just a hook for future dimension-exclusive weapon content. UsesContentDB's existing weapon(id) singular accessor rather than anonexistent weapons() plural one (verified before dispatch)."Task 9: Full verification, unlock, and deploy
Section titled “Task 9: Full verification, unlock, and deploy”Files:
-
Modify:
main.gd(flipV01_LOCK_AREAS),sim/constants.gd(bumpBUILD) -
No new tests — this task verifies everything already built and ships it.
-
Step 1: Full-branch review
Read the diff across all 8 prior tasks’ commits (git log --oneline since this plan’s
first commit) for: determinism parity (every new spawn/rng path gated past a boss death,
SpawnTable.pick’s single-draw invariant preserved, _remap_to_dimension never draws
extra rng), /sim purity (no Node/Engine/File API leaked into sim/), the
invisible-entity rule (the new phase_flicker fx kind has a real consumer in
fx/fx_manager.gd, not silently dropped), and that portals_open can never get
permanently stuck true (Task 4’s PORTAL_LIFETIME auto-clear covers the
ignore-forever case; enter_area covers the travel case — confirm both paths are
reachable and tested).
- Step 2: Confirm the determinism baseline is byte-identical
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexitExpected: PASS, values unchanged from snapshot_string().hash()=2730172591,
state_checksum()=4075578713. If either moved, STOP — something leaked into the
no-Dimension baseline path; do not proceed to unlock/deploy until root-caused.
- Step 3: Full suite + boot check
bash scripts/check-test-count.shgodot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"Expected: all green, empty grep output.
- Step 4: Flip the launch gate
In main.gd, find const V01_LOCK_AREAS := true and change to false. Re-run Step 3
after this change (flipping the gate changes what code paths a boot-smoke test actually
exercises).
- Step 5: Bump the build number and deploy
Bump Sim_Const.BUILD in sim/constants.gd. Follow the bh-deploy skill’s sections A
through C2 (sync gameplay to ~/Claude/bullet-heaven-tvos, verify there too, export +
build + install/launch on both the Apple TV and Chris’s iPhone — no phone-only
restriction applies to this feature).
- Step 6: Update CLAUDE.md and the roadmap memory
Add an entry to CLAUDE.md’s “Current status” section (and the bullet-heaven-roadmap
memory) covering: Elemental Dimensions shipped (3 portals after a boss kill, Pyre/Null/
Drift, reused bosses re-themed, on-theme enemy restriction, 1 environmental hazard each,
10 bonus gems on kill, a weapon-dimension-gating hook nothing uses yet), that it was
authored and implemented autonomously overnight with every non-obvious call documented
as a Decision in the spec, and the 3 items explicitly flagged as placeholder/needing
Chris’s eye: the reused background variant assignments (Step 3 of Task 1), the
crystal-as-bonus-gold scoping decision (Task 7) if he wants a real persistent currency
instead, and the Pyre/Null/Drift names themselves (easy to rename).
- Step 7: Commit the final housekeeping
git add main.gd sim/constants.gd CLAUDE.mdgit commit -m "feat(areas): unlock Elemental Dimensions for real play
V01_LOCK_AREAS -> false. All 8 prior tasks verified green (full suite,both determinism tests, boot check) before this flip. Deployed to theApple TV and Chris's iPhone per the bh-deploy skill."Self-Review
Section titled “Self-Review”Spec coverage: 3 simultaneous named/colored portals (Task 4/5) ✅; weapons/upgrades
pause while open, resume after (Task 4) ✅; each dimension = one element, on-theme
enemies/background/boss (Tasks 1/2/3) ✅; environmental phenomena (Task 6) ✅; 10
crystals + XP on boss kill (Task 7, XP already flows through the existing unconditional
_award_xp call in _sweep_dead, untouched by this plan) ✅; weapon dimension-gating
hook (Task 8) ✅; starting dimension = Generic/home, unchanged (no task needed — it
already exists) ✅; determinism gating (every task) ✅; unlock + ship (Task 9) ✅.
Placeholder scan: Tasks 5/6/8 each have one step that says “read the file first to
confirm the exact existing name/shape before finalizing” rather than a guessed literal —
this is deliberate (I don’t have 100% certainty of fx_manager.gd’s exact case-dispatch
syntax, gem_pool.gd’s exact add() arg order, or which file currently tests
SpawnTable.pick/ContentDB.weapons(), without another full research pass I judged not
worth the overnight time budget for). Each such step names the EXACT grep to run and
what to match it against — not “add appropriate handling,” a real instruction with a
verifiable, concrete answer once run.
Type consistency: portals_open/_portal_timer/PORTAL_LIFETIME (Task 4) not
referenced again until Task 9’s review — consistent. AreaDefs.DIMENSION_IDS/
is_dimension/element_for/boss_for/enemy_types_for (Task 1) used identically in
Tasks 2/3/4/6/7/8 — consistent. _dimension_allowed_types/_remap_to_dimension (Task 2)
not reused elsewhere — consistent, no naming drift found.