Skip to content

Archive Reactions Mode + Rename Crystals Ruleset to Survival — Implementation Plan

Archive Reactions Mode + Rename Crystals Ruleset to Survival — Implementation Plan

Section titled “Archive Reactions Mode + Rename Crystals Ruleset to Survival — 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: Archive the reactions-only ruleset’s code out of the active codebase into _archive/reactions-mode/, then rename the remaining (crystals) ruleset’s identifiers to reclaim “survival” now that the old survival/reactions mode is gone.

Architecture: Two ordered commits, matching the spec’s own two-phase structure. Commit 1 archives (moves whole files, extracts reactions-only functions from shared files into inert .gd.txt reference copies, deletes them from the live files, flips Sim’s default ruleset). Commit 2 renames (only possible once commit 1 has removed the collision — main.gd’s _mode == "survival" string is currently claimed by the OLD reactions-mode menu card). Both commits happen inside the pre-existing isolated worktree at .claude/worktrees/reactions-archive-rename (branch reactions-archive-rename), verified with the full GUT suite + check-test-count.sh + determinism re-check after each, then fast-forwarded onto main.

Tech Stack: Godot 4.6.3 / GDScript, GUT 9.6.0 test framework.

  • Working directory for every step below: /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-rename (already exists, already has main merged in as of 2026-07-09, already imported).
  • Determinism baseline at plan time: snapshot_string().hash()=4217109746, state_checksum()=3312602286 — read the literal pinned assertion in the relevant test file after every change, never trust this prose number.
  • RULESET_REACTIONS = 0 and RULESET_CRYSTALS = 1 are plain typed-int constants on Sim (sim/sim.gd:329-330), not a GDScript enum. Per the approved design, both stay definedRULESET_REACTIONS becomes unreachable in practice (nothing ever sets ruleset to it once the default flips) but the name is not deleted from the file.
  • The elemental crystal currency (sim/crystal_state.gd, crystals.counts, WeaponThresholds, tests/test_crystal_state.gd, tests/test_crystal_endless_tuning.gd) is never touched by this plan — it’s a real in-game collectible, not the ruleset’s name.
  • Correction from the design spec, found during planning: the spec’s Section 3 said the CRYSTALS_DEAD_EFFECTS gating logic in sim/upgrade_system.gd “becomes dead” and implied archival. This is wrong and has been corrected in this plan: CRYSTALS_DEAD_EFFECTS (reaction_damage_mult/aura_duration_mult/stack_bonus) filters 3 upgrade-card effects that remain technically pickable content (their ModState fields still exist) but do nothing once the reaction system that reads them is archived. If this filter were deleted, those 3 cards would become offerable again with zero effect when picked — the exact “crit upgrade” dead-card bug already found and fixed elsewhere in this codebase (2026-07-09, sim/player_crit.gd’s commit history). This filter stays, unarchived, in sim/upgrade_system.gd — only its sim.ruleset == sim.RULESET_CRYSTALS condition gets renamed in Task 2, not deleted. Task 1 does not touch CRYSTALS_DEAD_EFFECTS at all.
  • AIM_MODS, aim_mod_mag(), and the "aim:"-id handling inside apply_upgrade()/upgrade_preview()/upgrade_choice_display() in sim/upgrade_system.gd are not archived. Only the 3-line offer block inside roll_upgrade_choices() (the code that adds an "aim:" candidate to the level-up roll when ruleset != RULESET_CRYSTALS) is removed — the rest of the aim-mod machinery is generic infrastructure that’s harmless to keep and isn’t reactions-exclusive.

New files:

  • _archive/reactions-mode/README.md — explains what’s archived, where each piece came from, that it’s inert reference material.
  • _archive/reactions-mode/ui/level_up_panel.gd — moved whole from ui/level_up_panel.gd.
  • _archive/reactions-mode/tests/test_level_up_panel.gd — moved whole from tests/test_level_up_panel.gd.
  • _archive/reactions-mode/tests/test_determinism_checksum.gd — moved whole from tests/test_determinism_checksum.gd.
  • _archive/reactions-mode/sim/elemental_system_reactions.gd.txt — extracted reaction-only functions from sim/elemental_system.gd (inert reference, .txt so Godot never parses it).
  • _archive/reactions-mode/sim/upgrade_system_reactions.gd.txt — extracted aim:-offer block from sim/upgrade_system.gd (inert reference).
  • _archive/reactions-mode/main_reactions.gd.txt — extracted old _mode == "survival" routing from main.gd (inert reference).
  • _archive/reactions-mode/start_menu_reactions.gd.txt — extracted old survival menu card from ui/start_menu.gd (inert reference).

Modified files (Task 1 — archival):

  • sim/elemental_system.gd — reaction functions/consts removed, apply_element() collapsed to its remaining unconditional behavior.
  • sim/sim.gdupdate_zones() caller removed from tick(); default ruleset flips to RULESET_CRYSTALS.
  • sim/upgrade_system.gd — the 3-line aim:-offer block removed from roll_upgrade_choices().
  • main.gd — old _mode == "survival" reactions routing removed; LevelUpPanel/level_up var + its branch in _open_levelup() removed.
  • ui/start_menu.gd — old ["survival", "SURVIVAL", ...] menu card entry removed from the full/non-gated array.
  • tests/test_crystals_ruleset.gdtest_default_ruleset_is_reactions() updated (default is no longer reactions).

Modified files (Task 2 — rename):

  • sim/sim.gdRULESET_CRYSTALSRULESET_SURVIVAL, enable_crystals()enable_survival().
  • ui/crystals_levelup_panel.gd → renamed to ui/level_up_panel.gd, class CrystalsLevelUpPanelLevelUpPanel.
  • main.gd_mode == "crystals"_mode == "survival"; crystals_level_up/CrystalsLevelUpPanel references updated; enable_crystals() call updated.
  • ui/start_menu.gd — surviving menu card’s id "crystals""survival".
  • net/gameplay_telemetry.gd — telemetry "ruleset" label "crystals""survival".
  • sim/upgrade_system.gdsim.RULESET_CRYSTALS references → sim.RULESET_SURVIVAL (the CRYSTALS_DEAD_EFFECTS name itself is NOT renamed — internal implementation detail, out of the spec’s explicit rename list).
  • tests/test_crystals_ruleset.gd → renamed to tests/test_survival_ruleset.gd.
  • tests/test_crystals_levelup_panel.gd → renamed to tests/test_level_up_panel.gd.
  • tests/test_crystals_manual_progression.gd → renamed to tests/test_survival_manual_progression.gd.
  • tests/test_determinism_crystals.gd → renamed to tests/test_determinism_survival.gd.
  • CLAUDE.md, .claude/skills/bh-add-content/SKILL.md, .claude/skills/bh-determinism-repin/SKILL.md — doc updates.

Task 1: Archive reactions-only code, flip the default ruleset

Section titled “Task 1: Archive reactions-only code, flip the default ruleset”

Files:

  • Create: _archive/reactions-mode/README.md, _archive/reactions-mode/ui/level_up_panel.gd (moved), _archive/reactions-mode/tests/test_level_up_panel.gd (moved), _archive/reactions-mode/tests/test_determinism_checksum.gd (moved), _archive/reactions-mode/sim/elemental_system_reactions.gd.txt, _archive/reactions-mode/sim/upgrade_system_reactions.gd.txt, _archive/reactions-mode/main_reactions.gd.txt, _archive/reactions-mode/start_menu_reactions.gd.txt
  • Modify: sim/elemental_system.gd, sim/sim.gd, sim/upgrade_system.gd, main.gd, ui/start_menu.gd, tests/test_crystals_ruleset.gd
  • Test: full suite + scripts/check-test-count.sh

