Skip to content

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.

  • 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-run tests/test_determinism_checksum.gd and tests/test_determinism_crystals.gd after 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).
  • /sim files stay pure RefCounted, no Node/Engine/Time/file APIs.
  • Test pattern for these tests: Sim.new(<seed>, SimContentFixture.db()) (loads real data/bible.json) or ContentLoader.load_from_path("res://data/bible.json") — both patterns are already used side-by-side in tests/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>.gd to 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 (rename PYRE/NULL_DIM/DRIFT references; update boss/element expectations for the reassignment)
  • Modify: tests/test_wormhole.gd (no id references, but re-run to confirm — it only reads AreaDefs.DIMENSION_IDS generically)

Interfaces:

  • Produces: AreaDefs.FIRE := "fire", AreaDefs.VOID_DIM := "void_dim", AreaDefs.LIGHT := "light" (replacing PYRE/NULL_DIM/DRIFT); AreaDefs.DIMENSION_IDS: Array[String] := [FIRE, VOID_DIM, LIGHT]; AreaDefs.is_dimension(id: String) -> bool (now get_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 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 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_dimensionhome 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
Terminal window
git add sim/area_defs.gd tests/test_areas.gd
git 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 an element_idx parameter)
  • Modify: sim/sim.gd (_spawn_dimension_boss ~line 1276 — add a "warden" match arm; TYPE_BOSS death 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 (rename PYRE/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 -1 means “use void, the old default” — preserves _spawn_due_elites’s existing call site unchanged); Sim._spawn_dimension_boss handles boss_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
Terminal window
git add sim/boss_warden.gd sim/sim.gd tests/test_dimensions.gd tests/test_boss_gate.gd
git commit -m "feat(dimensions): wire Warden as Generic + Fire's dimension boss, award crystals on its kill"

Files:

  • Modify: sim/sim.gd (new _element_for_spawn helper; _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
Terminal window
git add sim/sim.gd tests/test_dimensions.gd
git 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_buff helper; _spawn_one applies 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
Terminal window
git add sim/sim.gd tests/test_dimensions.gd
git 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 reads sim._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_mult when 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,523enemy_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_lancers and update_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
Terminal window
git add sim/enemy_attacks.gd tests/test_dimensions.gd
git 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_hazard match ~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_hazard keep 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 BombRenderer element-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
Terminal window
git add sim/sim.gd render/bomb_renderer.gd tests/test_dimensions.gd
git 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 (delete weapon_matches_dimension; rewrite weapon_available_in)
  • Modify: tests/test_content_db.gd (rewrite the 2 tests that exercised the old mechanism)
  • Modify: tests/test_wormhole_renderer.gd (rename PYRE/NULL_DIM/DRIFT if 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.gd needs 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
Terminal window
git add sim/content_db.gd tests/test_content_db.gd tests/test_wormhole_renderer.gd
git 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
Terminal window
git add CLAUDE.md
git 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 to update_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.