Skip to content

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.

  • 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.gd after every task; the baseline (snapshot_string().hash()=2730172591, state_checksum()=4075578713) must hold.
  • SpawnTable.pick’s existing invariant — always draw exactly one rng.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 at sim/spawn_table.gd’s pick()).
  • Full suite currently: 189 scripts / 1347+ tests (grows as this plan adds tests), all green. Run bash scripts/check-test-count.sh after 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 boss class_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.md is 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 (an Array of EnemyPool.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 AreaDefs
extends 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" # fire
const 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
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
  • Step 6: Grep tests/test_wormhole.gd for 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.”

Terminal window
git add sim/area_defs.gd tests/test_areas.gd docs/superpowers/specs/2026-07-02-elemental-dimensions-design.md
git commit -m "feat(areas): 3 elemental Dimension defs (Pyre/Null/Drift)
Pure data: each Dimension pairs a bible.json element with an existing
boss archetype (re-themed, not new) and the subset of the existing
enemy roster that already carries that element. Renamed the fire
dimension Ember -> Pyre to avoid confusion with the pre-existing
unrelated ember_reach/Nebula area."

Files:

  • Modify: sim/spawn_table.gd, sim/sim.gd
  • Test: tests/test_spawn_rework.gd (or wherever SpawnTable.pick is currently tested — grep SpawnTable.pick( across tests/ 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, {} if current_area isn’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 allowed filter to SpawnTable.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 out

Find _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_type results 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
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
  • Step 8: Commit
Terminal window
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 the
Dimension's roster via modulo instead of drawing an extra rng value, so
the 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_count directly — 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 whether current_area is 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 whether current_area is 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 += 1

with:

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
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
  • Step 8: Commit
Terminal window
git add sim/graviton.gd sim/funzo.gd sim/sim.gd tests/test_boss_gate.gd
git commit -m "feat(areas): Dimension-themed boss dispatch via the real boss-gate mechanic
Graviton/FunZo's previously-hardcoded element becomes an optional
override param (default unchanged for every existing call site); Eye
already 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 it
always spawns that Dimension's own on-theme boss instead of rotating,
leaving _boss_gate_count untouched so the generic rotation resumes
correctly 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), each wormholes entry now carries pos, 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_timer fields

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.0
var _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():
return

with:

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_open on 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()
return

with:

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 regardless

enter_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 += 1

Wrap 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 += 1

And 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
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
  • Step 9: Commit
Terminal window
git add sim/sim.gd tests/test_wormhole.gd
git 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 small
circle around the death point) instead of a single cycling wormhole.
Sim.portals_open pauses weapon firing and drone updates from the
moment they open until the player commits to one OR PORTAL_LIFETIME
(45s) elapses -- so ignoring the choice forever can't permanently
lock 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.gd if no render-side test exists for it yet (grep tests/ for WormholeRenderer first — if a test file already exists, add to it instead of creating a new one)

Interfaces:

  • Consumes: each wormhole dict’s element/name/dest keys (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
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
  • Step 6: Commit
Terminal window
git add render/wormhole_renderer.gd main.gd tests/test_wormhole_renderer.gd
git 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) and
labels itself with its Dimension's name, so the player can tell them
apart 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, in sim/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.0
var _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_flicker fx 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
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
  • Step 8: Commit
Terminal window
git add sim/sim.gd fx/fx_manager.gd tests/test_dimensions.gd
git commit -m "feat(areas): one on-theme environmental hazard per Dimension
Pyre gets a telegraphed fireball impact (reusing the bomber's own
bombs-array mechanism), Null a weak one-shot gravity pull (far below
Graviton's own boss-ability magnitude), Drift a damage-free vision
flicker. One shared timer dispatches to a small per-element function
rather than 3 bespoke systems; never fires while that Dimension's boss
is 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_crystals and call it from each boss death branch

In sim/sim.gd, add:

const DIMENSION_CRYSTAL_COUNT: int = 10
const 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
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
  • Step 6: Commit
Terminal window
git add sim/sim.gd tests/test_dimensions.gd
git 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 is
exclusively XP-carrying and renders uniformly white with no per-
instance element tint, so reusing it (the original plan) would have
granted XP instead of the intended bonus gold and produced pickups
visually indistinguishable from an ordinary gem -- defeating '10
crystals 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) at
the death position instead. Not a new persistent currency -- see the
scoping 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) and tests/test_weapon_unlock.gd (confirmed exists, already tests the locked_weapons gating 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 no dimension field, or its field matches; dimension_id may 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
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit
godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
  • Step 7: Commit
Terminal window
git add sim/content_db.gd sim/upgrade_system.gd tests/test_content_db.gd tests/test_weapon_unlock.gd
git commit -m "feat(areas): lightweight weapon-to-dimension gating hook
An optional 'dimension' bible.json field on a weapon restricts its
offer to that Dimension only. No shipped weapon uses this yet -- all
6 existing weapons are unrestricted, so this is a genuine no-op today,
just a hook for future dimension-exclusive weapon content. Uses
ContentDB's existing weapon(id) singular accessor rather than a
nonexistent 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 (flip V01_LOCK_AREAS), sim/constants.gd (bump BUILD)

  • 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
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit

Expected: 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
Terminal window
bash scripts/check-test-count.sh
godot --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
Terminal window
git add main.gd sim/constants.gd CLAUDE.md
git 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 the
Apple TV and Chris's iPhone per the bh-deploy skill."

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.