Interfaces:

  • Consumes: nothing new — this task only removes code.

  • Produces: Sim.new()’s default ruleset is RULESET_CRYSTALS from this task onward (was RULESET_REACTIONS). Every later task (and any future work) must NOT assume the old default.

  • Step 1: Tag the pre-archival commit for safety

Terminal window
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-rename
git tag pre-reactions-archive-2026-07-09
  • Step 2: Create the archive directory structure and README
Terminal window
mkdir -p _archive/reactions-mode/ui _archive/reactions-mode/sim _archive/reactions-mode/tests

Write _archive/reactions-mode/README.md:

# Archived: Reactions Mode
This directory holds the code for Bullet Heaven's original "reactions" ruleset
(`Sim.RULESET_REACTIONS`, internally `0`) — full elemental engine with auras,
stacks, reactions, terrain zones, and aura chains — archived 2026-07-09 when
the crystals ruleset (renamed to "Survival") became the game's only mode.
Nothing here is wired into any build. Files with a `.gd` extension are the
exact original code (safe to copy back into a live location and re-wire if
this mode is ever revived). Files with a `.gd.txt` extension are extracted
reference SNIPPETS from files that still have live code in them — pasting a
`.gd.txt` file back requires re-integrating it into its original host file,
not just renaming the extension.
The commit immediately before this archival is tagged
`pre-reactions-archive-2026-07-09` — the full original state (including a
fully wired, playable reactions mode) is always recoverable from there even
without this directory.
## What's here
- `ui/level_up_panel.gd` — the `LevelUpPanel` class, reactions-mode's
level-up card UI. Moved whole from `ui/level_up_panel.gd`.
- `tests/test_level_up_panel.gd` — its test. Moved whole from
`tests/test_level_up_panel.gd`.
- `tests/test_determinism_checksum.gd` — the reactions-mode determinism
baseline test (pinned trace for `RULESET_REACTIONS`). Moved whole from
`tests/test_determinism_checksum.gd`.
- `sim/elemental_system_reactions.gd.txt` — the reaction/terrain/chain
functions (`reaction_burst`, `on_reaction`, `pop_primed`, `seed_primed`,
`spawn_zone`, `update_zones`) and their tuning consts, extracted from
`sim/elemental_system.gd`.
- `sim/upgrade_system_reactions.gd.txt` — the `aim:`-mod level-up-card offer
block, extracted from `sim/upgrade_system.gd`'s `roll_upgrade_choices()`.
- `main_reactions.gd.txt` — the old `_mode == "survival"` reactions-mode
routing (menu music selection, the `LevelUpPanel` display branch),
extracted from `main.gd`.
- `start_menu_reactions.gd.txt` — the old `["survival", "SURVIVAL", ...]`
menu card entry, extracted from `ui/start_menu.gd`.
## What's NOT here (deliberately)
The elemental crystal *currency* (`sim/crystal_state.gd`, `WeaponThresholds`)
is untouched and still lives at its original location — it's a real
in-game collectible in the (renamed) Survival ruleset, unrelated to the
archived reactions ruleset despite the naming proximity.
  • Step 3: Move the 3 whole files
Terminal window
git mv ui/level_up_panel.gd _archive/reactions-mode/ui/level_up_panel.gd
git mv tests/test_level_up_panel.gd _archive/reactions-mode/tests/test_level_up_panel.gd
git mv tests/test_determinism_checksum.gd _archive/reactions-mode/tests/test_determinism_checksum.gd
  • Step 4: Extract the reaction-only code from sim/elemental_system.gd into the archive reference file

Write _archive/reactions-mode/sim/elemental_system_reactions.gd.txt:

# Extracted from sim/elemental_system.gd, archived 2026-07-09 (reactions mode retired).
# Inert reference only — NOT live GDScript, not wired into any build. To revive: paste
# these consts + functions back into a live ElementalSystem-equivalent class, and restore
# apply_element()'s original branching (see git tag pre-reactions-archive-2026-07-09 for
# the exact original file).
# Elemental reactions — fired when a weapon hit applies a DIFFERENT element to an
# already-aura'd enemy.
const REACTION_BURST_RADIUS: float = 120.0
const GENERIC_REACTION_RADIUS: float = 70.0
const GENERIC_REACTION_MAGNITUDE: float = 8.0
const REACTION_COOLDOWN: float = 0.45 # per-enemy gate so reactions don't fire every tick
const REACTION_DAMAGE_SCALE: float = 0.62 # reactions hit HARD — close elemental play out-damages poking from range
# Reaction Terrain — a lingering elemental zone dropped at each reaction.
const ZONE_RADIUS: float = 78.0
const ZONE_DURATION: float = 2.4
const ZONE_DPS: float = 2.0
const ZONE_CAP: int = 64
# Aura Chains — a reaction primes nearby enemies; a hit on a primed one pops a mini-burst.
const CHAIN_RADIUS: float = 120.0
const PRIMED_POP_MAGNITUDE: float = 6.0
func reaction_burst(sim: Sim, center: Vector2, magnitude: float, generic: bool, _element_idx: int) -> void:
var radius := GENERIC_REACTION_RADIUS if generic else REACTION_BURST_RADIUS
var amount := (GENERIC_REACTION_MAGNITUDE if generic else magnitude) * REACTION_DAMAGE_SCALE * sim.mods.reaction_damage_mult
var hits := sim.hash.query_circle(center, radius, sim.enemies)
for ei in hits:
damage_enemy(sim, ei, amount)
func on_reaction(sim: Sim, ei: int, ev: Dictionary, element_idx: int) -> void:
if sim.enemies.react_cd[ei] > 0.0:
return # rate-limit bursts/terrain/chains per enemy
sim.enemies.react_cd[ei] = REACTION_COOLDOWN
sim.reactions_total += 1 # telemetry (excluded from the checksum)
var center: Vector2 = ev["center"]
reaction_burst(sim, center, float(ev.get("magnitude", 0.0)), bool(ev.get("generic", true)), element_idx)
spawn_zone(sim, center, element_idx)
seed_primed(sim, center, ei)
sim.fx_events.append({"kind": "reaction", "pos": center, "element": element_idx, "name": ev.get("name", "")})
func pop_primed(sim: Sim, ei: int, element_idx: int) -> void:
sim.enemies.primed[ei] = 0
var center := sim.enemies.pos[ei]
reaction_burst(sim, center, PRIMED_POP_MAGNITUDE, true, element_idx)
sim.fx_events.append({"kind": "chain", "pos": center, "element": element_idx})
func seed_primed(sim: Sim, center: Vector2, source_ei: int) -> void:
var hits := sim.hash.query_circle(center, CHAIN_RADIUS, sim.enemies)
for ei in hits:
if ei != source_ei and sim.enemies.data[ei] > 0.0:
sim.enemies.primed[ei] = 1
func spawn_zone(sim: Sim, center: Vector2, element_idx: int) -> void:
if sim.zones.size() >= ZONE_CAP:
sim.zones.pop_front()
sim.zones.append({"pos": center, "radius": ZONE_RADIUS, "remaining": ZONE_DURATION, "element": element_idx})
func update_zones(sim: Sim, dt: float) -> void:
var z := sim.zones.size() - 1
while z >= 0:
var zone: Dictionary = sim.zones[z]
var hits := sim.hash.query_circle(zone["pos"], zone["radius"], sim.enemies)
for ei in hits:
damage_enemy(sim, ei, ZONE_DPS * dt)
zone["remaining"] -= dt
if zone["remaining"] <= 0.0:
sim.zones.remove_at(z)
z -= 1
# apply_element()'s ORIGINAL body (for reference — the live version after archival is
# unconditional cosmetic-tint-only, see sim/elemental_system.gd):
#
# func apply_element(sim: Sim, ei: int, element_idx: int) -> void:
# if element_idx < 0 or sim.enemies.data[ei] <= 0.0:
# return
# if sim.ruleset == sim.RULESET_CRYSTALS:
# sim.enemies.aura_element[ei] = element_idx
# return
# var was_primed := sim.enemies.primed[ei] == 1
# var ev := Elemental.apply(sim.enemies, ei, element_idx, sim.content, sim.mods)
# if not ev.is_empty() and sim.enemies.data[ei] > 0.0:
# on_reaction(sim, ei, ev, element_idx)
# if was_primed and sim.enemies.data[ei] > 0.0:
# pop_primed(sim, ei, element_idx)
  • Step 5: Remove the extracted code from the live sim/elemental_system.gd

