Skip to content

Elemental Dimensions — design

Status: authored autonomously overnight (2026-07-02/03), self-decided per Chris’s explicit “don’t stall on a brainstorming question, pick the sensible option and record the reasoning” authorization — there was no human available to answer clarifying questions. Every decision below that would normally be a brainstorming question is called out as a Decision with its reasoning, so Chris can override any of them later without archaeology.

Evolve the existing, currently-locked explorable-areas/wormhole system (sim/area_defs.gd, render/wormhole_renderer.gd, main.gd’s warp flow — all built, none shipped, gated behind main.V01_LOCK_AREAS = true) into Dimensions: after a boss dies, three portals open at once, each a distinct elemental dimension with on-theme enemies/background/boss and its own environmental hazard, dropping crystals of its element on the next boss kill. Then unlock it.

Context (what already exists — verified via code, not memory)

Section titled “Context (what already exists — verified via code, not memory)”
  • AreaDefs (sim/area_defs.gd) holds area defs (name, difficulty_mult, reward_mult, background) for 3 areas (home, aurora, ember_reach), cycled by AreaDefs.other(id) — a fixed _CYCLE array, one-at-a-time.
  • Sim.current_area/Sim.enter_area(id)/Sim.other_area() (sim/sim.gd:233,954-970) already do the area-switch mechanics: swap difficulty/reward mults, clear enemies, re-arm per-run spawn gates, reset the wave phase.
  • Boss death is handled in Sim._sweep_dead() (sim/sim.gd, boss branches ~1503-1534, wormhole spawn at 1550-1552): boss_died_at is captured, then _spawn_area_wormhole(boss_died_at) fires — currently a single-slot gate (if not wormholes.is_empty(): return, sim/sim.gd:1623-1628) — this is the one line standing between “1 wormhole” and “N simultaneous wormholes.”
  • WormholeRenderer (render/wormhole_renderer.gd) already loops over an array of wormhole dicts and draws each — multi-portal rendering needs no structural change, only per-portal color/label differentiation (today every portal looks identical, no text, dest is stored on each dict but never read by the renderer).
  • main.gd’s warp flow (_warping/_warp_timer/_warp_dest, _begin_warp/ _finish_warp, main.gd:342-355) is single-destination — one _warp_dest: String. Needs generalizing to “the player entered PORTAL N of the 3 open ones.”
  • main.V01_LOCK_AREAS = true sets sim.areas_enabled = false (sim/sim.gd:1624-1625 gates _spawn_area_wormhole on it) — today, in the shipped game, zero area wormholes ever spawn. The separate, unrelated teaser_wormhole (single, destination-less, previews a locked boss) is the only wormhole players see.
  • 5 bosses exist (sim/boss_warden.gd, sim/boss2.gd, sim/funzo.gd, sim/graviton.gd, sim/eye.gd). CORRECTED during Task 3’s implementation research (2026-07-03) — the original text below, based on sim/boss_rotation.gd’s maybe_spawn_survival_boss, described dead code; verified via exhaustive grep that function has ZERO call sites anywhere in the current codebase. The REAL live “one boss, arena clears, portal should open after” mechanic is Sim._spawn_phase_boss() (sim/sim.gd), which rotates only FunZo/Graviton/Eye via _boss_gate_count % 3. Warden and Boss2 have been demoted to mid-wave high-biomass “elite” enemies (_spawn_due_elites, fixed spawn times, coexist with the swarm rather than gating an empty arena) — a different mechanic, not eligible as a Dimension’s sole boss. FunZo/ Graviton/Eye are hardcoded to a specific element at spawn (FunZo→psychic, Graviton→void, both via sim.content.element_index(...) literal calls); Eye→aether via an existing sim.eye_element_idx indirection — none of this was parameterized before this feature.
  • Enemy roster (20 entries in bible.json) already spans 10 of the 14 bible elements. Per-element carrier count: fire has 5 (swarmer, tank/“Pyromancer”, bomber, accumulator, tank_missile/“Fireball” — CLAUDE.md’s “tank=cold” note is stale, the tank is fire now), void has 3 (elite/“Charger”, ghost, + Graviton as a boss), aether has 3 (skirmisher, rusher, + Eye as a boss), everything else has 1-2. kinetic, time, sound, nano have zero enemy carriers today.

Decision 1 — reuse existing bosses with a parameterized element, don’t author new ones

Section titled “Decision 1 — reuse existing bosses with a parameterized element, don’t author new ones”

Inventing 3 brand-new bosses overnight, unsupervised, is unbounded scope and high risk (a new boss needs 5 things per CLAUDE.md’s own documented lesson: a _sweep_dead death branch, a nuke exemption, a renderer + color LUT sized to the new max type id, boss_render_info().max_hp, and a _move_enemies skip — getting any one wrong silently breaks the boss rotation or a renderer). Instead: generalize each boss’s spawn function to take an element_idx: int parameter (replacing the hardcoded sim.content.element_index("void")/"psychic"/"aether" literals) so the SAME attack-pattern code can be re-themed per dimension. “The boss must be on-theme” is satisfied by giving it the dimension’s element, not by writing a new boss.

