Elemental Dimensions v2 Implementation Plan
Elemental Dimensions v2 Implementation Plan
Section titled “Elemental Dimensions v2 Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the shipped 3-dimension Elemental Dimensions mapping (Pyre/Null/Drift) with
Toby’s 8-dimension design, generalizing the system (enemy element override, per-dimension enemy
stat buffs, weapon gating, reused hazard mechanisms), and ship 4 real dimensions this pass:
Generic (= home, restricted for real), Fire, Void, Light. Ice/Blood/Electricity/Poison are
follow-up specs (they need new bosses/mechanics this plan does not build).
Architecture: All changes are in /sim (pure RefCounted, no engine deps) plus one render
file (BombRenderer, made element-aware). AreaDefs gains data-only fields (per-dimension
enemy-element overrides, stat-buff multipliers, a weapon allow-list) that existing dispatch code
(_spawn_one, _spawn_phase_boss, _update_dimension_hazard, roll_upgrade_choices) already
calls — most of this plan is making that existing dispatch code read the new fields, not new
dispatch machinery. AreaDefs.is_dimension() changes from “id is in the 3-item portal pool” to
“this area has a boss field” — home gains one ("warden"), so home becomes a real themed
area (restricted roster, dedicated boss) for the first time, while DIMENSION_IDS (a separate,
smaller list) still governs only which 3 ids a boss-kill portal offers.
Tech Stack: Godot 4.6.3 / GDScript, GUT 9.6.0 test runner, existing bible.json content DB.
Global Constraints
Section titled “Global Constraints”- Every new spawn/rng path must be gated so it never fires inside the pinned determinism baseline
window (600-tick, seed 1234) — mirror the existing pattern (boss-death-gated, or
run_time-gated past a safe threshold). Re-runtests/test_determinism_checksum.gdandtests/test_determinism_crystals.gdafter every task; they must stay green with the SAME pinned hash/checksum values (no re-pin expected in this plan — every change here is gated exactly like the feature it extends). /simfiles stay pureRefCounted, noNode/Engine/Time/file APIs.- Test pattern for these tests:
Sim.new(<seed>, SimContentFixture.db())(loads realdata/bible.json) orContentLoader.load_from_path("res://data/bible.json")— both patterns are already used side-by-side intests/test_dimensions.gd/tests/test_areas.gd; match whichever the file you’re extending already uses. - Run the full suite after each task:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit(exit 0 = pass). Use-gtest=res://tests/<file>.gdto run a single file while iterating. - Commit after each task (frequent, small commits — see each task’s final step).
Task 1: AreaDefs v2 — rename/reassign the 3 dimensions, refactor is_dimension, extend the data schema
Section titled “Task 1: AreaDefs v2 — rename/reassign the 3 dimensions, refactor is_dimension, extend the data schema”Files:
- Modify:
sim/area_defs.gd(full rewrite of the dimension section) - Modify:
tests/test_areas.gd(renamePYRE/NULL_DIM/DRIFTreferences; update boss/element expectations for the reassignment) - Modify:
tests/test_wormhole.gd(no id references, but re-run to confirm — it only readsAreaDefs.DIMENSION_IDSgenerically)
Interfaces:
- Produces:
AreaDefs.FIRE := "fire",AreaDefs.VOID_DIM := "void_dim",AreaDefs.LIGHT := "light"(replacingPYRE/NULL_DIM/DRIFT);AreaDefs.DIMENSION_IDS: Array[String] := [FIRE, VOID_DIM, LIGHT];AreaDefs.is_dimension(id: String) -> bool(nowget_def(id).has("boss"), NOT array membership);AreaDefs.enemy_element_override(id: String, tid: int) -> String(new);AreaDefs.enemy_buff(id: String, key: String) -> float(new);AreaDefs.weapons_for(id: String) -> Array(new). - Consumes: nothing new (pure data class).
This task is a data + refactor step only — it does NOT yet make Warden spawn as a real
dimension boss (that’s Task 2’s job, since _spawn_dimension_boss’s match statement has no
"warden" arm yet). home gets a "boss": "warden" field here, so is_dimension("home")
becomes true after this task — but nothing actually triggers _spawn_phase_boss() while
current_area == "home" in this task’s own tests, so the missing dispatch arm is never
exercised yet (Task 2 completes that end-to-end).
- Step 1: Replace
sim/area_defs.gd’s dimension section
Read the current file first (sim/area_defs.gd) to confirm line numbers before editing — the
constants block (PYRE/NULL_DIM/DRIFT), the _DEFS dict’s 3 dimension entries, and
DIMENSION_IDS all get replaced. Full replacement content for the file:
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 additionally restricts# the roster to on-theme types (each optionally reflavored to the Dimension's element via# "enemy_elements"), assigns an on-theme boss, an enemy stat-buff set, and a weapon allow-list.# 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 v2 (2026-07-05, supersedes the original Pyre/Null/Drift mapping —# see docs/superpowers/specs/2026-07-05-elemental-dimensions-v2-design.md). Reached via the# 3-portal choice after a boss kill, NOT via the plain other()-cycle above.const FIRE := "fire"const VOID_DIM := "void_dim" # "void_dim" avoids the bare identifier `void` (a GDScript type keyword)const LIGHT := "light"
const _DEFS := { "home": { "name": "Home", "difficulty_mult": 1.0, "reward_mult": 1.0, "background": "home", # Generic — the starting/default dimension (Toby's note marks it "(Temporary)"). # Not a portal destination (not in DIMENSION_IDS below) — you start here, you don't # warp back to it. Restricted to 3 baseline enemy types + Warden as its sole boss; # no element, no hazard, no weapon restriction. "boss": "warden", "enemy_types": [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_SHOOTER, EnemyPool.TYPE_ELITE], }, "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"}, "fire": { "name": "Fire", "difficulty_mult": 1.4, "reward_mult": 1.4, "background": "aurora", "element": "fire", "boss": "warden", "enemy_types": [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_ELITE, EnemyPool.TYPE_SHOOTER, EnemyPool.TYPE_LANCER], "enemy_elements": {EnemyPool.TYPE_ELITE: "fire", EnemyPool.TYPE_SHOOTER: "fire", EnemyPool.TYPE_LANCER: "fire"}, "enemy_buffs": {"speed_mult": 1.2, "fire_rate_mult": 1.3, "proj_speed_mult": 1.25}, "weapons": [{"id": "nova", "temporary": false}, {"id": "turret", "temporary": true}, {"id": "scatter", "temporary": true}], }, "void_dim": { "name": "Void", "difficulty_mult": 1.5, "reward_mult": 1.45, "background": "ember_reach", "element": "void", "boss": "graviton", "enemy_types": [EnemyPool.TYPE_ELITE, EnemyPool.TYPE_ORBITER, EnemyPool.TYPE_GHOST, EnemyPool.TYPE_SPIDER], "enemy_elements": {EnemyPool.TYPE_ORBITER: "void", EnemyPool.TYPE_SPIDER: "void"}, "enemy_buffs": {"size_mult": 1.25, "damage_mult": 1.3}, "weapons": [{"id": "orbit", "temporary": true}, {"id": "blade", "temporary": true}], }, "light": { "name": "Sentinel's Reach", "difficulty_mult": 1.45, "reward_mult": 1.4, "background": "home", "element": "light", "boss": "eye", "enemy_types": [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_LANCER, EnemyPool.TYPE_ORBITER, EnemyPool.TYPE_ACCUMULATOR], "enemy_elements": {EnemyPool.TYPE_SWARMER: "light", EnemyPool.TYPE_ORBITER: "light", EnemyPool.TYPE_ACCUMULATOR: "light"}, "enemy_buffs": {"fire_rate_mult": 1.25, "damage_mult": 1.2}, "weapons": [{"id": "beam", "temporary": false}, {"id": "pulse", "temporary": true}, {"id": "blade", "temporary": true}], },}
# 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 — Ice/Blood/Electricity/Poison are each# their own follow-up spec). Deliberately does NOT include HOME — Generic is the starting# point, never a portal destination (see the "home" _DEFS entry's comment above).const DIMENSION_IDS: Array[String] = [FIRE, VOID_DIM, LIGHT]
static func get_def(id: String) -> Dictionary: return _DEFS.get(id, _DEFS["home"])
# A "Dimension" is any area with on-theme data (a "boss" field) — this now includes `home`# (Generic), not just the 3 portal-destination ids in DIMENSION_IDS. Aurora/Ember Reach stay# plain (no "boss" field), so they're never dimensions.static func is_dimension(id: String) -> bool: return get_def(id).has("boss")
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: # .duplicate() -- get_def()'s dict is the live entry inside const _DEFS; GDScript's # const only locks the top-level binding, not nested containers, so returning the # array by reference would let a careless caller mutate _DEFS itself in place. return get_def(id).get("enemy_types", []).duplicate()
# The bible element id (e.g. "fire") a Dimension reflavors `tid` to, or "" if `tid` spawns at# its own bible-native element in this Dimension (the common case — most rosters mix a couple# of reflavored guests with 1+ natively-themed types).static func enemy_element_override(id: String, tid: int) -> String: return String(get_def(id).get("enemy_elements", {}).get(tid, ""))
# A per-dimension enemy stat multiplier (speed_mult/fire_rate_mult/proj_speed_mult/hp_mult/# damage_mult/size_mult), 1.0 (no-op) if the Dimension doesn't set that key.static func enemy_buff(id: String, key: String) -> float: return float(get_def(id).get("enemy_buffs", {}).get(key, 1.0))
# {id: String, temporary: bool} entries this Dimension curates, or [] for "every weapon is# available" (Generic's case, matching every non-Dimension area today).static func weapons_for(id: String) -> Array: return get_def(id).get("weapons", []).duplicate()
# 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()]- Step 2: Update
tests/test_areas.gd’s renamed/reassigned references
Replace these existing test bodies (find by name in the current file):
func test_three_dimension_ids_exist_and_are_distinct_from_existing_areas() -> void: assert_eq(AreaDefs.DIMENSION_IDS, [AreaDefs.FIRE, AreaDefs.VOID_DIM, AreaDefs.LIGHT]) for id in AreaDefs.DIMENSION_IDS: assert_false(id in [AreaDefs.AURORA, AreaDefs.EMBER_REACH], "a Dimension id must not collide with an existing plain-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) assert_true(AreaDefs.is_dimension(AreaDefs.HOME), "Generic (home) is now a themed Dimension too") for id in [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.FIRE), "fire") assert_eq(AreaDefs.element_for(AreaDefs.VOID_DIM), "void") assert_eq(AreaDefs.element_for(AreaDefs.LIGHT), "light") assert_eq(AreaDefs.element_for(AreaDefs.HOME), "", "Generic has no element")
func test_dimension_bosses() -> void: assert_eq(AreaDefs.boss_for(AreaDefs.HOME), "warden", "Generic's boss is Warden") assert_eq(AreaDefs.boss_for(AreaDefs.FIRE), "warden", "Fire's boss is Warden, re-elemented") assert_eq(AreaDefs.boss_for(AreaDefs.VOID_DIM), "graviton", "Void's boss is Graviton, at its native element") assert_eq(AreaDefs.boss_for(AreaDefs.LIGHT), "eye", "Light's boss is Eye (displayed as \"Sentinel\")")
func test_dimension_enemy_types_are_all_on_theme() -> void: assert_eq(AreaDefs.enemy_types_for(AreaDefs.HOME), [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_SHOOTER, EnemyPool.TYPE_ELITE]) assert_eq(AreaDefs.enemy_types_for(AreaDefs.FIRE), [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_ELITE, EnemyPool.TYPE_SHOOTER, EnemyPool.TYPE_LANCER]) assert_eq(AreaDefs.enemy_types_for(AreaDefs.VOID_DIM), [EnemyPool.TYPE_ELITE, EnemyPool.TYPE_ORBITER, EnemyPool.TYPE_GHOST, EnemyPool.TYPE_SPIDER]) assert_eq(AreaDefs.enemy_types_for(AreaDefs.LIGHT), [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_LANCER, EnemyPool.TYPE_ORBITER, EnemyPool.TYPE_ACCUMULATOR])
func test_dimension_allowed_types_matches_fire() -> void: var s := _sim() s.enter_area(AreaDefs.FIRE) var allowed := s._dimension_allowed_types() for tid in AreaDefs.enemy_types_for(AreaDefs.FIRE): assert_true(allowed.has(tid)) assert_eq(allowed.size(), AreaDefs.enemy_types_for(AreaDefs.FIRE).size())
func test_remap_to_dimension_remaps_an_off_theme_type_into_void() -> void: # Swarmer isn't in Void's roster ([TYPE_ELITE, TYPE_ORBITER, TYPE_GHOST, TYPE_SPIDER]) -- # the modulo remap must actually engage and land on one of Void's on-theme types. var s := _sim() s.enter_area(AreaDefs.VOID_DIM) var remapped := s._remap_to_dimension(EnemyPool.TYPE_SWARMER) assert_true(remapped in [EnemyPool.TYPE_ELITE, EnemyPool.TYPE_ORBITER, EnemyPool.TYPE_GHOST, EnemyPool.TYPE_SPIDER], "an off-theme type remaps into Void's allowed set (got %d)" % remapped)
func test_remap_to_dimension_passes_through_an_already_allowed_type() -> void: var s := _sim() s.enter_area(AreaDefs.VOID_DIM) assert_eq(s._remap_to_dimension(EnemyPool.TYPE_ELITE), EnemyPool.TYPE_ELITE, "a type already on-theme for the current Dimension is returned unchanged")
func test_remap_to_dimension_is_a_noop_outside_a_dimension() -> void: var s := _sim() s.enter_area(AreaDefs.AURORA) # a genuinely non-Dimension area (home is now a Dimension too) assert_eq(s._remap_to_dimension(EnemyPool.TYPE_TANK), EnemyPool.TYPE_TANK, "outside a Dimension, _remap_to_dimension must not touch the type id")
func test_spawn_swarm_burst_inside_a_dimension_only_produces_on_theme_enemies() -> void: var s := _sim() s.enter_area(AreaDefs.VOID_DIM) s._spawn_swarm_burst() assert_gt(s.enemies.count, 0, "the swarm burst actually spawned something to check") var allowed := AreaDefs.enemy_types_for(AreaDefs.VOID_DIM) for i in range(s.enemies.count): var tid: int = s.enemies.type_id[i] assert_true(tid in allowed, "enemy %d spawned by the swarm burst inside Void has off-theme type %d" % [i, tid])Also update test_dimension_allowed_types_empty_outside_a_dimension — home is no longer
“outside a dimension,” so retarget it to aurora:
func test_dimension_allowed_types_empty_outside_a_dimension() -> void: var s := _sim() s.enter_area(AreaDefs.AURORA) assert_eq(s._dimension_allowed_types(), {}, "aurora is not a Dimension -> no restriction")Leave test_defaults_to_home, test_enter_area_sets_mults, test_enter_area_clears_the_field,
test_enter_area_keeps_the_player_run, test_enter_area_rearms_spawn_gates,
test_other_area_cycles_through_areas, test_enter_area_ember_reach_sets_mults,
test_wormhole_spawns_when_areas_enabled, test_wormhole_gated_off_for_v01 unchanged — none
reference the renamed constants or is_dimension.
- Step 3: Run the full suite
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: FAILs in tests/test_dimensions.gd, tests/test_boss_gate.gd, tests/test_content_db.gd,
tests/test_wormhole_renderer.gd (still reference PYRE/NULL_DIM/DRIFT and old
boss/behavior assumptions) — these are fixed in Tasks 2 and 7. tests/test_areas.gd and
tests/test_wormhole.gd must be fully green after this step; if either still fails, fix before
moving on.
- Step 4: Re-verify determinism
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit
Expected: both PASS, same pinned values (this task never spawns anything inside the baseline
window — home’s new restriction only engages once _spawn_one is actually called with
current_area == "home", which the baseline’s fixed 600-tick/seed-1234 run already does today
regardless of this task — the restriction changes WHICH type spawns via _remap_to_dimension,
not WHETHER an rng draw happens, so the draw order is unchanged. If this fails, STOP and
investigate before continuing — do not proceed to Task 2 with a broken baseline.)
- Step 5: Commit
git add sim/area_defs.gd tests/test_areas.gdgit commit -m "feat(dimensions): AreaDefs v2 -- fire/void_dim/light replace pyre/null_dim/drift, is_dimension covers Generic"Task 2: Wire Warden into the Dimension-boss system (Generic + Fire)
Section titled “Task 2: Wire Warden into the Dimension-boss system (Generic + Fire)”Files:
- Modify:
sim/boss_warden.gd:31-38(spawn()— add anelement_idxparameter) - Modify:
sim/sim.gd(_spawn_dimension_boss~line 1276 — add a"warden"match arm;TYPE_BOSSdeath branch ~line 1659 — add the crystal-award call) - Modify:
tests/test_dimensions.gd(rewrite the Warden/Boss2-inside-a-dimension regression tests,test_spawn_due_elites_*) - Modify:
tests/test_boss_gate.gd(renamePYRE/NULL_DIM, retarget the Graviton assertion, add a Warden-spawns-in-Fire assertion)
Interfaces:
-
Consumes:
AreaDefs.is_dimension/boss_for/element_for(Task 1). -
Produces:
BossWarden.spawn(sim: Sim, hp_mult: float = 1.0, element_idx: int = -1) -> void(element_idx-1means “use void, the old default” — preserves_spawn_due_elites’s existing call site unchanged);Sim._spawn_dimension_bosshandlesboss_id == "warden". -
Step 1: Write the failing tests
Add to tests/test_dimensions.gd:
func test_warden_spawns_as_generic_dimension_boss_with_no_element() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) # fresh sim -- current_area == "home" == Generic now sim._spawn_phase_boss() var bi := sim.boss_rotation.boss_index(sim) assert_ne(bi, -1, "Generic's boss (Warden) spawned") assert_eq(sim.enemies.aura_element[bi], -1, "Generic has no element -- Warden spawns auraless")
func test_warden_spawns_as_fire_dimension_boss_re_elemented() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.FIRE) sim._spawn_phase_boss() var bi := sim.boss_rotation.boss_index(sim) assert_ne(bi, -1, "Fire's boss (Warden) spawned") assert_eq(sim.enemies.aura_element[bi], content.element_index("fire"), "Fire's Warden is fire-elemental, not the old void default")
func test_warden_kill_in_fire_awards_a_dimension_bonus() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.FIRE) sim.run_time = 50.0 sim._spawn_phase_boss() var gold_before := sim.run_gold var fx_before := sim.fx_events.size() sim.enemies.data[sim.boss_rotation.boss_index(sim)] = 0.0 sim._sweep_dead() var expected_gold := gold_before \ + int(round(float(Sim.GOLD_PER_KILL) * sim.area_reward_mult)) \ + int(round(float(Sim.BOSS_GOLD) * sim.area_reward_mult)) \ + Sim.CRYSTAL_GOLD_BONUS assert_eq(sim.run_gold, expected_gold, "Warden's kill in Fire pays the normal reward PLUS the Dimension crystal bonus") assert_eq(sim.fx_events.size(), fx_before + 2 + Sim.DIMENSION_CRYSTAL_COUNT, "death fx + \"BOSS DOWN\" fx + one burst per crystal")
func test_spawn_due_elites_suppressed_in_fire_and_generic_too() -> void: # _spawn_due_elites is gated on is_dimension(current_area) -- now true for home/fire too, # so the mid-wave-elite Warden/Boss2 path must be dead in BOTH (Warden only ever appears # there via the dimension-boss dispatch above now). var content := ContentLoader.load_from_path("res://data/bible.json") for id in [AreaDefs.HOME, AreaDefs.FIRE]: var sim := Sim.new(1234, content) sim.enter_area(id) sim.run_time = maxf(Sim.ELITE_WARDEN_TIME, Sim.ELITE_BOSS2_TIME) + 1.0 sim._spawn_due_elites() assert_eq(sim.boss_rotation.boss_index(sim), -1, "%s: no mid-wave Warden elite" % id) assert_eq(sim.boss_rotation.boss2_index(sim), -1, "%s: no mid-wave Boss2 elite" % id)
func test_spawn_due_elites_unchanged_outside_any_dimension() -> void: # Aurora is the genuinely non-Dimension area now (home became one) -- proves the guard # still lets the ordinary generic-elite spawn happen somewhere. var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.AURORA) sim.run_time = maxf(Sim.ELITE_WARDEN_TIME, Sim.ELITE_BOSS2_TIME) + 1.0 sim._spawn_due_elites() assert_ne(sim.boss_rotation.boss_index(sim), -1, "the Warden elite still spawns normally in Aurora") assert_ne(sim.boss_rotation.boss2_index(sim), -1, "the Boss2 elite still spawns normally in Aurora")
func test_warden_kill_in_aurora_awards_no_dimension_bonus() -> void: # Aurora is not a Dimension -- the mid-wave-elite Warden there must still pay ONLY the # ordinary reward, same regression this replaces from the old Pyre-based test. var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.AURORA) sim.run_time = 50.0 sim.boss_warden.spawn(sim) var gold_before := sim.run_gold var fx_before := sim.fx_events.size() sim.enemies.data[sim.boss_rotation.boss_index(sim)] = 0.0 sim._sweep_dead() var expected_gold := gold_before \ + int(round(float(Sim.GOLD_PER_KILL) * sim.area_reward_mult)) \ + int(round(float(Sim.BOSS_GOLD) * sim.area_reward_mult)) assert_eq(sim.run_gold, expected_gold, "no Dimension crystal bonus outside a Dimension") assert_eq(sim.fx_events.size(), fx_before + 2, "just the death fx + \"BOSS DOWN\" fx")DELETE the old test_warden_kill_inside_a_dimension_awards_no_dimension_bonus and
test_spawn_due_elites_suppressed_inside_a_dimension/test_spawn_due_elites_unchanged_outside_a_dimension
(superseded by the rewritten versions above). KEEP test_boss2_kill_inside_a_dimension_awards_no_dimension_bonus
unchanged except renaming its AreaDefs.PYRE reference to AreaDefs.FIRE — Boss2 never gets a
Dimension role in this plan, so that regression is still valid and still exercisable (its test
calls boss2_director.spawn directly, bypassing the now-fully-suppressed _spawn_due_elites path).
In tests/test_boss_gate.gd, update:
func test_spawn_phase_boss_spawns_the_dimension_owned_boss_inside_a_dimension() -> void: var s := Sim.new(1, _content()) s.enter_area(AreaDefs.VOID_DIM) s._spawn_phase_boss() assert_gte(s.boss_rotation.graviton_index(s), 0, "Void's boss is Graviton") var i := s.boss_rotation.graviton_index(s) assert_eq(s.enemies.aura_element[i], _content().element_index("void"), "Void's Graviton is void-elemental (its own native element, unchanged)")
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.VOID_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.enter_area(AreaDefs.AURORA) # home is now a Dimension (Generic) -- use a genuinely plain area s._boss_gate_count = 0 s._spawn_phase_boss() assert_gte(s.boss_rotation.funzo_index(s), 0, "aurora still gets the ordinary FunZo-first rotation") assert_eq(s._boss_gate_count, 1, "the generic counter still advances outside a Dimension")Leave test_funzo_spawns_with_an_overridden_element, test_funzo_default_element_unchanged,
and test_spawn_dimension_boss_fails_loud_on_an_unrecognized_boss_id unchanged (none reference
the renamed constants or Warden).
- Step 2: Run the new/changed tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_boss_gate.gd -gexit
Expected: FAIL — _spawn_dimension_boss doesn’t recognize "warden" yet (push_error + no
boss spawned), so every Warden-in-a-Dimension test fails; test_warden_kill_in_aurora_awards_no_dimension_bonus
should already PASS (it exercises the unchanged mid-wave path in a still-non-dimension area) —
that’s fine, it’s a carried-over regression, not a new behavior.
- Step 3: Parameterize
BossWarden.spawn’s element
In sim/boss_warden.gd, change:
func spawn(sim: Sim, hp_mult: float = 1.0) -> void: var pos := sim._nearest_pilot(Vector2.ZERO).pos + sim.rng.rand_unit_dir() * 640.0 var hp := BOSS_HP * hp_mult sim.enemies.add(pos, Vector2.ZERO, BOSS_RADIUS, hp, BOSS_ARMOR, BOSS_SPEED, BOSS_CONTACT_DMG, BOSS_XP, EnemyPool.TYPE_BOSS, sim.content.element_index("void"), EnemyPool.BEHAVIOR_BOSS) sim.boss.reset() sim.boss.max_hp = hp # enrage threshold tracks the scaled HP sim._boss_spawn_count += 1 sim.fx_events.append({"kind": "reaction", "pos": pos, "element": -1, "name": "BOSS"})to:
# element_idx == -1 (the default) keeps the old behaviour: void, for _spawn_due_elites'# mid-wave-elite call site (unparameterized, matches every existing caller). A Dimension's# boss dispatch passes its own dimension's element instead (see Sim._spawn_dimension_boss).func spawn(sim: Sim, hp_mult: float = 1.0, element_idx: int = -1) -> void: var pos := sim._nearest_pilot(Vector2.ZERO).pos + sim.rng.rand_unit_dir() * 640.0 var hp := BOSS_HP * hp_mult var el := element_idx if element_idx != -1 else sim.content.element_index("void") sim.enemies.add(pos, Vector2.ZERO, BOSS_RADIUS, hp, BOSS_ARMOR, BOSS_SPEED, BOSS_CONTACT_DMG, BOSS_XP, EnemyPool.TYPE_BOSS, el, EnemyPool.BEHAVIOR_BOSS) sim.boss.reset() sim.boss.max_hp = hp # enrage threshold tracks the scaled HP sim._boss_spawn_count += 1 sim.fx_events.append({"kind": "reaction", "pos": pos, "element": -1, "name": "BOSS"})- Step 4: Add the
"warden"dispatch arm
In sim/sim.gd’s _spawn_dimension_boss (~line 1276), change:
func _spawn_dimension_boss(boss_id: String, pos: Vector2, element_idx: int) -> void: match boss_id: "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) _: push_error("Sim._spawn_dimension_boss: unrecognized boss id '%s' for area '%s' -- no boss spawned" % [boss_id, current_area])to (adding the "warden" arm — note it ignores pos, matching boss_warden.spawn’s existing
self-computed-position behavior, same as every other pre-existing call to it):
func _spawn_dimension_boss(boss_id: String, pos: Vector2, element_idx: int) -> void: match boss_id: "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) "warden": boss_warden.spawn(self, 1.0, element_idx) _: push_error("Sim._spawn_dimension_boss: unrecognized boss id '%s' for area '%s' -- no boss spawned" % [boss_id, current_area])- Step 5: Award dimension crystals on a Warden kill
In sim/sim.gd’s _sweep_dead, the TYPE_BOSS death branch (~line 1659), change:
if dead_type == EnemyPool.TYPE_BOSS: # Big reward + schedule the next boss; clear the boss state. # NOTE: no _award_dimension_crystals() here -- the Warden spawns via the # Dimension-unaware _spawn_due_elites() (run_time-gated only), so it can die # while the player merely happens to be inside a Dimension without ever being # that Dimension's assigned boss. See _award_dimension_crystals's callers. run_gold += int(round(float(BOSS_GOLD) * area_reward_mult)) _next_boss_time = run_time + BOSS_INTERVAL boss.reset() fx_events.append({"kind": "reaction", "pos": dead_pos, "element": -1, "name": "BOSS DOWN"})to:
if dead_type == EnemyPool.TYPE_BOSS: # Big reward + schedule the next boss; clear the boss state. Warden is now # ALSO a real Dimension boss (Generic + Fire, via _spawn_dimension_boss) -- # _award_dimension_crystals is itself a no-op outside a Dimension (checked # internally), and _spawn_due_elites can no longer spawn Warden inside any # Dimension (is_dimension() now covers Generic/Fire too, same guard as # Graviton/FunZo/Eye), so this call is safe unconditionally. 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"})- Step 6: Run the tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: PASS. tests/test_content_db.gd and tests/test_wormhole_renderer.gd still fail
(fixed in Task 7 and not touched by this task) — everything else must be green.
- Step 7: Re-verify determinism
Run both determinism test files as in Task 1’s Step 4. Expected: PASS, same pinned values —
Warden’s dimension-boss dispatch only fires post-boss-death, past BOSS_PREP_S/ELITE_WARDEN_TIME,
both well outside the 600-tick baseline window.
- Step 8: Commit
git add sim/boss_warden.gd sim/sim.gd tests/test_dimensions.gd tests/test_boss_gate.gdgit commit -m "feat(dimensions): wire Warden as Generic + Fire's dimension boss, award crystals on its kill"Task 3: Enemy element override at spawn
Section titled “Task 3: Enemy element override at spawn”Files:
- Modify:
sim/sim.gd(new_element_for_spawnhelper;_spawn_one~line 1085 uses it) - Test:
tests/test_dimensions.gd(new tests)
Interfaces:
-
Consumes:
AreaDefs.enemy_element_override/is_dimension(Task 1),content.element_index. -
Produces:
Sim._element_for_spawn(tid: int) -> int. -
Step 1: Write the failing tests
Add to tests/test_dimensions.gd:
func test_element_for_spawn_uses_override_inside_a_dimension() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.FIRE) # TYPE_ELITE ("Charger") is native void; Fire overrides it to fire. assert_eq(sim._element_for_spawn(EnemyPool.TYPE_ELITE), content.element_index("fire"))
func test_element_for_spawn_falls_back_to_native_when_no_override() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.FIRE) # TYPE_SWARMER is native fire already, and Fire's roster doesn't override it. assert_eq(sim._element_for_spawn(EnemyPool.TYPE_SWARMER), content.element_index("fire"))
func test_element_for_spawn_native_outside_any_dimension() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.AURORA) assert_eq(sim._element_for_spawn(EnemyPool.TYPE_ELITE), content.element_index("void"), "outside any Dimension, every type spawns at its own bible-native element")
func test_spawn_one_in_void_dim_reflavors_orbiter_to_void() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.VOID_DIM) sim._spawn_one(EnemyPool.TYPE_ORBITER, Vector2.ZERO) assert_eq(sim.enemies.count, 1) assert_eq(sim.enemies.base_element[0], content.element_index("void"), "Void reflavors the (native-cold) Orbiter to void")- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: FAIL — _element_for_spawn doesn’t exist yet.
- Step 3: Implement
In sim/sim.gd, add near _spawn_one (~line 1035, just above it):
# The element `tid` should spawn at right now: the current Dimension's override if one is# set for this type, else the enemy's own bible-native element (_enemy_base_el). Outside# any Dimension, AreaDefs.enemy_element_override always returns "" (no dimension has an# entry to look up), so this is a no-op fall-through to the native element everywhere else.func _element_for_spawn(tid: int) -> int: var override: String = AreaDefs.enemy_element_override(current_area, tid) if override != "": return content.element_index(override) return _enemy_base_el[tid]Then in _spawn_one (~line 1076-1089), change the enemies.add(...) call’s element argument
from _enemy_base_el[tid] to _element_for_spawn(tid):
var idx := enemies.add( pos, Vector2.ZERO, v["radius"] * e_r, v["hp"] * diff * e_hp, float(e.get("armor", 0.0)), v["speed"] * spd_mult * e_spd, v["contact"] * area_difficulty_mult * LETHALITY_MULT * e_c, float(e["xp_value"]) * e_xp, tid, _element_for_spawn(tid), _enemy_behavior[tid], flank_v, biomass_v )- Step 4: Run to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: PASS.
- Step 5: Full suite + determinism re-check
Run the full suite, then both determinism test files. Expected: all PASS, same pinned values —
_element_for_spawn returns _enemy_base_el[tid] unchanged for every type in every non-Dimension
area (including aurora/ember_reach, which the baseline run never leaves anyway), and for
every type Fire/Void/Light DON’T override; the baseline’s own 600-tick/seed-1234 run stays in
home, and home’s roster (Swarmer/Shooter/Charger) has no enemy_elements overrides in Task 1’s
data (all 3 keep their native element) — so no enemy in the baseline window changes element.
- Step 6: Commit
git add sim/sim.gd tests/test_dimensions.gdgit commit -m "feat(dimensions): enemy element override at spawn -- each Dimension can reflavor a shared enemy type"Task 4: Per-dimension enemy stat-buff framework (hp / speed / contact / size)
Section titled “Task 4: Per-dimension enemy stat-buff framework (hp / speed / contact / size)”Files:
- Modify:
sim/sim.gd(new_dim_buffhelper;_spawn_oneapplies the 4 spawn-time multipliers) - Test:
tests/test_dimensions.gd
Interfaces:
-
Consumes:
AreaDefs.enemy_buff(Task 1). -
Produces:
Sim._dim_buff(key: String) -> float. -
Step 1: Write the failing tests
func test_dim_buff_defaults_to_one_when_unset() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.enter_area(AreaDefs.AURORA) assert_almost_eq(sim._dim_buff("hp_mult"), 1.0, 0.0001) assert_almost_eq(sim._dim_buff("speed_mult"), 1.0, 0.0001)
func test_dim_buff_reads_the_current_dimension() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.enter_area(AreaDefs.VOID_DIM) assert_almost_eq(sim._dim_buff("size_mult"), AreaDefs.enemy_buff(AreaDefs.VOID_DIM, "size_mult"), 0.0001) assert_almost_eq(sim._dim_buff("damage_mult"), AreaDefs.enemy_buff(AreaDefs.VOID_DIM, "damage_mult"), 0.0001)
func test_spawn_one_applies_size_and_damage_buffs_in_void() -> void: var sim := Sim.new(1234, SimContentFixture.db()) var home_sim := Sim.new(1234, SimContentFixture.db()) # same seed -- same _vary_stats roll sim.enter_area(AreaDefs.VOID_DIM) sim._spawn_one(EnemyPool.TYPE_ELITE, Vector2.ZERO) home_sim.enter_area(AreaDefs.AURORA) home_sim._spawn_one(EnemyPool.TYPE_ELITE, Vector2.ZERO) assert_gt(sim.enemies.radius[0], home_sim.enemies.radius[0], "Void's size_mult grows the radius") assert_gt(sim.enemies.contact_dmg[0], home_sim.enemies.contact_dmg[0], "Void's damage_mult grows contact damage")- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: FAIL — _dim_buff doesn’t exist; the buff-comparison test fails (both sims produce
identical stats today).
- Step 3: Implement
In sim/sim.gd, add next to _element_for_spawn:
# A per-dimension enemy stat multiplier for the CURRENT area (1.0 = no-op). Thin wrapper so# every call site reads naturally as "the current dimension's buff," not a 2-arg static call.func _dim_buff(key: String) -> float: return AreaDefs.enemy_buff(current_area, key)In _spawn_one, apply hp_mult/speed_mult/damage_mult/size_mult alongside the existing
diff/e_hp/e_c/e_r multipliers (change the enemies.add(...) call from Task 3’s version to):
var idx := enemies.add( pos, Vector2.ZERO, v["radius"] * e_r * _dim_buff("size_mult"), v["hp"] * diff * e_hp * _dim_buff("hp_mult"), float(e.get("armor", 0.0)), v["speed"] * spd_mult * e_spd * _dim_buff("speed_mult"), v["contact"] * area_difficulty_mult * LETHALITY_MULT * e_c * _dim_buff("damage_mult"), float(e["xp_value"]) * e_xp, tid, _element_for_spawn(tid), _enemy_behavior[tid], flank_v, biomass_v )- Step 4: Run to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: PASS.
- Step 5: Full suite + determinism re-check
Run the full suite, then both determinism test files. Expected: all PASS, same pinned values —
home’s (AreaDefs.HOME) enemy_buffs is unset in Task 1’s data, so _dim_buff returns 1.0
for every key there, an exact no-op multiply — the baseline run (which stays in home) is
byte-identical.
- Step 6: Commit
git add sim/sim.gd tests/test_dimensions.gdgit commit -m "feat(dimensions): per-dimension enemy stat-buff framework (hp/speed/damage/size at spawn)"Task 5: Fire-rate / projectile-speed buffs for Shooter, Lancer, Orbiter
Section titled “Task 5: Fire-rate / projectile-speed buffs for Shooter, Lancer, Orbiter”Files:
- Modify:
sim/enemy_attacks.gd(update_shooters~line 85,update_lancers~line 283,update_orbiters~line 229 — each readssim._dim_buff(...)at its rate/speed/damage point) - Test:
tests/test_dimensions.gd
Interfaces:
- Consumes:
Sim._dim_buff(Task 4). - Produces: nothing new — these 3 functions now respond to
fire_rate_mult/proj_speed_mult/damage_multwhen the enemy’s current dimension sets them.
update_ranged (zapper/scatterer/bomber) is untouched — no dimension in this phase rosters any
of those 3 types (Fire/Void/Light/Generic all use Shooter/Lancer/Orbiter for their ranged
threats, never Zapper/Scatterer/Bomber), so wiring it would be dead code until a future
dimension needs it.
- Step 1: Write the failing tests
Sim.tick() calls these via the enemy_attacks: EnemyAttacks instance field (sim/sim.gd:309,523
— enemy_attacks = EnemyAttacks.new(), called as enemy_attacks.update_shooters(self, edt) etc.
at sim/sim.gd:703-708). SHOOTER_FIRE_INTERVAL/SHOOTER_PROJ_SPEED are consts on EnemyAttacks
itself (sim/enemy_attacks.gd:22-23), not Sim.
func test_shooter_fire_rate_and_proj_speed_buffed_in_fire() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.enter_area(AreaDefs.FIRE) sim.enemies.add(Vector2.ZERO, Vector2.ZERO, 14.0, 8.0, 0.0, 55.0, 10.0, 3.0, EnemyPool.TYPE_SHOOTER, sim._element_for_spawn(EnemyPool.TYPE_SHOOTER)) var interval := EnemyAttacks.SHOOTER_FIRE_INTERVAL / sim._dim_buff("fire_rate_mult") sim.enemy_attacks.update_shooters(sim, interval) # exactly one interval's worth of dt assert_eq(sim.enemy_proj.count, 1, "the buffed (shorter) interval elapsed -- it fired") var expected_speed: float = EnemyAttacks.SHOOTER_PROJ_SPEED * sim._dim_buff("proj_speed_mult") assert_almost_eq(sim.enemy_proj.vel[0].length(), expected_speed, 0.5)
func test_lancer_fire_rate_and_damage_buffed_in_light() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.enter_area(AreaDefs.LIGHT) var lancer_def := sim.content.enemy("lancer") var base_interval: float = float(lancer_def["beam_interval"]) var expected_interval: float = base_interval / sim._dim_buff("fire_rate_mult") assert_lt(expected_interval, base_interval, "Light's fire_rate_mult shortens the beam interval")
func test_orbiter_damage_and_spin_buffed_in_void() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.enter_area(AreaDefs.VOID_DIM) var orbiter_def := sim.content.enemy("orbiter") var base_dmg: float = float(orbiter_def["orbit_damage"]) var base_spin: float = float(orbiter_def["orbit_spin"]) assert_gt(base_dmg * sim._dim_buff("damage_mult"), base_dmg, "Void's damage_mult raises orbit_damage") assert_gt(base_spin * sim._dim_buff("fire_rate_mult"), base_spin, "Void has no fire_rate_mult set -- stays 1.0, this just documents the read point exists")
func test_lancer_beam_fx_uses_the_spawned_instances_own_element_not_the_native_type() -> void: # Regression: update_lancers' beam fx event previously read _enemy_base_el[TYPE_LANCER] # (the type's fixed native element) instead of the actual spawned instance's element -- # invisible until a Dimension could reflavor Lancer to a non-native element (Fire does). var sim := Sim.new(1234, SimContentFixture.db()) sim.enter_area(AreaDefs.FIRE) # Fire overrides Lancer (native light) to fire sim._spawn_one(EnemyPool.TYPE_LANCER, Vector2(200.0, 0.0)) sim.player.pos = Vector2.ZERO var lancer_def := sim.content.enemy("lancer") var charge_t: float = float(lancer_def["beam_charge"]) sim.enemy_attacks.update_lancers(sim, float(lancer_def["beam_interval"]) * 0.5 + 0.01) # idle -> charge sim.enemy_attacks.update_lancers(sim, charge_t + 0.01) # charge -> fire, emits the beam fx var beam_ev: Dictionary = {} for ev in sim.fx_events: if String(ev.get("kind", "")) == "beam": beam_ev = ev assert_eq(int(beam_ev.get("element", -2)), sim.content.element_index("fire"), "the beam fx is tinted with THIS Lancer's actual (fire, overridden) element")- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: FAIL — buffs aren’t read by these functions yet (both sims/dimensions behave identically to unbuffed).
- Step 3: Implement —
update_shooters
In sim/enemy_attacks.gd’s update_shooters (~line 107-121), change the interval check and
the fired shot’s speed/damage:
var interval: float = Sim.SHOOTER_FIRE_INTERVAL / sim._dim_buff("fire_rate_mult") var elapsed: float = sim._shooter_timers.get(eid, 0.0) + dt if elapsed >= interval: elapsed = 0.0 var is_elite_shooter := sim.enemies.is_elite[i] == 1 var target_pilot := sim._nearest_pilot(sim.enemies.pos[i]) var target_pos := target_pilot.pos var target_vel: Vector2 = pilot_vel.get(target_pilot, Vector2.ZERO) var aim_pos := (target_pos + target_vel * ELITE_SHOOTER_LEAD_S) if is_elite_shooter else target_pos var dir := (aim_pos - sim.enemies.pos[i]).normalized() var dmg := sim.SHOOTER_PROJ_DAMAGE * (ELITE_SHOOTER_DAMAGE_MULT if is_elite_shooter else 1.0) * sim._dim_buff("damage_mult") var spd := SHOOTER_PROJ_SPEED * sim._dim_buff("proj_speed_mult") sim.enemy_proj.add(sim.enemies.pos[i], dir * spd, sim.SHOOTER_PROJ_RADIUS, sim.SHOOTER_PROJ_LIFETIME, dmg, 0, sim.ENEMY_PROJ_KNOCKBACK, sim.enemies.type_id[i], sim.enemies.base_element[i]) sim.fx_events.append({"kind": "enemy_ranged_fire", "pos": sim.enemies.pos[i], "element": -1}) next_timers[eid] = elapsed(Only the interval/dmg/added spd lines and the enemy_proj.add call change; everything
else in the loop body is unchanged from today.)
- Step 4: Implement —
update_lancersandupdate_orbiters
In sim/enemy_attacks.gd’s update_lancers (~line 292-298), change:
var e: Dictionary = sim._enemy_types[EnemyPool.TYPE_LANCER] var beam_interval: float = float(e.get("beam_interval", 3.5)) var beam_charge_t: float = float(e.get("beam_charge", 1.1)) var beam_active_t: float = float(e.get("beam_active", 0.25)) var beam_range_v: float = float(e.get("beam_range", 620.0)) var beam_width_v: float = float(e.get("beam_width", 16.0)) var beam_dmg: float = float(e.get("beam_damage", 16.0))to:
var e: Dictionary = sim._enemy_types[EnemyPool.TYPE_LANCER] var beam_interval: float = float(e.get("beam_interval", 3.5)) / sim._dim_buff("fire_rate_mult") var beam_charge_t: float = float(e.get("beam_charge", 1.1)) var beam_active_t: float = float(e.get("beam_active", 0.25)) var beam_range_v: float = float(e.get("beam_range", 620.0)) var beam_width_v: float = float(e.get("beam_width", 16.0)) var beam_dmg: float = float(e.get("beam_damage", 16.0)) * sim._dim_buff("damage_mult")Then, in the "fire" phase branch (~line 340-345), fix a real (previously invisible) bug found
while reading this function during planning: the beam’s fx event tints itself using the
LANCER TYPE’s fixed native element (sim._enemy_base_el[EnemyPool.TYPE_LANCER]), not the actual
spawned instance’s element — invisible until now, because no Dimension could reflavor Lancer to
a non-native element before this plan. Fire does exactly that (Lancer is native light, Fire
overrides it to fire), so this must read the per-instance column instead. Change:
sim.fx_events.append({"kind": "beam", "pos": lpos, "dir": aim2, "length": beam_range_v, "element": sim._enemy_base_el[EnemyPool.TYPE_LANCER]})to:
sim.fx_events.append({"kind": "beam", "pos": lpos, "dir": aim2, "length": beam_range_v, "element": sim.enemies.base_element[i]})In update_orbiters (~line 238-242), change:
var e: Dictionary = sim._enemy_types[EnemyPool.TYPE_ORBITER] var shards: int = int(e.get("orbit_shards", 3)) var orbit_r: float = float(e.get("orbit_radius", 62.0)) var spin: float = float(e.get("orbit_spin", 2.5)) var orbit_dmg: float = float(e.get("orbit_damage", 6.0))to:
var e: Dictionary = sim._enemy_types[EnemyPool.TYPE_ORBITER] var shards: int = int(e.get("orbit_shards", 3)) var orbit_r: float = float(e.get("orbit_radius", 62.0)) var spin: float = float(e.get("orbit_spin", 2.5)) * sim._dim_buff("fire_rate_mult") var orbit_dmg: float = float(e.get("orbit_damage", 6.0)) * sim._dim_buff("damage_mult")(Orbiter has no “shooting” concept — mapping its spin rate to fire_rate_mult matches Light’s
“fire rate, damage” buff pair thematically; orbiter_shard_render‘s own copy of orbit_r is
render-only positioning and does NOT need the buff — only update_orbiters’ damage-dealing copy
does, since visual shard position doesn’t need to match a gameplay multiplier.)
- Step 5: Run to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: PASS.
- Step 6: Full suite + determinism re-check
Run the full suite, then both determinism test files. Expected: all PASS, same pinned values —
_dim_buff returns 1.0 for every key outside Fire/Void/Light (including the baseline’s home
run), so every multiplied expression above is unchanged for the baseline.
- Step 7: Commit
git add sim/enemy_attacks.gd tests/test_dimensions.gdgit commit -m "feat(dimensions): fire-rate/proj-speed/damage buffs reach Shooter, Lancer, Orbiter"Task 6: Hazards — Fire (unchanged), Void (pull + visibility), Light (new, element-tinted)
Section titled “Task 6: Hazards — Fire (unchanged), Void (pull + visibility), Light (new, element-tinted)”Files:
- Modify:
sim/sim.gd(_update_dimension_hazardmatch ~line 1302; rename_spawn_aether_hazard→_spawn_visibility_hazard; add_spawn_light_hazard) - Modify:
render/bomb_renderer.gd(element-aware tint, backward compatible) - Test:
tests/test_dimensions.gd
Interfaces:
- Consumes:
AreaDefs.FIRE/VOID_DIM/LIGHT(Task 1). - Produces:
Sim._spawn_visibility_hazard()(renamed from_spawn_aether_hazard),Sim._spawn_light_hazard()(new)._spawn_fire_hazard/_spawn_void_hazardkeep their existing names and bodies verbatim.
Known limitation, documented rather than fixed here (out of scope): update_bombs’s own
detonation-flash fx_events.append(...) hardcodes sim.nova_element_idx (fire) for EVERY bomb
regardless of source — a pre-existing quirk affecting real Bomber enemies too, not something
this task introduces. Light’s hazard will show a light-tinted telegraph circle (via the render
change below) but a fire-colored detonation flash. Fixing the shared detonation-flash color is
unrelated pre-existing tech debt, not part of this plan.
- Step 1: Write the failing tests
func test_visibility_hazard_fires_in_void_alongside_the_pull() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.VOID_DIM) sim._dimension_hazard_timer = 0.001 sim._update_dimension_hazard(0.1) var kinds: Array = [] for ev in sim.fx_events: kinds.append(String(ev.get("kind", ""))) assert_true(kinds.has("reaction"), "the gravity-pull hazard still fires its RIFT reaction fx") assert_true(kinds.has("phase_flicker"), "the visibility hazard fires alongside it")
func test_light_hazard_queues_a_light_tinted_telegraphed_bomb() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.LIGHT) 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, "Light's hazard queues exactly one telegraphed strike") var bm: Dictionary = sim.bombs[sim.bombs.size() - 1] assert_eq(int(bm.get("element", -1)), content.element_index("light"), "Light's hazard is tagged with its own element for the renderer to tint")
func test_fire_hazard_unchanged() -> void: # Regression: Fire's hazard keeps behaving exactly like the old Pyre hazard (no element tag). var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) sim.enter_area(AreaDefs.FIRE) 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) var bm: Dictionary = sim.bombs[sim.bombs.size() - 1] assert_false(bm.has("element"), "Fire's hazard is untagged, same as the original Pyre hazard")Update the existing test_hazard_fires_at_most_once_per_interval_in_a_dimension and
test_hazard_does_not_fire_while_a_boss_is_alive to use AreaDefs.VOID_DIM (rename from
AreaDefs.NULL_DIM; both already use Null/void specifically, per their own comments, so this
is a pure rename) and test_fire_hazard_queues_a_telegraphed_bomb_not_an_immediate_fx_event
to use AreaDefs.FIRE (rename from AreaDefs.PYRE). test_hazard_never_fires_outside_a_dimension
is unaffected (still uses a fresh sim on home, whose hazard dispatch has no matching arm — see
Task 2’s note that Generic has no hazard). Update
test_dimension_boss_kill_awards_bonus_gold_and_a_tinted_burst to use AreaDefs.LIGHT instead of
AreaDefs.DRIFT (rename; the assertion body — checking the burst is tinted with the current
dimension’s own element — is otherwise unchanged, just swap the expected element string from
"aether" to "light").
- Step 2: Run to verify new/changed tests fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: FAIL — _spawn_light_hazard doesn’t exist; _update_dimension_hazard’s match still
uses the old PYRE/NULL_DIM/DRIFT keys (which no longer exist as constants, so this file
currently fails to even compile until Step 3 lands — expected at this stage).
- Step 3: Rewrite the hazard dispatch + functions
In sim/sim.gd, change the match statement (~line 1302-1308):
match current_area: AreaDefs.PYRE: _spawn_fire_hazard() AreaDefs.NULL_DIM: _spawn_void_hazard() AreaDefs.DRIFT: _spawn_aether_hazard()to:
match current_area: AreaDefs.FIRE: _spawn_fire_hazard() AreaDefs.VOID_DIM: _spawn_void_hazard() _spawn_visibility_hazard() AreaDefs.LIGHT: _spawn_light_hazard()Rename _spawn_aether_hazard → _spawn_visibility_hazard (body unchanged, doc comment updated
— it’s exactly the same render-only vision-flicker fx event, just no longer tied to the retired
Drift/aether dimension):
# Void's "poor visibility": render-only vision flicker, no damage -- carried over verbatim# from the original Drift/aether hazard (that dimension is retired; this effect wasn't).# Purely an fx_event; fx/fx_manager.gd's consumer handles the actual visual.func _spawn_visibility_hazard() -> void: fx_events.append({"kind": "phase_flicker", "pos": player.pos, "element": content.element_index("light")})Wait — Void’s visibility flicker should be tagged with VOID’s own element, not light’s (it was
content.element_index("aether") for the old Drift hazard, tagged with Drift’s own element).
Use Void’s element:
func _spawn_visibility_hazard() -> void: fx_events.append({"kind": "phase_flicker", "pos": player.pos, "element": content.element_index(AreaDefs.element_for(current_area))})Add the new Light hazard next to _spawn_fire_hazard:
# Light: a warned telegraphed strike, reusing the exact same bombs mechanism Fire's hazard# uses (telegraph -> AoE, via update_bombs()/render/bomb_renderer.gd) but tagged with an# "element" key so the renderer tints it distinctly (see BombRenderer's element-aware change)# instead of reading as another fireball.func _spawn_light_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, "element": content.element_index("light")})- Step 4: Make
BombRendererelement-aware
In render/bomb_renderer.gd’s _draw(), change the color computation (find the ic/rc lerp
lines) from the fixed-palette lookup to an element-aware one when the bomb dict carries one:
var max_delay: float = float(bm.get("max_delay", 1.2)) var remaining: float = clampf(float(bm["delay"]), 0.0, max_delay) var frac: float = 1.0 - remaining / max_delay # 0→1 as it counts down var el: int = int(bm.get("element", -1)) var base_ic := ElementPalette.color_for(_sim.content, el) if el >= 0 else WARN_INNER var base_rc := ElementPalette.color_for(_sim.content, el).lightened(0.3) if el >= 0 else WARN_RING var ic := base_ic.lerp(FULL_INNER, frac) var rc := base_rc.lerp(FULL_RING, frac)(A bomb with no "element" key — every existing real Bomber enemy spawn and Fire’s own
hazard — falls through to the exact original WARN_INNER/WARN_RING constants, byte-identical
to today’s rendering.)
- Step 5: Run to verify tests pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dimensions.gd -gexit
Expected: PASS.
- Step 6: Full suite + determinism re-check
Run the full suite, then both determinism test files. Expected: all PASS, same pinned values —
the hazard timer is gated identically to before (boss-death-reachable areas only, never home’s
baseline window), and BombRenderer is render-only (not part of snapshot_string/state_checksum).
- Step 7: Commit
git add sim/sim.gd render/bomb_renderer.gd tests/test_dimensions.gdgit commit -m "feat(dimensions): Void gets a visibility hazard alongside its pull, Light gets an element-tinted telegraphed strike"Task 7: Weapon gating — move restriction from the weapon side to the dimension side
Section titled “Task 7: Weapon gating — move restriction from the weapon side to the dimension side”Files:
- Modify:
sim/content_db.gd(deleteweapon_matches_dimension; rewriteweapon_available_in) - Modify:
tests/test_content_db.gd(rewrite the 2 tests that exercised the old mechanism) - Modify:
tests/test_wormhole_renderer.gd(renamePYRE/NULL_DIM/DRIFTif present — verify during Step 1)
Interfaces:
-
Consumes:
AreaDefs.weapons_for(Task 1). -
Produces:
ContentDB.weapon_available_in(weapon_id: String, dimension_id: String) -> bool(same signature, new implementation — the ONE existing caller,sim/upgrade_system.gd:75, needs no change). -
Step 1:
tests/test_wormhole_renderer.gdneeds no changes (verified during planning)
It contains raw string literals "pyre"/"null_dim" (lines 12-13, 25) as synthetic fixture
dest/name values passed directly to the renderer under test — it never references the
AreaDefs.PYRE/NULL_DIM/DRIFT constants or any real AreaDefs data, so it’s fully decoupled
from this plan’s rename and needs no edits. Just include it in Step 3’s full-suite run to
confirm it’s still green (it should already be, untouched).
- Step 2: Write the failing tests
Replace test_weapon_with_no_dimension_field_is_available_everywhere and
test_a_dimension_restricted_weapon_is_gated_correctly in tests/test_content_db.gd with:
func test_weapon_available_everywhere_when_dimension_has_no_weapon_list() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") for wid in ["pulse", "nova", "orbit", "beam", "turret", "scatter", "blade"]: assert_true(content.weapon_available_in(wid, AreaDefs.HOME), "Generic has no weapon list -- everything is available") assert_true(content.weapon_available_in(wid, ""))
func test_weapon_available_in_gates_to_the_dimensions_curated_list() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") assert_true(content.weapon_available_in("nova", AreaDefs.FIRE), "Nova is Fire's native weapon") assert_true(content.weapon_available_in("turret", AreaDefs.FIRE), "Turret is one of Fire's temp fillers") assert_false(content.weapon_available_in("beam", AreaDefs.FIRE), "Beam isn't in Fire's curated list") assert_true(content.weapon_available_in("beam", AreaDefs.LIGHT), "Beam is Light's native weapon") assert_true(content.weapon_available_in("orbit", AreaDefs.VOID_DIM), "Orbit is one of Void's temp fillers") assert_false(content.weapon_available_in("nova", AreaDefs.VOID_DIM), "Nova isn't in Void's curated list")
func test_weapon_available_in_unknown_dimension_fails_open() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") assert_true(content.weapon_available_in("nova", "not_a_real_dimension"), "an unrecognized dimension id has no weapon list -- fails open, matches this codebase's other lookups")- Step 3: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_db.gd -gexit
Expected: FAIL — weapon_available_in still checks the old weapon-side field, which none of
these weapons set, so it currently returns true for every case (including the ones this task
expects false for).
- Step 4: Implement
In sim/content_db.gd, delete weapon_matches_dimension entirely (lines ~34-41 — it’s dead in
practice, no weapon in bible.json ever sets a "dimension" field) and replace
weapon_available_in (~line 43-47):
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)with:
# A Dimension's optional "weapons" list (AreaDefs.weapons_for) curates a small allow-list --# absent/empty means unrestricted (every non-Dimension area, plus Generic, plus any future# Dimension that doesn't bother curating one). This replaced a weapon-side "dimension" field# in 2026-07 (docs/superpowers/specs/2026-07-05-elemental-dimensions-v2-design.md) because a# single weapon can now be a temporary filler in MULTIPLE Dimensions at once (e.g. Blade in# both Void and Light) -- a one-weapon-one-dimension field couldn't express that.func weapon_available_in(weapon_id: String, dimension_id: String) -> bool: var list := AreaDefs.weapons_for(dimension_id) if list.is_empty(): return true for w in list: if String(w.get("id", "")) == weapon_id: return true return false- Step 5: Run to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_db.gd -gexit
Expected: PASS.
- Step 6: Full suite + determinism re-check
Run the full suite, then both determinism test files. Expected: all PASS, same pinned values —
weapon_available_in is read-only content lookup, called from roll_upgrade_choices which
already exists and is already exercised identically outside a Dimension (AreaDefs.weapons_for
returns [] for home in this task’s data… wait — Task 1 gave home NO "weapons" key at
all, so weapons_for("home") returns [] via the .get(..., []) default, meaning Generic is
unrestricted, matching Toby’s “Weapons: all of them” for Generic exactly, with zero extra code.
- Step 7: Commit
git add sim/content_db.gd tests/test_content_db.gd tests/test_wormhole_renderer.gdgit commit -m "feat(dimensions): weapon gating moves to a per-dimension curated allow-list"Task 8: Full-suite verification, CLAUDE.md + roadmap memory update
Section titled “Task 8: Full-suite verification, CLAUDE.md + roadmap memory update”Files:
- Modify:
CLAUDE.md(“Current status” section) - Modify: memory
bullet-heaven-roadmap.md(via the memory system, not a repo file)
Interfaces: none — this is a verification + documentation task, no code changes.
- Step 1: Full suite
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: exit 0, every test green.
- Step 2: Test-count guard
Run: scripts/check-test-count.sh
Expected: passes (confirms GUT ran every tests/test_*.gd file, not a silently-dropped subset).
Per this repo’s known gotcha, if this script hangs/aborts with no count-check output despite the
full suite being green in Step 1, that’s the documented set -e/-gexit interaction, not a new
regression — re-run Step 1’s raw command and check its own Scripts line directly instead.
- Step 3: Determinism, one final time
Run both tests/test_determinism_checksum.gd and tests/test_determinism_crystals.gd.
Expected: PASS, same pinned values as documented in CLAUDE.md’s “Current status” section
(snapshot_string().hash()=2730172591, state_checksum()=4075578713) — this whole plan should
never have needed a re-pin; if either value changed at any point in Tasks 1-7, STOP and treat it
as a bug, not an expected re-pin (nothing in this plan’s design touches the baseline’s home,
600-tick, seed-1234 window in a way that should perturb it).
- Step 4: Headless boot smoke
Run: godot --headless --path . --quit-after 120
Expected: boots and ticks without any SCRIPT ERROR in stderr.
- Step 5: Update CLAUDE.md
In CLAUDE.md’s “Current status” section, add a line (in place, per this file’s own
“condense in place, never append a dated bullet” convention) noting: Elemental Dimensions v2
replaces Pyre/Null/Drift with Fire/Void/Light (+ Generic/home now a real restricted
dimension with Warden as its boss); Ice/Blood/Electricity/Poison remain deferred pending their
new bosses/mechanics (see docs/superpowers/specs/2026-07-05-elemental-dimensions-v2-design.md).
- Step 6: Update the roadmap memory
Append a short entry to the bullet-heaven-roadmap memory (same style as existing entries — a
one-line pointer plus what changed and what’s still deferred) noting this shipped to main
(deploy itself is a separate step via the bh-deploy skill, not part of this plan).
- Step 7: Commit
git add CLAUDE.mdgit commit -m "docs(claude.md): Elemental Dimensions v2 -- Generic/Fire/Void/Light shipped, Ice/Blood/Electricity/Poison deferred"Not in this plan (deferred to follow-up specs)
Section titled “Not in this plan (deferred to follow-up specs)”- Ice, Blood, Electricity, Poison as playable dimensions, and their 3 new bosses (Nautilus, The Queen, The Last Computer).
- The Tank/TankMissile (“Summoner”) paired element-override — the spec’s Decision 5 called
for building this now even though no Phase 1 dimension uses it. Cut per YAGNI during planning:
Fire/Void/Light/Generic’s rosters never include Tank or TankMissile (Toby’s Ice/Electricity
rosters are the only consumers, both deferred), so wiring it now would be dead code with no
test able to exercise it honestly.
_element_for_spawn/enemy_element_override(Task 3) already generalize cleanly to Tank the same way they do to every other type — whichever spec builds Ice or Electricity next just adds the override entry, same one-line pattern as any other reflavored enemy, plus a one-line fix toupdate_tank_fire’s missile-spawn element read. - Wyrm (the shared-health multi-segment worm) and the Scythe/Chalice weapons.
- Poison’s HP-drain-on-hit player-status mechanic.
- A true persistent “drifting hazard body with orbiting children” for Fire/Void (this plan reuses the existing telegraphed-bomb mechanism instead — a deliberate simplification found during planning; upgrade to a richer visual is a follow-up, not a blocker).
- Random-subset portal selection once the dimension pool grows past 3.