Delete these exact lines (the reaction-only consts, lines 25-41 in the pre-archival file):

# Elemental reactions — fired when a weapon hit applies a DIFFERENT element to an
# already-aura'd enemy.
const REACTION_BURST_RADIUS: float = 120.0
# Impact burst — the manual (kinetic) shot's baseline AoE splash on a direct hit.
const BURST_FRAC: float = 0.5
const GENERIC_REACTION_RADIUS: float = 70.0
const GENERIC_REACTION_MAGNITUDE: float = 8.0
const REACTION_COOLDOWN: float = 0.45 # per-enemy gate so reactions don't fire every tick
const REACTION_DAMAGE_SCALE: float = 0.62 # reactions hit HARD — close elemental play out-damages poking from range
# Reaction Terrain — a lingering elemental zone dropped at each reaction.
const ZONE_RADIUS: float = 78.0
const ZONE_DURATION: float = 2.4
const ZONE_DPS: float = 2.0
const ZONE_CAP: int = 64
# Aura Chains — a reaction primes nearby enemies; a hit on a primed one pops a mini-burst.
const CHAIN_RADIUS: float = 120.0
const PRIMED_POP_MAGNITUDE: float = 6.0

Replace with (keeping BURST_FRAC, which is shared — the manual weapon’s impact-burst splash, unrelated to elemental reactions):

# Impact burst — the manual (kinetic) shot's baseline AoE splash on a direct hit.
const BURST_FRAC: float = 0.5

Replace apply_element()’s full body:

# All weapon-applied elements route through here so reactions, terrain, chains
# and named VFX are handled in one place.
func apply_element(sim: Sim, ei: int, element_idx: int) -> void:
if element_idx < 0 or sim.enemies.data[ei] <= 0.0:
return
if sim.ruleset == sim.RULESET_CRYSTALS:
# Cosmetic tint only: set the aura colour but skip Elemental.apply
# so there are no reactions, bursts, terrain zones, status effects, or priming.
sim.enemies.aura_element[ei] = element_idx
return
var was_primed := sim.enemies.primed[ei] == 1
var ev := Elemental.apply(sim.enemies, ei, element_idx, sim.content, sim.mods)
if not ev.is_empty() and sim.enemies.data[ei] > 0.0:
on_reaction(sim, ei, ev, element_idx)
if was_primed and sim.enemies.data[ei] > 0.0:
pop_primed(sim, ei, element_idx)

with:

# All weapon-applied elements route through here. Reactions mode's terrain/burst/chain
# behavior was archived 2026-07-09 (see _archive/reactions-mode/) — every enemy now just
# gets the cosmetic aura tint set, unconditionally (no more ruleset branch: this was the
# only remaining behavior once the reactions-only functions this used to call were
# archived out of this file).
func apply_element(sim: Sim, ei: int, element_idx: int) -> void:
if element_idx < 0 or sim.enemies.data[ei] <= 0.0:
return
sim.enemies.aura_element[ei] = element_idx

Delete reaction_burst(), on_reaction(), pop_primed(), seed_primed(), spawn_zone(), and update_zones() in full (their exact bodies are shown in Step 4’s extraction above — delete the live copies of all 6 functions from sim/elemental_system.gd).

  • Step 6: Remove update_zones()’s caller in sim/sim.gd

update_zones() was called once per tick from Sim.tick(). Find and remove that call site:

Terminal window
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-rename
grep -n "elemental_system.update_zones" sim/sim.gd

Remove the matching line (e.g. elemental_system.update_zones(self, dt)) from wherever tick() calls it — sim.zones is now permanently empty (nothing populates it anymore since spawn_zone() is archived), so this call was about to become a guaranteed no-op loop over an empty array; removing it is a pure cleanup, not a behavior change.

  • Step 7: Extract the aim:-offer block from sim/upgrade_system.gd

Write _archive/reactions-mode/sim/upgrade_system_reactions.gd.txt:

# Extracted from sim/upgrade_system.gd's roll_upgrade_choices(), archived 2026-07-09
# (reactions mode retired). Inert reference only. This is the block that used to offer
# an "aim:" mod card in the level-up roll whenever the run was NOT in crystals mode
# (i.e. was in reactions mode). AIM_MODS/aim_mod_mag() themselves are NOT archived —
# they're still live, generic infrastructure (apply_upgrade/upgrade_preview/
# upgrade_choice_display all still handle "aim:"-prefixed ids) — only this OFFER site
# is gone, since reactions mode (the only ruleset that ever triggered this branch) no
# longer exists.
#
# Original location: inside roll_upgrade_choices(sim, n), right after the wmod_ids loop:
#
# if sim.ruleset != sim.RULESET_CRYSTALS:
# var ak: String = AIM_MODS[sim.upgrade_rng.randi_range(0, AIM_MODS.size() - 1)][0]
# wmod_ids.append("aim:" + ak)
  • Step 8: Remove the extracted block from the live sim/upgrade_system.gd

Delete these exact lines from inside roll_upgrade_choices():

# The manual (aim) weapon is always owned → always offer-eligible; add at most ONE candidate
# per roll so it can't crowd out arsenal upgrades. Draw from the upgrade_rng stream only.
if sim.ruleset != sim.RULESET_CRYSTALS:
var ak: String = AIM_MODS[sim.upgrade_rng.randi_range(0, AIM_MODS.size() - 1)][0]
wmod_ids.append("aim:" + ak)

(The blank line immediately after can stay or go — just don’t leave two blank lines in a row.)

  • Step 9: Extract main.gd’s old reactions-mode routing

Write _archive/reactions-mode/main_reactions.gd.txt:

# Extracted from main.gd, archived 2026-07-09 (reactions mode retired). Inert reference
# only. This is the old-survival (reactions-mode) routing: playing the survival_loop
# music for the (old) "survival" menu card, the story-load-failure fallback that used to
# drop back into reactions mode, and the LevelUpPanel display branch inside
# _open_levelup(). Original locations (line numbers as of the pre-archival commit,
# tag pre-reactions-archive-2026-07-09):
# _on_mode_chosen(), lines 443-452 (survival/crystals both played survival_loop music —
# after archival only "survival" exists, so this collapses, see live main.gd):
# func _on_mode_chosen(mode: String) -> void:
# if mode == "survival" or mode == "crystals":
# audio.play_music("survival_loop")
# else:
# audio.stop_music()
# _mode = mode
# _new_run()
# _new_run()'s story-load-failure fallback, lines 539-541 (fell back to reactions mode
# by setting _mode = "survival", which at the time meant RULESET_REACTIONS via the OLD
# menu-card id "survival" — this string collided with the id the CRYSTALS ruleset's own
# menu card would later reclaim, see the design spec's "collision" section):
# else:
# push_error("main: story.json failed to load — falling back to survival")
# _mode = "survival"
# The var declarations + instantiation for the old LevelUpPanel (var level_up:
# LevelUpPanel, constructed via `level_up = LevelUpPanel.new()` near the other panel
# constructions early in _new_run()), and _open_levelup()'s ruleset-branching display
# call:
# if sim.ruleset == Sim.RULESET_CRYSTALS:
# crystals_level_up.show_for(sim, ids)
# else:
# var choices: Array[Dictionary] = []
# for id in ids:
# choices.append(sim.upgrade_system.upgrade_preview(sim, id))
# level_up.show_choices(choices, sim.player.level)
  • Step 10: Remove the extracted routing from the live main.gd

10a. In _on_mode_chosen(), replace:

func _on_mode_chosen(mode: String) -> void:
# Survival/Crystals (the only mode v0.1 ships) gets a gameplay loop instead of just cutting
# to silence; other modes (gated off for v0.1, story/tutorial) have no loop yet -- silence
# there is unchanged behavior, not a regression (see SOUND_MAP.md Music section).
if mode == "survival" or mode == "crystals":
audio.play_music("survival_loop")
else:
audio.stop_music()
_mode = mode
_new_run()

with:

func _on_mode_chosen(mode: String) -> void:
# Survival (the only mode v0.1 ships) gets a gameplay loop instead of just cutting
# to silence; other modes (gated off for v0.1, story/tutorial) have no loop yet -- silence
# there is unchanged behavior, not a regression (see SOUND_MAP.md Music section).
if mode == "survival":
audio.play_music("survival_loop")
else:
audio.stop_music()
_mode = mode
_new_run()

10b. In _new_run()’s story-load-failure fallback, replace:

push_error("main: story.json failed to load — falling back to survival")
_mode = "survival"
if _mode == "crystals":
sim.enable_crystals()

with:

push_error("main: story.json failed to load — falling back to survival")
_mode = "survival"
if _mode == "survival":
sim.enable_crystals()

(This is a rename-shaped edit landing in the archival task because the fallback and the ruleset-enable check are adjacent — _mode is still "survival" after archival removes the old menu card that used to claim that string first; the enable_crystals()enable_survival() rename itself happens in Task 2, not here. After this step, _mode == "survival" is the ONLY routing string that ever triggers enable_crystals(), which is exactly the collision-free state the design’s ordering was built around.)

10c. Remove main.gd’s old LevelUpPanel declaration and instantiation.

The declaration, near the other panel-variable declarations (exact line as of the pre-archival commit, main.gd:45):

var level_up: LevelUpPanel

Delete this line entirely. (Line 46, var crystals_level_up: CrystalsLevelUpPanel, stays — it renames in Task 2, not here.)

The instantiation, inside _new_run()’s panel-setup block (exact lines as of the pre-archival commit, main.gd:816-824):

level_up = LevelUpPanel.new()
add_child(level_up)
level_up.chosen.connect(_on_upgrade_chosen)
level_up.audio = audio # UI nav/select sound (Sound Phase 4, 2026-07-02)
crystals_level_up = CrystalsLevelUpPanel.new()
add_child(crystals_level_up)
crystals_level_up.chosen.connect(_on_upgrade_chosen)
crystals_level_up.audio = audio

Replace with (deleting the 4 level_up-only lines and the blank line that separated the two blocks, keeping the 4 crystals_level_up lines untouched — they rename in Task 2):

crystals_level_up = CrystalsLevelUpPanel.new()
add_child(crystals_level_up)
crystals_level_up.chosen.connect(_on_upgrade_chosen)
crystals_level_up.audio = audio

10d. In _open_levelup(), replace:

if sim.ruleset == Sim.RULESET_CRYSTALS:
crystals_level_up.show_for(sim, ids)
else:
var choices: Array[Dictionary] = []
for id in ids:
choices.append(sim.upgrade_system.upgrade_preview(sim, id))
level_up.show_choices(choices, sim.player.level)

with:

crystals_level_up.show_for(sim, ids)

(There is now only one ruleset, so the branch collapses to its RULESET_CRYSTALS arm unconditionally. crystals_level_up’s own class/variable name gets renamed in Task 2 — don’t rename it here, this task only removes the reactions arm.)

  • Step 11: Extract ui/start_menu.gd’s old survival menu card

Write _archive/reactions-mode/start_menu_reactions.gd.txt:

# Extracted from ui/start_menu.gd, archived 2026-07-09 (reactions mode retired). Inert
# reference only. This was the old reactions-mode menu card in the full/non-gated modes
# array (unreachable in the live v0.1 build even before archival, since
# V01_CRYSTALS_ONLY=true always overwrote `modes` with just the crystals card — see the
# live ui/start_menu.gd for what remains):
#
# ["survival", "SURVIVAL", "Endless waves, score attack", NeonTheme.CYAN],
  • Step 12: Remove the extracted card from the live ui/start_menu.gd

Replace:

var modes := [
["tutorial", "TUTORIAL", "Learn the ropes, guided and paused", Color(0.5, 0.95, 0.62)],
["story", "STORY", "Ascend the Spectrum, the campaign", Color(1.0, 0.62, 0.32)],
["survival", "SURVIVAL", "Endless waves, score attack", NeonTheme.CYAN],
["crystals", "CRYSTALS", "Harvest element crystals, build-craft", Color(0.72, 0.42, 1.0)],
]

with:

var modes := [
["tutorial", "TUTORIAL", "Learn the ropes, guided and paused", Color(0.5, 0.95, 0.62)],
["story", "STORY", "Ascend the Spectrum, the campaign", Color(1.0, 0.62, 0.32)],
["crystals", "SURVIVAL", "Harvest element crystals, build-craft", Color(0.72, 0.42, 1.0)],
]

(The remaining card keeps its id as "crystals" for this task — the id itself renames to "survival" in Task 2, matching the ordering the design requires. The label was already “SURVIVAL”-equivalent in spirit in the V01_CRYSTALS_ONLY branch below this array; this makes the full/non-gated array consistent with that too, one card fewer.)

  • Step 13: Flip Sim’s default ruleset

In sim/sim.gd, replace:

# Ruleset controls which subsystems are active for a mode.
# RULESET_REACTIONS (default): full elemental engine — auras, stacks, reactions.
# RULESET_CRYSTALS: elements are cosmetic only — aura tint is set but NO reactions,
# bursts, terrain zones, or priming. Call enable_crystals() to activate.
const RULESET_REACTIONS := 0
const RULESET_CRYSTALS := 1
var ruleset: int = RULESET_REACTIONS # default keeps survival + determinism baseline byte-identical

with:

# Ruleset controls which subsystems are active for a mode. RULESET_REACTIONS (retired
# 2026-07-09, see _archive/reactions-mode/) is kept defined but unreachable -- nothing
# ever sets `ruleset` to it anymore. RULESET_CRYSTALS is now the only live ruleset:
# elements are cosmetic only -- aura tint is set but no reactions, bursts, terrain zones,
# or priming (that machinery no longer exists in this file at all). Defaults ON now, so
# every Sim.new() call is crystals-mode unless a future 3rd ruleset is ever added.
const RULESET_REACTIONS := 0
const RULESET_CRYSTALS := 1
var ruleset: int = RULESET_CRYSTALS
  • Step 14: Fix the one test that explicitly asserted the old default

tests/test_crystals_ruleset.gd’s test_default_ruleset_is_reactions() will now fail — Sim.new() defaults to crystals, not reactions. Replace:

func test_default_ruleset_is_reactions() -> void:
var sim := Sim.new(1, _content())
assert_eq(sim.ruleset, Sim.RULESET_REACTIONS, "default ruleset is reactions (determinism-safe)")

with:

func test_default_ruleset_is_crystals() -> void:
var sim := Sim.new(1, _content())
assert_eq(sim.ruleset, Sim.RULESET_CRYSTALS, "default ruleset is crystals, since reactions mode was archived 2026-07-09")

(This test file gets renamed in Task 2 — don’t rename it here, only fix the assertion.)

  • Step 15: Import, run the full suite, and confirm the count guard
Terminal window
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-rename
godot --headless --path . --import 2>&1 | tail -5
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -30
bash scripts/check-test-count.sh

Expected: the count guard now reports one fewer test script than before this task (2 whole test files moved out to _archive/, so they’re no longer under tests/) — confirm the printed Scripts count matches ls tests/test_*.gd | wc -l exactly, and that it’s green (0 failing).

If anything besides tests/test_crystals_ruleset.gd (already fixed in Step 14) fails, investigate before continuing — a test failing here means either (a) it implicitly relied on the OLD default ruleset without realizing it (the exact risk this step exists to catch), or (b) a reactions-only code path this task didn’t identify is still being exercised. Do not proceed to Step 16 with a red suite.

  • Step 16: Determinism check — the survival baseline is now the ONLY baseline (crystals one still pending rename)
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_crystals -gexit 2>&1 | tail -15

Expected: still passes against the pinned 4217109746 / 3312602286 values — this task didn’t touch anything in the <10s/600-tick baseline window (the reactions code removed was never reachable from a crystals-ruleset run in the first place, since apply_element()’s crystals branch already skipped it before this task). If it fails, stop and investigate — this would mean the archival accidentally touched shared/crystals-reachable code, not truly reactions-exclusive code.

  • Step 17: Commit
Terminal window
git add -A
git status --short # review before committing — confirm only the expected files changed
git commit -m "$(cat <<'EOF'
refactor: archive reactions-mode code, flip Sim's default ruleset
Reactions mode (RULESET_REACTIONS) has been unreachable via the live menu
since V01_CRYSTALS_ONLY=true (crystals-only). This moves its exclusive code
out of the active codebase into _archive/reactions-mode/ (whole files:
ui/level_up_panel.gd + 2 test files; extracted-then-deleted from shared
files: elemental_system.gd's reaction/terrain/chain functions,
upgrade_system.gd's aim:-card offer block, main.gd's old routing,
start_menu.gd's old menu card) and flips Sim.new()'s default ruleset to
RULESET_CRYSTALS (was RULESET_REACTIONS) now that nothing implements the
old default anymore.
RULESET_REACTIONS itself stays defined (unreachable, not deleted) per
design: docs/superpowers/specs/2026-07-09-reactions-mode-archive-and-rename-design.md.
Full suite green, test-count guard passing, determinism baseline
unaffected (reactions code was already unreachable from any crystals-mode
run before this change -- apply_element()'s crystals branch always skipped
it).
Tagged pre-reactions-archive-2026-07-09 immediately before this commit.
EOF
)"

Task 2: Rename crystals ruleset identifiers to “survival”

Section titled “Task 2: Rename crystals ruleset identifiers to “survival””

Files:

  • Modify: sim/sim.gd, main.gd, ui/start_menu.gd, net/gameplay_telemetry.gd, sim/upgrade_system.gd, CLAUDE.md, .claude/skills/bh-add-content/SKILL.md, .claude/skills/bh-determinism-repin/SKILL.md
  • Rename (git mv): ui/crystals_levelup_panel.gdui/level_up_panel.gd, tests/test_crystals_ruleset.gdtests/test_survival_ruleset.gd, tests/test_crystals_levelup_panel.gdtests/test_level_up_panel.gd, tests/test_crystals_manual_progression.gdtests/test_survival_manual_progression.gd, tests/test_determinism_crystals.gdtests/test_determinism_survival.gd
  • Test: full suite + scripts/check-test-count.sh + boot smoke test

Interfaces:

  • Consumes: Task 1’s flipped default ruleset (RULESET_CRYSTALS is the only reachable value) and the removed _mode == "survival" collision.

  • Produces: Sim.RULESET_SURVIVAL (renamed from RULESET_CRYSTALS), Sim.enable_survival() (renamed from enable_crystals()), LevelUpPanel class at ui/level_up_panel.gd (renamed from CrystalsLevelUpPanel/ui/crystals_levelup_panel.gd) — every later reference to any of these old names must use the new ones.

  • Step 1: Rename the ruleset constant + activation method in sim/sim.gd

Replace:

const RULESET_REACTIONS := 0
const RULESET_CRYSTALS := 1
var ruleset: int = RULESET_CRYSTALS

with:

const RULESET_REACTIONS := 0
const RULESET_SURVIVAL := 1
var ruleset: int = RULESET_SURVIVAL

Replace:

# ── Crystals mode ──────────────────────────────────────────────────────────
# Sets ruleset = RULESET_CRYSTALS so elemental_system.apply_element skips reactions entirely.
# Called by main.gd BEFORE the first tick; never called from /sim tests so the
# survival determinism baseline is untouched.
func enable_crystals() -> void:
ruleset = RULESET_CRYSTALS

with:

# ── Survival mode (the game's only mode; renamed from "crystals" 2026-07-09 once
# reactions mode was archived) ───────────────────────────────────────────────
# Sets ruleset = RULESET_SURVIVAL so elemental_system.apply_element skips reactions
# entirely. Called by main.gd BEFORE the first tick.
func enable_survival() -> void:
ruleset = RULESET_SURVIVAL

_eval_thresholds()’s guard (if ruleset != RULESET_CRYSTALS:) also needs updating:

Terminal window
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-rename
grep -n "RULESET_CRYSTALS" sim/sim.gd

Replace every remaining RULESET_CRYSTALS occurrence in sim/sim.gd with RULESET_SURVIVAL (the const rename above already handles the declaration; this catches every other reference in the file, e.g. inside _eval_thresholds()).

  • Step 2: Rename the crystals level-up panel class + file
Terminal window
git mv ui/crystals_levelup_panel.gd ui/level_up_panel.gd

In the newly-renamed ui/level_up_panel.gd, replace the class declaration:

class_name CrystalsLevelUpPanel
extends CanvasLayer
# Full-screen level-up panel for the Crystals ruleset.

with:

class_name LevelUpPanel
extends CanvasLayer
# Full-screen level-up panel (renamed from CrystalsLevelUpPanel 2026-07-09 -- this is
# now the game's only level-up panel; the old reactions-mode LevelUpPanel was archived
# to _archive/reactions-mode/ui/level_up_panel.gd the same day).
  • Step 3: Update main.gd’s _mode string + panel/ruleset references

Corrected from the original plan draft, 2026-07-09, after Task 1 landed: Task 1’s implementer found that collapsing these checks to _mode == "survival" alone (as this step originally specified) would have broken real gameplay, because the live menu card’s id was still "crystals" at the end of Task 1 (it only becomes "survival" in THIS task, below, at Step 4). Task 1 correctly left all 3 sites checking _mode == "crystals" or _mode == "survival" instead, with explanatory NOTE comments. Now that Step 4 (below) renames the menu card’s id to "survival", this task removes the "crystals" disjunct — the NOTE comments should go too, since the ambiguity they explained no longer exists once this step lands. Do this step and Step 4 in the same pass (order between them doesn’t matter within this single task/commit, unlike the Task 1/Task 2 ordering, which did matter).

Replace (the _on_mode_chosen() music-selection check, currently at main.gd:443-450):

# Survival/Crystals (the only mode v0.1 ships) gets a gameplay loop instead of just cutting
# to silence; other modes (gated off for v0.1, story/tutorial) have no loop yet -- silence
# there is unchanged behavior, not a regression (see SOUND_MAP.md Music section).
# NOTE: the live menu card's id is still "crystals" in this task (Task 1) -- it only
# renames to "survival" in Task 2, once this old reactions-mode "survival" id (removed
# from the menu in Step 12) is safely out of the way. Don't collapse this to a single
# "survival" check before that rename lands, or real gameplay silently loses its music.
if mode == "survival" or mode == "crystals":
audio.play_music("survival_loop")
else:

with:

# Survival (the only mode v0.1 ships) gets a gameplay loop instead of just cutting
# to silence; other modes (gated off for v0.1, story/tutorial) have no loop yet -- silence
# there is unchanged behavior, not a regression (see SOUND_MAP.md Music section).
if mode == "survival":
audio.play_music("survival_loop")
else:

Replace (the _new_run() ruleset-activation check, currently at main.gd:545-552):

# NOTE: the live menu's crystals-ruleset card still has id "crystals" in this task
# (Task 1) -- it only renames to "survival" in Task 2. The story-load-failure fallback
# just above sets _mode to the literal string "survival" directly (never "crystals"),
# so both strings must be checked here or a story-load failure would silently never
# call enable_crystals() -- reactions mode's old "survival" menu card (now removed,
# Step 12) used to make "survival" ambiguous; it no longer does, since that path is gone.
if _mode == "crystals" or _mode == "survival":
sim.enable_crystals()

with:

if _mode == "survival":
sim.enable_survival()

Replace (the boss-music-swap check, currently at main.gd:1024-1028):

# Swap the gameplay music loop while any boss is alive (survival/crystals only -- the only
# mode v0.1 ships with a loop at all; see _on_mode_chosen). Live menu id is still
# "crystals" in this task (Task 1) -- see the enable_crystals() call above for why both
# strings are checked.
if _mode == "survival" or _mode == "crystals":
var boss_alive: bool = sim.boss_rotation.any_boss_alive(sim)

with:

# Swap the gameplay music loop while any boss is alive (survival only -- the only mode
# v0.1 ships with a loop at all; see _on_mode_chosen).
if _mode == "survival":
var boss_alive: bool = sim.boss_rotation.any_boss_alive(sim)

Find every reference to crystals_level_up (the panel variable) and CrystalsLevelUpPanel (the class) in main.gd:

Terminal window
grep -n "crystals_level_up\|CrystalsLevelUpPanel" main.gd

Rename the variable crystals_level_uplevel_up throughout the file (its instantiation, e.g. crystals_level_up = CrystalsLevelUpPanel.new(), becomes level_up = LevelUpPanel.new(); every call site, e.g. crystals_level_up.show_for(sim, ids) from Task 1’s _open_levelup() simplification, becomes level_up.show_for(sim, ids); any add_child/signal-connection lines follow the same rename). The variable’s declared type also changes from CrystalsLevelUpPanel to LevelUpPanel.

  • Step 4: Update ui/start_menu.gd’s surviving menu card id

Replace:

if V01_CRYSTALS_ONLY:
modes = [["crystals", "SURVIVAL", "Endless waves, build your run", NeonTheme.CYAN]]

with:

if V01_CRYSTALS_ONLY:
modes = [["survival", "SURVIVAL", "Endless waves, build your run", NeonTheme.CYAN]]

And in the full/non-gated array (already reduced to 3 entries in Task 1 Step 12), replace:

["crystals", "SURVIVAL", "Harvest element crystals, build-craft", Color(0.72, 0.42, 1.0)],

with:

["survival", "SURVIVAL", "Harvest element crystals, build-craft", Color(0.72, 0.42, 1.0)],
  • Step 5: Update net/gameplay_telemetry.gd’s telemetry label

Replace:

"ruleset": "crystals" if sim.ruleset == Sim.RULESET_CRYSTALS else "reactions",

with:

"ruleset": "survival" if sim.ruleset == Sim.RULESET_SURVIVAL else "reactions",
  • Step 6: Update sim/upgrade_system.gd’s remaining RULESET_CRYSTALS references
Terminal window
grep -n "RULESET_CRYSTALS" sim/upgrade_system.gd

Replace every occurrence of sim.RULESET_CRYSTALS with sim.RULESET_SURVIVAL in this file — this includes both CRYSTALS_DEAD_EFFECTS guard sites (mod_eligible()’s two checks, upgrade_effects()’s one check) and roll_upgrade_choices()’s crystal-grant branch condition. Do not rename CRYSTALS_DEAD_EFFECTS itself (out of scope per this plan’s Global Constraints) — only the sim.RULESET_CRYSTALSsim.RULESET_SURVIVAL token changes.

  • Step 7: Rename the 4 ruleset-identity test files
Terminal window
git mv tests/test_crystals_ruleset.gd tests/test_survival_ruleset.gd
git mv tests/test_crystals_levelup_panel.gd tests/test_level_up_panel.gd
git mv tests/test_crystals_manual_progression.gd tests/test_survival_manual_progression.gd
git mv tests/test_determinism_crystals.gd tests/test_determinism_survival.gd

(tests/test_crystal_state.gd and tests/test_crystal_endless_tuning.gd are NOT touched — they test the currency, not the ruleset.)

  • Step 8: Fix the renamed test files’ internal references

In tests/test_survival_ruleset.gd: replace every Sim.RULESET_REACTIONS / Sim.RULESET_CRYSTALS / enable_crystals() reference:

Terminal window
grep -n "RULESET_CRYSTALS\|RULESET_REACTIONS\|enable_crystals" tests/test_survival_ruleset.gd

Replace sim.enable_crystals()sim.enable_survival() at every call site (11 occurrences per the plan’s research: lines 13, 31, 47, 58, 69, 83, 84, 90, 101, 117, 127, 137, 145, 156 in the pre-rename file — confirm exact count via the grep above, since line numbers will have shifted from Step 14 of Task 1’s edit). Replace Sim.RULESET_REACTIONSSim.RULESET_REACTIONS (unchanged — this const name doesn’t rename) and Sim.RULESET_CRYSTALSSim.RULESET_SURVIVAL (the two occurrences in test_default_ruleset_is_crystals(), already renamed from test_default_ruleset_is_reactions() in Task 1 Step 14, and test_enable_crystals_flips_flag()).