Decision 2 — the 3 initial dimensions, chosen by existing enemy-roster depth

Section titled “Decision 2 — the 3 initial dimensions, chosen by existing enemy-roster depth”

Picking dimensions whose element already has enough on-theme trash enemies to feel like a real roster, not a reskinned lone enemy:

Dimension Element Boss (reused, re-elemented) Trash enemies (all already this element)
Pyre fire Graviton’s attack pattern, spawned with element=fire swarmer, tank/“Pyromancer”, bomber, accumulator, tank_missile/“Fireball”
Null void FunZo’s attack pattern, spawned with element=void (was psychic) elite/“Charger”, ghost
Drift aether Eye’s attack pattern, spawned with element=aether (unchanged) skirmisher, rusher

(Names are placeholders — easy to rename later; picked to avoid colliding with the existing area names Home/Aurora/Nebula, which this feature supersedes for the post-boss-portal flow.) This now uses all 3 members of the ACTUAL live “sole boss” rotation (_spawn_phase_boss’s FunZo/Graviton/Eye) — one per Dimension. Warden/Boss2 are NOT assigned a dimension: they aren’t part of that pool anymore (see the corrected note above), so there’s no “sole boss” role for them to fill. Null was originally assigned Warden; corrected to FunZo once Task 3’s research found Warden’s boss-gate role was dead code.

Renamed Ember → Pyre during plan-writing to avoid confusion with the pre-existing, unrelated ember_reach/‘Nebula’ area.

Decision 3 — starting dimension is a 4th, home, kept as “Generic”

Section titled “Decision 3 — starting dimension is a 4th, home, kept as “Generic””

Chris said the starting dimension can be Generic for now. home (already AreaDefs’ first entry, difficulty/reward mult 1.0, unthemed enemy mix) IS that Generic starting point already — no new area needed for it. Pyre/Null/Drift are the 3 portal destinations a boss-kill offers; home is never one of the 3 (you don’t return to Generic from a portal choice in this pass — see Decision 6 on scope).

  • AreaDefs gains 3 new entries (pyre, null_dim, driftnull is a GDScript reserved-adjacent word, avoid it as an id) alongside existing home/aurora/ ember_reach, each with a new element: String field (bible element id) in addition to the existing name/difficulty_mult/reward_mult/background. A new AreaDefs.boss_for(id) -> String maps a dimension id to which boss archetype spawns there ("graviton"/"funzo"/"eye"); AreaDefs.DIMENSION_IDS := [pyre, null_dim, drift] is the pool a boss-kill draws its 3 portal choices from (all 3, always, since there are exactly 3 — no random subset needed yet; extend this constant when a 4th+ Dimension lands, same pattern as the existing other()’s extension comment).
  • Sim._spawn_area_wormhole: change the single-slot guard to spawn one wormhole per id in AreaDefs.DIMENSION_IDS, each wormhole dict gaining element (for render tint) and name (for the label) alongside existing pos/dest.
  • Weapons/upgrades pause while portals are open: a new Sim.portals_open: bool (true from the moment _spawn_area_wormhole fires until the player enters one and enter_area runs). Every weapon’s .update() call in the tick loop gets gated on not portals_open (mirrors the existing pattern of gating boss-only mechanics — grep sim.gd’s weapon tick loop for the exact call site). This directly implements Chris’s stated reason: stop an accidental buffed-speed teleport.
  • main.gd’s warp flow: _warp_dest: String stays (still exactly one destination once the player commits to a portal), but _begin_warp is now triggered by touching ANY of the 3 wormholes (unchanged mechanism, since WormholeRenderer/collision already loop over an array) — no signature change needed there, only the spawn side (Decision above) changes from 1 to 3.
  • WormholeRenderer: read each wormhole’s element to pick a tint via the existing ElementPalette.color_for (already used for enemy/reaction tinting elsewhere — reuse it here for consistency rather than inventing a second color table), and add a Label-per-portal (a child Label node keyed by wormhole index, NOT a _draw() string — Godot’s _draw() text needs a loaded Font resource passed explicitly, simpler and consistent with how every other neon-labeled UI in this project already works to just use a real Label) showing the dimension’s name, colored to match.
  • Environmental phenomena: one generic per-dimension hazard timer on Sim (_dimension_hazard_timer, fires every DIMENSION_HAZARD_INTERVAL seconds while current_area is a Dimension), dispatching to a small per-element hazard function (_spawn_fire_hazard/_spawn_void_hazard/_spawn_aether_hazard) rather than 3 bespoke systems. Fire: a single telegraphed fireball impact (reuse the EXISTING bomber telegraph-then-AoE mechanism, sim/enemy_attacks.gd’s bomber path, just triggered environmentally instead of by an enemy). Void: a brief, weak gravity pulse (reuse Graviton’s own GRAVITON_PULL_STRENGTH mechanic at a much lower magnitude and only once — NOT the boss’s full ability). Aether: a short screen-static/vision flicker (render-only, no damage — the least mechanically risky of the three, matches aether’s “phase” flavor). Each is capped to fire only when no boss is currently alive (so it doesn’t stack with an actual boss fight) and is excluded from state_checksum/ snapshot_string exactly like every other timing-driven fx event in this codebase.
  • 10 crystals of the dimension’s element on boss kill: CORRECTED during Task 7’s implementation research (2026-07-03) — the text below described reusing the gems entity pool, based on an unverified assumption that it was “the same one the Crystals ruleset already spawns from.” Verified directly: Sim.gems is a plain EntityPool (no dedicated gem-pool file), exclusively XP-carrying (_collect_gems calls _bank_xp on pickup, never a gold/currency award), and renders uniformly white with no per-instance element tint at all (confirmed against this project’s own established convention: gems are deliberately recolored white “to read as loot, not an element”). RULESET_CRYSTALS (sim/upgrade_system.gd) is an unrelated upgrade-offering mechanic that doesn’t spawn any pickup entity at all. Reusing gems as originally written would have granted XP (not gold) and produced 10 pickups visually identical to an ordinary gem — defeating “of the dimension’s element” as a visible thing entirely. Corrected design: a flat bonus-gold award (matching the existing BOSS_GOLD flat-reward convention) plus 10 element-tinted visual bursts (the existing "reaction" fx kind with an empty name, which already renders as a pure colored spark+ring with no text) at the death position — no new pickup entity, no gem-pool changes.
  • Weapon dimension-gating hook (lightweight, per Chris’s explicit “don’t block on fully authoring”): add an optional dimension: String field to a weapon’s bible.json entry (unset = available everywhere, the default for all 6 existing weapons — none are restricted by this pass) and a pure function ContentDB.weapon_available_in(weapon_id, dimension_id) -> bool that returns true when the field is unset or matches. roll_upgrade_choices’s weapon-grant filter calls it. No existing weapon’s behavior changes; this is purely a hook for future content.