Also rename the test function test_enable_crystals_flips_flag()test_enable_survival_flips_flag() for consistency:

func test_enable_crystals_flips_flag() -> void:
var sim := Sim.new(1, _content())
sim.enable_crystals()
assert_eq(sim.ruleset, Sim.RULESET_CRYSTALS)

becomes:

func test_enable_survival_flips_flag() -> void:
var sim := Sim.new(1, _content())
sim.enable_survival()
assert_eq(sim.ruleset, Sim.RULESET_SURVIVAL)

In tests/test_level_up_panel.gd (renamed from test_crystals_levelup_panel.gd): replace every CrystalsLevelUpPanelLevelUpPanel and enable_crystals()enable_survival():

Terminal window
grep -n "CrystalsLevelUpPanel\|enable_crystals" tests/test_level_up_panel.gd

(3 occurrences of each per the plan’s research — lines 4/6, 20/22, 41/43 in the pre-rename file.)

In tests/test_survival_manual_progression.gd (renamed from test_crystals_manual_progression.gd): replace every enable_crystals()enable_survival() (4 occurrences per the plan’s research — lines 12, 27, 39, 58 in the pre-rename file).

In tests/test_determinism_survival.gd (renamed from test_determinism_crystals.gd): replace every enable_crystals()enable_survival() (2 occurrences, lines 5 and 7 in the pre-rename file). Also update the test function name and its comments for clarity — replace:

func test_crystals_trace_is_stable() -> void:
var a := Sim.new(1234, SimContentFixture.db())
a.enable_crystals()
var b := Sim.new(1234, SimContentFixture.db())
b.enable_crystals()

with:

func test_survival_trace_is_stable() -> void:
var a := Sim.new(1234, SimContentFixture.db())
a.enable_survival()
var b := Sim.new(1234, SimContentFixture.db())
b.enable_survival()

Leave the pinned assert_eq values and the dated comment history untouched in this step — Step 9 (determinism re-check) will confirm they’re still correct, and this file is now the sole surviving determinism baseline (the old test_determinism_checksum.gd was archived whole in Task 1), so its own comment trail is what future re-pins extend from.

  • Step 9: Import, run the full suite, count guard, and determinism re-check
Terminal window
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-rename
godot --headless --path . --import 2>&1 | tail -5
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -30
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit 2>&1 | tail -15

Expected: full suite green, count guard passing (script count unchanged from Task 1’s end — this task only renames files, doesn’t add/remove any), test_determinism_survival still passes against 4217109746 / 3312602286. If godot --import reports a class-name resolution error for LevelUpPanel, this usually means the global script-class cache needs a moment to pick up the renamed file — re-run the import command once more before investigating further.

  • Step 10: Headless boot smoke test
Terminal window
godot --headless --path . --quit-after 60 2>&1 | grep -i "SCRIPT ERROR"

Expected: no output (grep finds nothing). If it does find SCRIPT ERROR lines, a stale reference to one of the renamed identifiers (CrystalsLevelUpPanel, RULESET_CRYSTALS, enable_crystals, crystals_level_up) survived somewhere outside the files this task’s steps covered — grep the whole tree for the offending name before proceeding:

Terminal window
grep -rn "CrystalsLevelUpPanel\|RULESET_CRYSTALS\|enable_crystals\|crystals_level_up" --include="*.gd" .
  • Step 11: Update docs

11a. In CLAUDE.md, find and reword every “survival AND crystals” / “crystals mode” phrasing to reflect there’s one mode now:

Terminal window
grep -n "crystals\|Crystals" CLAUDE.md

Update each match — for example, the Determinism baseline bullet:

- **Determinism baseline:** `snapshot_string().hash()=4217109746`, `state_checksum()=3312602286` (survival AND crystals, last re-pinned 2026-07-07). **Read the literal pinned assertion in `tests/test_determinism_checksum.gd` + `test_determinism_crystals.gd`, never a prose number** — this line has gone stale before. Re-verify after any spawn/rng-touching change; re-pinning is normal, not a break.

becomes:

- **Determinism baseline:** `snapshot_string().hash()=4217109746`, `state_checksum()=3312602286` (last re-pinned 2026-07-07; the old separate reactions-mode baseline was archived 2026-07-09 along with reactions mode itself — see `_archive/reactions-mode/`). **Read the literal pinned assertion in `tests/test_determinism_survival.gd`, never a prose number** — this line has gone stale before. Re-verify after any spawn/rng-touching change; re-pinning is normal, not a break.

And the StartMenu.V01_CRYSTALS_ONLY gate bullet — replace:

- `StartMenu.V01_CRYSTALS_ONLY=true` — menu shows only Survival.

with:

- `StartMenu.V01_CRYSTALS_ONLY=true` — menu shows only Survival (the const name is a
pre-2026-07-09-rename holdover; renaming it is optional cleanup, not required —
see `_archive/reactions-mode/README.md` for the history).

(Renaming the V01_CRYSTALS_ONLY const itself is explicitly NOT required by this plan — it’s a v0.1-launch gate flag name, not one of the identifiers the approved design listed for renaming. Leaving it as-is with an explanatory note is the correct, disciplined scope; don’t rename it as a drive-by.)

Also correct the stale sim/sim.gd line-count claim found during Task 2 planning research (the file is currently 2466 lines, not “~1,700” as the Architecture section states) — replace ~1,700-line "conductor" with ~2,400-line "conductor" in that bullet, or note it needs a fresh count next time that section is touched for real — whichever is faster; this is a minor drive-by fix, not the focus of this step.

11b. In .claude/skills/bh-add-content/SKILL.md, replace:

- **`WeaponThresholds.TABLE` — the CRYSTALS-mode ladder. REQUIRED if the weapon should progress
in-run.** Crystals is the primary/shipping mode; `WEAPON_MODS` + level-up cards are the *survival*
path only, so a weapon with no `TABLE` row is **dormant in the mode that actually ships** (the real
bug from the 2026-07-05 manual-weapon work — see memory `bullet-heaven-crystals-is-primary-mode`). A
crystals `"evolve"` rung calls the weapon's `evolve()` directly (no `WeaponEvolutions` row needed for
the crystals path). An always-on weapon that is NOT in `active_weapon_ids` (e.g. the manual aim
weapon) also needs its own `Sim._eval_thresholds` call via `_apply_weapon_thresholds(id, w)` — the
loop only visits active ids.

with:

- **`WeaponThresholds.TABLE` — the survival-mode crystal-progression ladder. REQUIRED if the weapon
should progress in-run.** This is now the ONLY progression path (reactions mode, which used
`WEAPON_MODS` + level-up cards instead, was archived 2026-07-09 — see `_archive/reactions-mode/`),
so a weapon with no `TABLE` row is **permanently dormant**. A `"evolve"` rung calls the weapon's
`evolve()` directly (no `WeaponEvolutions` row needed). An always-on weapon that is NOT in
`active_weapon_ids` (e.g. the manual aim weapon) also needs its own `Sim._eval_thresholds` call via
`_apply_weapon_thresholds(id, w)` — the loop only visits active ids.

11c. In .claude/skills/bh-determinism-repin/SKILL.md, update every reference to the two old test filenames:

Terminal window
grep -n "test_determinism_checksum\|test_determinism_crystals\|survival\|crystals" .claude/skills/bh-determinism-repin/SKILL.md

Replace the frontmatter description field’s reference to both filenames with a single reference to tests/test_determinism_survival.gd (there’s now only one determinism test file, not two). Replace the “asserted in two files” prose (and its (survival)/(crystals mode) parentheticals) with prose describing the single surviving file. Replace both -gselect=test_determinism_crystals CLI invocation lines with -gselect=test_determinism_survival, and remove the now-inapplicable -gselect=test_determinism_checksum invocation lines entirely (that script no longer exists — it’s archived). Update the “Mention explicitly whether survival and crystals converged on the same pair or diverged” guidance to reflect there’s no second ruleset to converge/diverge against anymore — simplify to just describing the re-pin process for the single remaining baseline.

11d. Retire memory bullet-heaven-crystals-is-primary-mode — since crystals is no longer “primary” among alternatives, it’s the only mode. This is a memory-file change, not a repo file — note it in the task’s final report for whoever runs the memory-update step (memory isn’t part of this git-tracked plan’s file scope, but should be updated in the same session this plan lands).

  • Step 12: Final full-suite + count-guard re-verification after doc edits

Doc-only edits don’t usually break tests, but .claude/skills/ files are gitignored/local and not verified by GUT — run the suite one more time purely as a safety net before committing, since Step 11’s main.gd/sim.gd edits in earlier steps are the ones that actually matter for this check:

Terminal window
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-rename
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -20
bash scripts/check-test-count.sh
  • Step 13: Commit
Terminal window
git add -A
git status --short # review before committing
git commit -m "$(cat <<'EOF'
refactor: rename crystals ruleset to survival
Now that reactions mode is archived (previous commit), the crystals
ruleset is the game's only mode -- the "crystals" qualifier no longer
makes sense as its identity. Renames the underlying identifiers to
"survival", reclaiming the name the old (now-archived) reactions-mode menu
card used to hold:
- Sim.RULESET_CRYSTALS -> Sim.RULESET_SURVIVAL, enable_crystals() ->
enable_survival()
- ui/crystals_levelup_panel.gd -> ui/level_up_panel.gd,
CrystalsLevelUpPanel -> LevelUpPanel (reusing the name/path the archived
old panel vacated)
- main.gd's _mode == "crystals" -> "survival"; crystals_level_up var ->
level_up
- ui/start_menu.gd's surviving menu card id "crystals" -> "survival"
- net/gameplay_telemetry.gd's ruleset label "crystals" -> "survival"
- sim/upgrade_system.gd's RULESET_CRYSTALS references -> RULESET_SURVIVAL
(CRYSTALS_DEAD_EFFECTS itself NOT renamed -- internal detail, out of
scope)
- 4 test files renamed to match (test_crystals_ruleset.gd ->
test_survival_ruleset.gd, test_crystals_levelup_panel.gd ->
test_level_up_panel.gd [vacated by archival], test_crystals_manual_progression.gd
-> test_survival_manual_progression.gd, test_determinism_crystals.gd ->
test_determinism_survival.gd)
- CLAUDE.md, bh-add-content and bh-determinism-repin skills updated to
match
The elemental crystal CURRENCY (sim/crystal_state.gd, WeaponThresholds)
is untouched -- it's a real in-game collectible, unrelated to the
ruleset's name despite the naming proximity.
Full suite green, count guard passing, determinism baseline unchanged,
boot smoke test clean.
Design: docs/superpowers/specs/2026-07-09-reactions-mode-archive-and-rename-design.md
EOF
)"

Files: none (git operations only)

Interfaces: none

  • Step 1: Confirm main is quiet before landing
Terminal window
cd /Users/chris/Claude/bullet-heaven
git status --short
git log --oneline -5
git fetch origin
git rev-list --left-right --count origin/main...main

Expected: clean working tree, no unfamiliar recent commits, main and origin/main at the same commit (0/0). If main has moved (either locally or on origin) since this plan’s worktree was branched, merge those changes into the worktree branch first (cd .claude/worktrees/reactions-archive-rename && git merge main), re-run the full suite there, and only then proceed — do not force-land over unreconciled changes.

  • Step 2: Fast-forward main to the worktree branch’s tip
Terminal window
cd /Users/chris/Claude/bullet-heaven
git fetch . reactions-archive-rename:main

If this is refused with refusing to fetch into branch 'refs/heads/main' checked out at ..., that means main’s working tree has live uncommitted state — do NOT force it; wait and retry, or run git merge --ff-only reactions-archive-rename from inside the primary checkout instead (equivalent effect, doesn’t require the ref-update trick).

  • Step 3: Verify + push
Terminal window
git rev-parse main reactions-archive-rename # should be identical
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -10
git push origin main
  • Step 4: Clean up the worktree
Terminal window
git worktree remove .claude/worktrees/reactions-archive-rename
git branch -d reactions-archive-rename

(Only after confirming Step 3’s push succeeded — the worktree/branch are the working copy until then.)


1. Spec coverage:

  • Section 2 “In scope” (archive, flip default, rename, tag) — Tasks 1-2 cover all four.
  • Section 2 “Out of scope” (ruleset field collapse, crystal currency, P2 pipeline, MB/MC worktree) — none of Tasks 1-3 touch any of these; confirmed by the Global Constraints section restating the currency/CRYSTALS_DEAD_EFFECTS boundary explicitly.
  • Section 3 “Archive mechanics” (3 whole-file moves, 4 extraction targets, README, default flip, safety tag) — Task 1 Steps 1-14 cover all of them 1:1.
  • Section 4 “Rename mechanics” (6 ordered items + test renames) — Task 2 Steps 1-8 cover all of them, in the same order the design specified.
  • Section 5 “Docs to update” — Task 2 Step 11 covers CLAUDE.md, bh-add-content, bh-determinism-repin; explicitly notes the memory-file update as outside git-tracked scope for whoever runs it. docs/ROADMAP.md and the two architecture docs were confirmed (during plan research) to contain zero relevant references — no task needed for them.
  • Section 6 “Verification plan” (2 commits, full-suite + count-guard + determinism + boot-smoke, --ff-only land) — Task 1 Step 15-17, Task 2 Step 9-13, Task 3 all match.

2. Placeholder scan: No TBD/TODO/“add appropriate”/“similar to Task N” patterns — every step shows exact before/after code or an exact shell command with expected output.

3. Type consistency: RULESET_SURVIVAL (Task 2 Step 1) is used consistently everywhere it’s referenced in later steps (main.gd, net/gameplay_telemetry.gd, sim/upgrade_system.gd) — no stray RULESET_CRYSTALS left in a later step. enable_survival() likewise consistent. LevelUpPanel (the renamed class) is used consistently in Task 2 Steps 2-3 without reintroducing CrystalsLevelUpPanel. level_up (the renamed main.gd variable) is used consistently in Step 3, matching the panel class rename in Step 2.

Correction applied during this self-review: the original design spec’s Section 3 implied CRYSTALS_DEAD_EFFECTS should be archived alongside the aim:-offer block. This plan’s Global Constraints section documents why that’s wrong (it would re-introduce a dead-upgrade-card bug) and none of Task 1’s or Task 2’s steps archive or delete it — only its RULESET_CRYSTALS condition gets renamed in Task 2 Step 6, matching the corrected constraint.