Every new spawn/rng path here (dimension hazard timer, 10-crystal drop, portal wormhole spawn) is gated behind a boss having died, exactly like the existing single-wormhole spawn it replaces — which never happens inside the pinned baseline window (the baseline run is a fixed 600-tick/1234-seed run that never reaches a boss). So the baseline should hold by construction; re-verify tests/test_determinism_checksum.gd + tests/test_determinism_crystals.gd after every task anyway, per this repo’s own standing rule.

Decision 6 — explicitly deferred (documented so it isn’t silently forgotten)

Section titled “Decision 6 — explicitly deferred (documented so it isn’t silently forgotten)”
  • No portal choice affects which dimension you return to — once V01_LOCK_AREAS is off, a full “return home” or multi-hop map is a follow-up, not part of this pass (mirrors the existing Aurora/Ember Reach precedent, which already only does one-hop travel per boss kill).
  • No new bespoke enemy types — every Dimension’s roster is drawn from enemies that already carry that element; no new EnemyPool.TYPE_* is added in this pass.
  • Weapon-gating is a hook only — no weapon is actually restricted to a dimension yet, since none of the 6 shipped weapons are dimension-exclusive content.
  • Boss2/FunZo are not given a dimension — they remain in the generic survival rotation, untouched by this feature.
  • Lore for the starting/Generic dimension — explicitly deferred by Chris himself (“we can decide later”); home’s existing name/flavor is left as-is.

New GUT tests needed (file: tests/test_dimensions.gd, new): AreaDefs.boss_for/ DIMENSION_IDS return the expected mapping; a boss death when current_area is home (or any non-Dimension) spawns exactly 3 wormholes, one per DIMENSION_IDS, each tagged with its element/name/dest; Sim.portals_open is true immediately after that spawn and false again after enter_area runs; every weapon’s .update() is a no-op while portals_open (assert player state doesn’t advance a cooldown/fire); entering a Dimension via enter_area spawns only that dimension’s on-theme enemy types over a sampled window (reuse the existing SpawnDirector-testing pattern); a boss kill inside a Dimension awards a flat gold bonus plus exactly 10 element-tinted visual bursts (a "reaction" fx event each, no text label) in addition to the existing gold/XP; the environmental hazard timer fires at most once per DIMENSION_HAZARD_INTERVAL and never while a boss is alive; ContentDB.weapon_available_in returns true for every existing weapon (none restricted) and correctly gates a synthetic restricted-weapon fixture. Existing tests this pass must not break: tests/test_areas.gd, tests/test_wormhole.gd (CLAUDE.md flags these as having bitten a prior 2-way→N-way generalization before — grep both for any hardcoded “exactly 1 wormhole” or “exactly 2 areas” assumption before considering this pass done).

Once green (full suite + both determinism tests + boot check), flip main.V01_LOCK_AREAS to false (and correspondingly whatever gates sim.areas_enabled off it) and deploy via the established bh-deploy skill to both the Apple TV and Chris’s iPhone. Update CLAUDE.md’s “Current status” section and the roadmap memory noting this shipped, plus every Decision above so Chris can revisit any of them.