Rift Bolt detonation — implementation plan
Rift Bolt Detonation Implementation Plan
Section titled “Rift Bolt Detonation 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. In this repo also consult skillbh-sdd-concurrentbefore Task 1 andbh-dev-chunkfor every task.
Goal: Every weapon hit builds element stacks on an enemy (up to 6); a Rift Bolt hit detonates them for damage that scales with stacks.
Architecture: All sim logic lives in sim/elemental_system.gd (apply_element gains accumulation; new is_stacked / detonate), triggered from its own resolve_collisions for ProjPool.KIND_AIM projectiles. Tuning values are live console tunables on WeaponAim / ElementalSystem. Render reads sim state only (aura rings already exist; one new FxManager kind). Autopilot learns to target stacked enemies so soak/economy numbers are honest.
Tech Stack: Godot 4.7, typed GDScript (warnings as errors), GUT tests.
Spec: docs/superpowers/specs/2026-10-12-rift-bolt-detonation-design.md
Global Constraints
Section titled “Global Constraints”/simis pure logic: no Node/render/Input/Engine/Time APIs; every damage goes throughsim.elemental_system.damage_enemy(sim, ...); nothing is removed mid-tick (deferred_sweep_dead).- No new RNG draws anywhere in this feature.
- Determinism baselines live in
tests/test_determinism_survival.gd,tests/test_determinism.gd,tests/test_determinism_coop.gd. A task that shifts them re-pins through skillbh-determinism-repinin the SAME task, after confirming the shift comes only from this task’s change. Never copy hash values into prose. - Code never names these “marks”:
mark_*is the Railgun’s debuff. Use “stacks” / “stacked” / “detonate”. - Every new tuning number is a registered
static vartunable:net/tunables_registry.gdentry +net/live_scope.gdentry + label in atools/tunable_docs/docs.d/*.jsonfile (never hand-editcontrol/src/tunable_docs.js). Recipe: skillbh-tunable-batch. - Annotate
Dictionary.get()results asVariant, not inferred:=. - Run the suite only via
scripts/check-test-count.sh(or-gselect=<file>.gdfor one script); never pull a whole raw GUT run into context. SetGUT_LOG=<own path>when running in a worktree. - Work in a worktree under
.claude/worktrees/rift-detonationbranched offorigin/main;scripts/gate.shmust pass at the end of every task. - Commit messages via
git commit --file=<file>, ending with the session’s Co-Authored-By / Claude-Session lines.
File map
Section titled “File map”| File | Change |
|---|---|
sim/elemental_system.gd |
STACKS_MAX tunable; is_stacked(); apply_element() accumulates; status pinned to 1 stack; detonate(); resolve_collisions calls it for KIND_AIM |
sim/weapon_aim.gd |
DETONATE_MULT_PER_STACK, DETONATE_RADIUS, DETONATE_SPLASH_FRAC, DETONATE_BOSS_FRAC tunables (Task 5: new DEF_DAMAGE) |
net/tunables_registry.gd, net/live_scope.gd, tools/tunable_docs/docs.d/rift_detonation.json (new) |
register the tunables |
fx/enemy_aura_look.gd |
const MAX_RINGS → tunable RINGS_MAX (default 6) |
fx/fx_manager.gd |
rift_detonate handler, essential kind, own cap |
audio/audio_manager.gd |
rift_detonate → scatter_explosion with RIFT_DETONATE_SFX_DB/_PITCH |
tools/vfx_library/library_manifest.gd, tools/vfx_library/library.gd |
rift_detonate entry, score, preview |
ai/skilled_auto_player.gd |
prefer stacked targets when not in danger |
data/story.json |
tut_riftbolt gains a line |
tests/test_rift_detonation.gd (new), tests/test_element_status_live.gd, tests/test_elemental_coverage.gd, tests/test_enemy_aura_look.gd, tests/test_weapon_aim.gd, tests/test_skilled_aim_stacks.gd (new) |
tests |
Task 1: Stack accumulation, status pinned to one stack
Section titled “Task 1: Stack accumulation, status pinned to one stack”Files:
- Modify:
sim/elemental_system.gd(apply_element~line 306,vuln_mult~line 272,apply_status_and_decay~line 585, header consts) - Modify:
net/tunables_registry.gd(near the otherELEMENTALentries),net/live_scope.gd - Create:
tools/tunable_docs/docs.d/rift_detonation.json - Test:
tests/test_rift_detonation.gd(new),tests/test_element_status_live.gd,tests/test_elemental_coverage.gd
Interfaces:
-
Produces:
static var ElementalSystem.STACKS_MAX: int = 6;func is_stacked(sim: Sim, ei: int) -> boolonElementalSystem(instance method, called assim.elemental_system.is_stacked(sim, ei)). -
Step 1: Write the failing tests — create
tests/test_rift_detonation.gd:
extends GutTest
# Rift Bolt detonation (spec docs/superpowers/specs/2026-10-12-rift-bolt-detonation-design.md).# Every test drives the REAL apply_element / resolve_collisions path (hand-set stacks is how the# 2026-08 "status does nothing" bug hid — see test_element_status_live.gd).
func _sim() -> Sim: return Sim.new(1, SimContentFixture.db())
func _enemy(sim: Sim, pos: Vector2 = Vector2(50, 0), hp: float = 1000.0, base_el: int = -1) -> int: var ei := sim.enemies.add(pos, Vector2.ZERO, 14.0, hp, 0.0, 70.0, 12.0, 1.0, EnemyPool.TYPE_SWARMER, base_el, EnemyPool.BEHAVIOR_WALK) sim.hash.rebuild(sim.enemies) return ei
func test_any_weapon_hit_adds_a_stack_up_to_the_cap() -> void: var sim := _sim() var ei := _enemy(sim) var fire := sim.content.element_index("fire") var cold := sim.content.element_index("cold") sim.elemental_system.apply_element(sim, ei, fire) sim.elemental_system.apply_element(sim, ei, cold) assert_eq(sim.enemies.stacks[ei], 2, "a different element still adds a stack") assert_eq(sim.enemies.aura_element[ei], cold, "colour follows the last element") for _i in range(10): sim.elemental_system.apply_element(sim, ei, fire) assert_eq(sim.enemies.stacks[ei], ElementalSystem.STACKS_MAX, "capped at STACKS_MAX")
func test_innate_aura_alone_is_not_stacked_but_own_element_hit_stacks() -> void: var sim := _sim() var fire := sim.content.element_index("fire") var ei := _enemy(sim, Vector2(50, 0), 1000.0, fire) assert_false(sim.elemental_system.is_stacked(sim, ei), "innate base aura is never stacked") # Decay counts the innate sentinel down too; it must still not read as stacked. sim.elemental_system.apply_status_and_decay(sim, 1.0) assert_false(sim.elemental_system.is_stacked(sim, ei), "decayed innate aura is still not stacked") sim.elemental_system.apply_element(sim, ei, fire) assert_true(sim.elemental_system.is_stacked(sim, ei), "a weapon hit of the enemy's own element stacks") assert_eq(sim.enemies.stacks[ei], 1)
func test_whole_stack_drops_at_expiry() -> void: var sim := _sim() var ei := _enemy(sim) var fire := sim.content.element_index("fire") var decay_s := float(sim.content.element_at(fire).get("aura_decay_s", 0.0)) for _i in range(4): sim.elemental_system.apply_element(sim, ei, fire) for _i in int(round((decay_s + 0.5) / Sim_Const.DT)): sim.elemental_system.apply_status_and_decay(sim, Sim_Const.DT) assert_false(sim.elemental_system.is_stacked(sim, ei), "all 4 stacks gone together") assert_eq(sim.enemies.stacks[ei], 0)
func test_burn_and_shock_ignore_stack_count() -> void: var sim := _sim() var a := _enemy(sim, Vector2(50, 0)) var b := _enemy(sim, Vector2(-50, 0)) var fire := sim.content.element_index("fire") sim.elemental_system.apply_element(sim, a, fire) for _i in range(6): sim.elemental_system.apply_element(sim, b, fire) sim.elemental_system.apply_status_and_decay(sim, 1.0) assert_almost_eq(sim.enemies.data[a], sim.enemies.data[b], 0.0001, "6 stacks burn the same as 1") var lightning := sim.content.element_index("lightning") sim.elemental_system.apply_element(sim, a, lightning) var one := sim.elemental_system.vuln_mult(sim, a) for _i in range(5): sim.elemental_system.apply_element(sim, a, lightning) assert_almost_eq(sim.elemental_system.vuln_mult(sim, a), one, 0.0001, "shock stays at 1-stack strength")- Step 2: Run to verify failure
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_rift_detonation.gd -gexit
Expected: FAIL (parse error on STACKS_MAX / is_stacked).
- Step 3: Implement in
sim/elemental_system.gd.
Add near the top-level consts (after TRAIL_SLOW_WEIGHT):
# Rift Bolt detonation (2026-10-12): every weapon hit adds one element stack up to this cap; a Rift# Bolt hit detonates them (detonate() below). = bible stacks_max. Live console tunable.static var STACKS_MAX: int = 6Replace the body of apply_element (keep its guard) and update its header comment to describe accumulation:
func apply_element(sim: Sim, ei: int, element_idx: int) -> void: if element_idx < 0 or sim.enemies.data[ei] <= 0.0: return if is_stacked(sim, ei): sim.enemies.stacks[ei] = mini(sim.enemies.stacks[ei] + 1, STACKS_MAX) else: sim.enemies.stacks[ei] = 1 sim.enemies.aura_element[ei] = element_idx sim.enemies.aura_remaining[ei] = float(sim.content.element_at(element_idx).get("aura_decay_s", 0.0))
# True while the enemy carries weapon-applied element stacks (not just its innate base aura).# The < BASE_AURA_PERSIST * 0.5 test matches fx/enemy_aura_look.gd: Elemental.decay counts the# innate sentinel down too, so an exact != BASE_AURA_PERSIST test would misfire.func is_stacked(sim: Sim, ei: int) -> bool: return sim.enemies.aura_element[ei] != -1 and sim.enemies.stacks[ei] > 0 \ and sim.enemies.aura_remaining[ei] < EnemyPool.BASE_AURA_PERSIST * 0.5In vuln_mult, change the last line’s stack arg to mini(sim.enemies.stacks[ei], 1); in apply_status_and_decay, change en.stacks[i] in the dot_per_second call to mini(en.stacks[i], 1). Add a one-line comment at each: # stacks feed Rift Bolt detonation only; status stays at 1-stack strength (spec 2026-10-12).
-
Step 4: Update the two older tests that pinned the old behaviour
tests/test_element_status_live.gdtest_reapplying_same_element_refreshes_without_stacking: rename totest_reapplying_same_element_refreshes_and_stacks, assertstacks == 2with message"stack accumulation on (Rift Bolt detonation, 2026-10-12)"; keep the refresh assert.tests/test_elemental_coverage.gdtest_burn_dot_reduces_hp_and_is_not_shock_amped: expected HP becomes1000.0 - 2.0 * Sim_Const.DT, comment# burn = status_base(2) at 1-stack strength regardless of stacks(2), message updated.
-
Step 5: Register
STACKS_MAXnet/tunables_registry.gd:{"key": "ElementalSystem.STACKS_MAX", "script": ELEMENTAL, "name": "STACKS_MAX", "min": 1, "max": 6},net/live_scope.gd:"ElementalSystem.STACKS_MAX": [LIVE, "", "read at every weapon hit"],- Create
tools/tunable_docs/docs.d/rift_detonation.json:
{ "ElementalSystem.STACKS_MAX": {"label": "Element stack cap", "desc": "Most element stacks one enemy can carry. Every weapon hit adds one; a Rift Bolt hit detonates them all.", "unit": "stacks", "src": "code", "group": "Rift Bolt"}}Then regenerate the generated docs and run the liveness probe per skill bh-tunable-batch (it names the exact commands and the registry/docs gate tests).
-
Step 6: Run
-gselect=test_rift_detonation.gd,test_element_status_live.gd,test_elemental_coverage.gd→ PASS. Thenscripts/check-test-count.sh. Determinism baselines will likely fail (stacks are insnapshot_string): followbh-determinism-repinand re-pin in this task. -
Step 7:
scripts/gate.sh→ exit 0, then commitfeat(elemental): weapon hits accumulate element stacks; status pinned to 1 stack.
Task 2: Rift Bolt detonation
Section titled “Task 2: Rift Bolt detonation”Files:
- Modify:
sim/weapon_aim.gd(static vars block),sim/elemental_system.gd(resolve_collisions~line 125; newdetonate) - Modify:
net/tunables_registry.gd(WEAPON_AIM block ~line 1059),net/live_scope.gd,tools/tunable_docs/docs.d/rift_detonation.json - Test:
tests/test_rift_detonation.gd
Interfaces:
-
Consumes:
is_stacked(sim, ei),STACKS_MAX(Task 1). -
Produces:
static var WeaponAim.DETONATE_MULT_PER_STACK: float = 0.25,DETONATE_RADIUS: float = 70.0,DETONATE_SPLASH_FRAC: float = 0.5,DETONATE_BOSS_FRAC: float = 0.5;func detonate(sim: Sim, ei: int, bolt_dmg: float) -> voidonElementalSystem; fx event{"kind": "rift_detonate", "pos": Vector2, "radius": float, "element": int, "stacks": int}. -
Step 1: Append failing tests to
tests/test_rift_detonation.gd:
# A Rift Bolt projectile placed directly on `at` (ctx "aim" → ProjPool.KIND_AIM), then one# collision pass. burst_radius 0 so only the direct hit (and detonation) can deal damage.func _bolt_hit(sim: Sim, at: Vector2, dmg: float, burst_r: float = 0.0) -> void: sim.projectiles.add_proj(at, Vector2(900, 0), 10.0, 1.0, dmg, -1, 0, 0, 0.0, burst_r, "aim", 0.0, 0) sim.hash.rebuild(sim.enemies) sim.elemental_system.resolve_collisions(sim)
func test_bolt_on_stacked_enemy_detonates_and_consumes_stacks() -> void: var sim := _sim() var ei := _enemy(sim) var fire := sim.content.element_index("fire") for _i in range(3): sim.elemental_system.apply_element(sim, ei, fire) _bolt_hit(sim, sim.enemies.pos[ei], 10.0) var det := 10.0 * WeaponAim.DETONATE_MULT_PER_STACK * 3.0 assert_almost_eq(sim.enemies.data[ei], 1000.0 - 10.0 - det, 0.001, "bolt hit + detonation of 3 stacks") assert_false(sim.elemental_system.is_stacked(sim, ei), "stacks consumed") var found := false for ev in sim.fx_events: if ev.get("kind", "") == "rift_detonate": found = true assert_eq(int(ev["stacks"]), 3) assert_true(found, "rift_detonate fx event emitted")
func test_bolt_on_unstacked_enemy_neither_detonates_nor_stacks() -> void: var sim := _sim() var ei := _enemy(sim) _bolt_hit(sim, sim.enemies.pos[ei], 10.0) assert_almost_eq(sim.enemies.data[ei], 990.0, 0.001, "plain bolt damage only") assert_false(sim.elemental_system.is_stacked(sim, ei), "a Rift Bolt hit adds no stack")
func test_detonation_splashes_neighbours_but_splash_does_not_detonate() -> void: var sim := _sim() var target := _enemy(sim, Vector2(50, 0)) var near := _enemy(sim, Vector2(50, 40)) # inside DETONATE_RADIUS (70), outside direct-hit range var fire := sim.content.element_index("fire") for _i in range(2): sim.elemental_system.apply_element(sim, target, fire) sim.elemental_system.apply_element(sim, near, fire) _bolt_hit(sim, sim.enemies.pos[target], 10.0) var det := 10.0 * WeaponAim.DETONATE_MULT_PER_STACK * 2.0 assert_almost_eq(sim.enemies.data[near], 1000.0 - det * WeaponAim.DETONATE_SPLASH_FRAC, 0.001, "splash share") assert_true(sim.elemental_system.is_stacked(sim, near), "splash victim keeps its stacks") assert_eq(sim.enemies.stacks[near], 2)
func test_impact_burst_does_not_detonate() -> void: var sim := _sim() var target := _enemy(sim, Vector2(50, 0)) var near := _enemy(sim, Vector2(50, 30)) # inside the 48 burst var fire := sim.content.element_index("fire") sim.elemental_system.apply_element(sim, near, fire) WeaponAim.DETONATE_RADIUS = 1.0 # isolate the impact burst from the detonation splash _bolt_hit(sim, sim.enemies.pos[target], 10.0, 48.0) WeaponAim.DETONATE_RADIUS = 70.0 assert_true(sim.elemental_system.is_stacked(sim, near), "burst splash leaves stacks alone")
func test_boss_takes_boss_fraction() -> void: var sim := _sim() var ei := _enemy(sim) sim.enemies.type_id[ei] = EnemyPool.TYPE_BOSS # is_boss_type() true; only the type id matters here var fire := sim.content.element_index("fire") sim.elemental_system.apply_element(sim, ei, fire) _bolt_hit(sim, sim.enemies.pos[ei], 10.0) var det := 10.0 * WeaponAim.DETONATE_MULT_PER_STACK * WeaponAim.DETONATE_BOSS_FRAC assert_almost_eq(sim.enemies.data[ei], 1000.0 - 10.0 - det, 0.001)Implementer check before Step 2:
add_proj(p, v, r, lifetime, dmg, el, pierce_v, split_v, knockback_v, burst_radius_v, ctx, vfx_v, rungs_v)is the verified signature (sim/proj_pool.gd:55); ctx"aim"maps toKIND_AIMviaProjPool.kind_for. Armor 0 meansdamage_enemydeals the amount unreduced. IfTYPE_BOSSis not accepted byis_boss_type(checksim/boss_rotation.gd:38), use a type id it does accept — keep the assertion.
-
Step 2: Run
-gselect=test_rift_detonation.gd→ new tests FAIL (DETONATE_*undefined). -
Step 3: Implement. In
sim/weapon_aim.gdafterDEF_BURST_RADIUS:
# Rift Bolt detonation (2026-10-12, spec docs/superpowers/specs/2026-10-12-rift-bolt-detonation-design.md):# a Rift Bolt hit on an enemy carrying weapon-applied element stacks deals an extra# bolt_hit_damage * DETONATE_MULT_PER_STACK * stacks, splashes DETONATE_SPLASH_FRAC of that within# DETONATE_RADIUS, bosses take DETONATE_BOSS_FRAC. Read at every hit (live console tunables).static var DETONATE_MULT_PER_STACK: float = 0.25static var DETONATE_RADIUS: float = 70.0static var DETONATE_SPLASH_FRAC: float = 0.5static var DETONATE_BOSS_FRAC: float = 0.5In sim/elemental_system.gd resolve_collisions, replace the line apply_element(sim, hit_ei, el) with:
# Rift Bolt (KIND_AIM) cashes in element stacks instead of adding one (2026-10-12). if sim.projectiles.kind[pi] == ProjPool.KIND_AIM: detonate(sim, hit_ei, dmg) else: apply_element(sim, hit_ei, el)Add after is_stacked:
# Rift Bolt detonation. bolt_dmg is the projectile's own damage (already carries damage_mult, power# picks and its crit roll), so detonation scales with Rift Bolt upgrades and never re-rolls crit.# One AoE group = one hit on a Wyrm's shared pool. damage_enemy only subtracts HP (deferred sweep).func detonate(sim: Sim, ei: int, bolt_dmg: float) -> void: if not is_stacked(sim, ei): return var n := sim.enemies.stacks[ei] var el := sim.enemies.aura_element[ei] var pos := sim.enemies.pos[ei] var det := bolt_dmg * WeaponAim.DETONATE_MULT_PER_STACK * float(n) # Consume: force the window shut and let decay revert it (innate aura or none), same as expiry. sim.enemies.aura_remaining[ei] = 0.0 Elemental.decay(sim.enemies, ei, 0.0) begin_aoe_group() damage_enemy(sim, ei, det * _boss_frac(sim, ei)) for oi in sim.hash.query_circle(pos, WeaponAim.DETONATE_RADIUS, sim.enemies): if oi != ei: damage_enemy(sim, oi, det * WeaponAim.DETONATE_SPLASH_FRAC * _boss_frac(sim, oi)) end_aoe_group() sim.fx_events.append({"kind": "rift_detonate", "pos": pos, "radius": WeaponAim.DETONATE_RADIUS, "element": el, "stacks": n})
func _boss_frac(sim: Sim, ei: int) -> float: return WeaponAim.DETONATE_BOSS_FRAC if sim.boss_rotation.is_boss_type(sim.enemies.type_id[ei]) else 1.0Note: resolve_collisions reads dmg before this point and the impact-burst block after it brackets its own AoE group — the groups are sequential, never nested. damage_context is still "aim" here, so detonation damage is attributed to the Rift Bolt on the death screen.
- Step 4: Register the four tunables (
net/tunables_registry.gdWEAPON_AIM block,net/live_scope.gdas[LIVE, "", "read at every Rift Bolt hit"]— NOT theHOOK_WEAPON + "aim"rebuild hook, they are never copied into the weapon), and add totools/tunable_docs/docs.d/rift_detonation.json:
"WeaponAim.DETONATE_MULT_PER_STACK": {"label": "Rift Bolt detonation per stack", "desc": "Each element stack on a Rift Bolt target adds this fraction of the shot's damage as a detonation.", "unit": "×", "src": "code", "group": "Rift Bolt"}, "WeaponAim.DETONATE_RADIUS": {"label": "Rift Bolt detonation radius", "desc": "Radius of the detonation splash around the struck enemy.", "unit": "px", "src": "code", "group": "Rift Bolt"}, "WeaponAim.DETONATE_SPLASH_FRAC": {"label": "Rift Bolt detonation splash", "desc": "Share of the detonation that nearby enemies take. Splash never detonates their own stacks.", "unit": "×", "src": "code", "group": "Rift Bolt"}, "WeaponAim.DETONATE_BOSS_FRAC": {"label": "Rift Bolt detonation vs bosses", "desc": "Share of detonation damage a boss takes.", "unit": "×", "src": "code", "group": "Rift Bolt"}Registry ranges: MULT 0.0-2.0, RADIUS 0.0-300.0, SPLASH 0.0-1.0, BOSS 0.0-1.0. Run the bh-tunable-batch liveness probe; a NO-DIFF key in the default scenario is expected for anything the probe autopilot never triggers — trace it by hand per that skill rather than reverting.
- Step 5: End-to-end XP test (the
biomasstrap rule) — append:
func test_detonation_kill_awards_xp_through_the_real_sweep() -> void: # Real spawn path (bible biomass) → bolt hit that only kills via detonation → _sweep_dead pays XP. # Precedent: tests/test_collision_damage.gd + tests/test_biomass.gd. var sim := Sim.new(1, SimContentFixture.db()) sim.player.pos = Vector2.ZERO sim._spawn_one(EnemyPool.TYPE_SWARMER, Vector2(200, 0)) var ei := sim.enemies.count - 1 assert_gt(sim.enemies.biomass[ei], 0, "real spawn sets biomass") sim.enemies.data[ei] = 12.0 # a 10-dmg bolt alone leaves it alive; 4 stacks of detonation kill it var fire := sim.content.element_index("fire") for _i in range(4): sim.elemental_system.apply_element(sim, ei, fire) var xp0 := sim.player.xp var lvl0 := sim.player.level _bolt_hit(sim, sim.enemies.pos[ei], 10.0) sim._sweep_dead() assert_eq(sim.enemies.count, 0, "detonation killed it") assert_true(sim.player.xp > xp0 or sim.player.level > lvl0, "XP paid through _sweep_dead (biomass)")With
DETONATE_MULT_PER_STACK0.25 the detonation adds 10 × 0.25 × 4 = 10 → total 20 > 12. If Task 5 later lowers the multiplier, keep this test’s numbers valid by deriving the HP from it (10.0 + 0.5 * 10.0 * WeaponAim.DETONATE_MULT_PER_STACK * 4.0).
-
Step 6: Run the file → PASS;
scripts/check-test-count.sh; re-pin determinism if it moved (only if the baseline run fires the Rift Bolt — confirm before re-pinning). -
Step 7:
scripts/gate.sh, commitfeat(aim): Rift Bolt detonates element stacks.
Task 3: Render — stack rings, detonation VFX, audio, VFX library
Section titled “Task 3: Render — stack rings, detonation VFX, audio, VFX library”Files:
- Modify:
fx/enemy_aura_look.gd(constMAX_RINGSline 18, use at line 130, header comment line 10) - Modify:
fx/fx_manager.gd(ESSENTIAL_KINDS~line 81, caps ~line 25, match block near"mark_pop"~line 743) - Modify:
audio/audio_manager.gd(SOUND_FOR_FX,cue_shape, static vars nearWILDFIRE_SFX_*~line 189) - Modify:
tools/vfx_library/library_manifest.gd(scores dict ~line 28,_entrylist ~line 149),tools/vfx_library/library.gd(preview match ~line 372) - Modify: registry / live_scope / docs.d for
EnemyAuraLook.RINGS_MAX,AudioManager.RIFT_DETONATE_SFX_DB/_PITCH - Test:
tests/test_enemy_aura_look.gd(line ~128-142),tests/test_vfx_library_manifest.gd(should pass once the entry exists)
Interfaces:
-
Consumes: fx event
rift_detonate {pos, radius, element, stacks}(Task 2). -
Produces:
static var EnemyAuraLook.RINGS_MAX: int = 6;static var AudioManager.RIFT_DETONATE_SFX_DB: float = -4.0,RIFT_DETONATE_SFX_PITCH: float = 1.35. -
Step 1: Failing test — in
tests/test_enemy_aura_look.gd, change everyEnemyAuraLook.MAX_RINGStoEnemyAuraLook.RINGS_MAX, update the line-128 comment to# stack accumulation is live (Rift Bolt detonation, 2026-10-12), and add:
func test_six_stacks_draw_six_rings_at_default_cap() -> void: assert_eq(EnemyAuraLook.RINGS_MAX, 6, "default shows every stack up to STACKS_MAX")Run -gselect=test_enemy_aura_look.gd → FAIL.
-
Step 2: Implement aura rings. Replace
const MAX_RINGS := 3withstatic var RINGS_MAX: int = 6 # live tunable (1-6): 3 vs 6 rings compared on device; line 130clampi(e.stacks[i], 1, RINGS_MAX); fix the header comment (stacks now accumulate). Register:{"key": "EnemyAuraLook.RINGS_MAX", "script": AURA_LOOK, "name": "RINGS_MAX", "min": 1, "max": 6}, live_scope[LIVE, "", "read every frame (aura ring sync)"], and add"EnemyAuraLook.RINGS_MAX": {"label": "Aura stack rings shown", "desc": "Most concentric rings drawn for an enemy's element stacks.", "unit": "rings", "src": "code", "group": "Visuals & FX"}totools/tunable_docs/docs.d/enemy_aura_look.json. Remove the “SEGS/MAX_RINGS/CAP stay const” mention of MAX_RINGS in the registry comment (line ~747). Notesync_from_simcaches on PAD/WIDTH/GAIN keys — addRINGS_MAXto that rebuild key the same way so a live edit redraws. -
Step 3: FxManager. Add
const RIFT_DETONATE_CAP := 8besideMARK_POP_CAP; add"rift_detonate": truetoESSENTIAL_KINDS(comment: damage lands); add a frame countervar rift_dets := 0besidevar mark_pops := 0; add the case after"mark_pop":
"rift_detonate": # Rift Bolt detonation (damage lands): element ring at the splash radius + a hot core # flash that grows with stacks consumed. Own cap; the ring pool is shared. if rift_dets >= RIFT_DETONATE_CAP: continue rift_dets += 1 var rdcol := _ev_color(ev) var rdn := float(ev.get("stacks", 1)) _spawn_ring(ev["pos"], float(ev.get("radius", 70.0)), rdcol) _spawn(ev["pos"], rdcol.lightened(0.4), 0.18, 0.5 + 0.12 * rdn, 0.1)(_spawn(pos, color, life, s0, s1) is the existing sprite-pop primitive at ~line 986.)
-
Step 4: Audio. In
audio/audio_manager.gd: add"rift_detonate": "scatter_explosion", # Rift Bolt detonation; pitched up so it doesn't read as ScattertoSOUND_FOR_FX; addstatic var RIFT_DETONATE_SFX_DB: float = -4.0andstatic var RIFT_DETONATE_SFX_PITCH: float = 1.35beside the wildfire pair; add"rift_detonate": return Vector2(RIFT_DETONATE_SFX_DB, RIFT_DETONATE_SFX_PITCH)tocue_shape. Register both (AUDIOscript, ranges -30..6 and 0.4..2.5) and add labels totools/tunable_docs/docs.d/sound_low_risk.jsonin the wildfire style. Do NOT add it toWEAPON_FIRE_KINDS(it is a payoff, not a firing cue). -
Step 5: VFX library. In
library_manifest.gdadd"rift_detonate": 2,to the scores dict beside"mark_pop": 2(honest: FxManager primitives, placeholder beside FX v2) and an entry aftermark_pop:
_entry("rift_detonate", "Rift Bolt Detonation", "COMBAT", "A Rift Bolt hit on an enemy carrying element stacks detonates them: element ring at the splash radius plus a core flash that grows with stacks.", "sim/elemental_system.gd detonate · sim/weapon_aim.gd DETONATE_* · fx/fx_manager.gd rift_detonate", "CURRENT", "rift_detonate", Color(0.85, 0.4, 1.0)),In library.gd preview match, after "mark_pop":
"rift_detonate": events.append({"kind": "rift_detonate", "pos": p, "radius": 70.0, "element": 0, "stacks": 4})-
Step 6: Run
-gselect=test_enemy_aura_look.gd,test_vfx_library_manifest.gd,test_audio_cooldowns.gd, thenscripts/check-test-count.sh→ PASS. -
Step 7: Visual check (no shaders touched, so no dual-renderer compile needed): run
godot --path . --rendering-method mobile res://tools/vfx_library/library.tscn, screenshot therift_detonateandelemental_auracards; confirm 6 rings read on a swarmer-sized enemy. Thengit checkout -- project.godot audio/bus_layout.tres. -
Step 8:
scripts/gate.sh, commitfeat(fx): Rift Bolt detonation ring + sound; aura shows up to 6 stack rings.
Task 4: Autopilot targets stacked enemies; tutorial line
Section titled “Task 4: Autopilot targets stacked enemies; tutorial line”Files:
- Modify:
ai/skilled_auto_player.gd(compute_aim_for~line 670) - Modify:
data/story.json(tut_riftboltlines) - Test:
tests/test_skilled_aim_stacks.gd(new)
Interfaces:
-
Consumes:
sim.elemental_system.is_stacked(sim, ei)(Task 1). -
Produces:
func _best_stacked_target(sim: Sim, pilot: PlayerState, aim_range: float) -> int(-1 when none). -
Step 1: Failing test —
tests/test_skilled_aim_stacks.gd:
extends GutTest
func _sim() -> Sim: var sim := Sim.new(1234, SimContentFixture.db()) sim.player.pos = Vector2.ZERO return sim
func _enemy(sim: Sim, pos: Vector2) -> int: var ei := sim.enemies.add(pos, Vector2.ZERO, 14.0, 1000.0, 0.0, 0.0, 12.0, 1.0, EnemyPool.TYPE_SWARMER, -1, EnemyPool.BEHAVIOR_WALK) sim.hash.rebuild(sim.enemies) return ei
func test_aims_at_the_most_stacked_enemy_when_safe() -> void: var sim := _sim() _enemy(sim, Vector2(300, 0)) # unstacked, east var stacked := _enemy(sim, Vector2(0, 320)) # stacked, south var fire := sim.content.element_index("fire") for _i in range(4): sim.elemental_system.apply_element(sim, stacked, fire) var ai := SkilledAutoPlayer.new() var dir := ai.compute_aim_for(sim, sim.player) assert_gt(dir.y, 0.9, "aims south at the stacked enemy")
func test_keeps_threat_aim_when_an_enemy_is_close() -> void: var sim := _sim() _enemy(sim, Vector2(SkilledAutoPlayer.DETONATE_DANGER_RADIUS * 0.5, 0)) # close threat, east var stacked := _enemy(sim, Vector2(0, 320)) var fire := sim.content.element_index("fire") for _i in range(4): sim.elemental_system.apply_element(sim, stacked, fire) var ai := SkilledAutoPlayer.new() var dir := ai.compute_aim_for(sim, sim.player) assert_lt(dir.y, 0.5, "danger wins over the payoff")Implementer check: confirm
SkilledAutoPlayer.new()needs no args and thatcompute_aim_forworks on a fresh instance without a prior_plancall (it calls_threat_slices); if construction differs, mirror an existingtests/test_skilled_*file. Keep both assertions.
-
Step 2: Run
-gselect=test_skilled_aim_stacks.gd→ FAIL. -
Step 3: Implement in
ai/skilled_auto_player.gd:
# Rift Bolt detonation (2026-10-12): with no enemy inside DETONATE_DANGER_RADIUS, aim at the in-range# enemy carrying the most element stacks (>= DETONATE_MIN_STACKS) to cash them in. AI behaviour,# not game tuning, so const (not a console tunable): it only shapes how honest soak/economy numbers are.const DETONATE_DANGER_RADIUS := 160.0const DETONATE_MIN_STACKS := 2
func _best_stacked_target(sim: Sim, pilot: PlayerState, aim_range: float) -> int: var best := -1 var best_stacks := DETONATE_MIN_STACKS - 1 var best_d2 := INF var r2 := aim_range * aim_range var danger2 := DETONATE_DANGER_RADIUS * DETONATE_DANGER_RADIUS for i in range(sim.enemies.count): var d2 := pilot.pos.distance_squared_to(sim.enemies.pos[i]) if d2 < danger2: return -1 # something is close: survival aim wins if d2 > r2 or not sim.elemental_system.is_stacked(sim, i): continue var s := sim.enemies.stacks[i] if s > best_stacks or (s == best_stacks and d2 < best_d2): best = i best_stacks = s best_d2 = d2 return bestand at the top of compute_aim_for, after aim_range is computed:
var det := _best_stacked_target(sim, pilot, aim_range) if det != -1: var to_det := sim.enemies.pos[det] - pilot.pos if to_det.length() > 0.001: return to_det.normalized()-
Step 4: Tutorial line. In
data/story.json,tut_riftbolt.linesbecomes:["Your ship has a second gun: the Rift Bolt.", "It fires where you point it, and faster than the Blaster.", "Your other guns leave a glow on what they hit. A Rift Bolt hit makes it explode."]Edit with apython3json round-trip that preserves the file’s existing indent style (checkhead -c 300 data/story.jsonfirst); confirm the diff touches only that entry. Then runtools/story_auto/story_trace.gd(viascripts/gate.sh’s tutorial trace,lessons: ok) and check the dialogue box fits three lines at the iPad (985 px) profile — if a UI fit test covers story dialogue, run it; if none does, screenshot the lesson viabh-visual-qa. -
Step 5:
scripts/check-test-count.sh,scripts/gate.sh, commitfeat(ai,tutorial): autopilot cashes in element stacks; Rift Bolt lesson explains detonation.
Task 5: Calibrate the power shift
Section titled “Task 5: Calibrate the power shift”Files:
-
Modify:
sim/weapon_aim.gd(DEF_DAMAGE,DETONATE_MULT_PER_STACKdefaults) -
Modify:
tests/test_weapon_aim.gd(hard-coded 6.0 / 94.0 / 97.0 values) -
Create:
docs/research/2026-10-12-rift-bolt-detonation-calibration.md(Starlighttitle:frontmatter required) -
Step 1: Measure stacks at Rift Bolt impact. Run soaks with the skilled autopilot (see
docs/reference/running-and-testing.md§ soak) for at least 5 seeds × full run, on the branch, with a temporary local counter (not committed) or astats_sinkhook that histogramsstacksat eachdetonate()call and counts unstacked bolt hits. Record: share of bolt hits that detonate, median/mean stacks consumed. -
Step 2: Solve the shift. With
p= share of bolt hits that detonate ands̄= mean stacks at detonation, today’s average bolt damage is6.0. ChooseDEF_DAMAGE = bandDETONATE_MULT_PER_STACK = msob * (1 + p * m * s̄) ≈ 6.0and a 2-stack hitb * (1 + 2m) ≈ 6.0(spec: a typical stacked hit lands ≈ today). If the two can’t both hold, prefer the average (run power level) and note the trade-off. -
Step 3: Verify run power. Paired-seed economy-sim comparison, main vs. branch, per skill
bh-economy-sim(scripts/economy-careers.sh). Accept if survival time / kills / gold per run are within the sim’s own seed noise; otherwise adjustb/mand repeat. Write the data, chosen values and caveats to the research doc. -
Step 4: Apply values to
WeaponAim.DEF_DAMAGE/DETONATE_MULT_PER_STACK; rewritetests/test_weapon_aim.gd’s literals to derive fromWeaponAim.DEF_DAMAGE(e.g.100.0 - WeaponAim.DEF_DAMAGE, burst100.0 - WeaponAim.DEF_DAMAGE * <BURST_FRAC const>) so they no longer pin 6.0; update theS5.jsonDEF_DAMAGE description only if it states a number. -
Step 5: Re-pin determinism via
bh-determinism-repin(a base-damage change moves it),scripts/gate.sh, regenerate the remote-config manifest perdocs/architecture/remote-config.mdstep 6, commitbalance(aim): shift Rift Bolt damage into detonation (calibrated, see docs/research).
Task 6: Review, merge, device
Section titled “Task 6: Review, merge, device”- Step 1:
bh-revieweragent on the branch → MERGE verdict (fix and re-review otherwise). - Step 2: Merge via skill
bh-merge-reviewed-branch(BUILD bump rules:docs/reference/builds-and-releases.md),git push origin mainimmediately. - Step 3: Add a
docs/RELEASE-1.0.1.mdline for the feature (ticked with the merge commit) and updatedocs/architecture/content-and-elemental.md’s “live system today” paragraph: stacks accumulate, status pinned to 1 stack, Rift Bolt detonates. - Step 4: Dev install to Chris’s iPhone + Apple TV (skill
bh-deploy, orbh-worktree-device-install), no background installs while Chris is mid-A/B. Hand Chris the console knobs to feel-tune live:WeaponAim.DETONATE_*,ElementalSystem.STACKS_MAX,EnemyAuraLook.RINGS_MAX(3 vs 6),AudioManager.RIFT_DETONATE_SFX_*.
Execution handoff
Section titled “Execution handoff”Written 2026-10-12 at the end of the brainstorming/planning session (Chris + Claude).
Decisions from conversation (all reflected in the spec; listed so nobody re-litigates):
- Chris’s premise was “element colours have no mechanic”. Corrected: Fire burn + Lightning shock were live (since 2026-09-23), but tiny (1 stack, 4 s); the other 8 weapon elements were pure tint. Chris accepted that framing.
- Payoff: ONE generic detonation first. Chris: “I was heading towards 2 [per-element payoffs] but lets start with 1 and then perhaps expand or test 2 and 3 later.” Per-element payoffs are a likely follow-up, so keep
detonate()easy to branch per element later (it already receives the element in its fx event). - Power: shift, don’t add (option 2). Stacks: any weapon hit, cap 6 (option 1). Trigger: direct hit + pierce only; splash does not detonate (option 1). Chain detonations parked as a future Rift Bolt upgrade / Railspike evolution path.
- Chris approved the spec with “go” after being told of three in-writing calls: own-element hits stack, bosses take a fraction, no second crit roll.
Changes made AFTER Chris’s approval while writing the plan (tell him in the execution summary):
- Detonation damage is now
bolt hit damage × DETONATE_MULT_PER_STACK × stacks(not a flat per-stack number), so it scales with Rift Bolt upgrades and inherits the shot’s crit. Spec updated. - “Mark” test fixed to
aura_remaining < BASE_AURA_PERSIST * 0.5(decay counts innate auras down too). Code says “stacks”, never “marks” (Railgun ownsmark_*). - Stack-ring A/B is a live tunable
EnemyAuraLook.RINGS_MAX(3 vs 6) instead of two coded looks. Sound pitch-by-stacks deferred (fixed pitch tunable). - The “Rift Bolt” name was already live in
tut_riftbolt; the memory note saying it was unwired is stale. WeaponAim.DEF_DAMAGEstays 6.0 until Task 5 (calibration), so Tasks 1-4 don’t churn the aim tests.
Authorized vs. needs Chris’s same-day OK:
- Authorized: worktree branch, commits, merge to main after a bh-reviewer MERGE verdict + gate,
git push origin main(repo rule: push after every merge). - Needs OK: Dev device installs (check he isn’t mid-A/B), any remote-config patch publish, any store/TestFlight build.
Already done outside the plan: spec committed on main (3ea6797c) and revised in this commit; nothing else touched.
Open for Chris after execution: feel-tune on device (detonation numbers, 3 vs 6 rings, sound level), and whether to move on to per-element payoffs.
Exact next action: consult skill bh-sdd-concurrent, create worktree .claude/worktrees/rift-detonation off origin/main, then run Task 1 via superpowers:subagent-driven-development with bh-implementer agents (pass model: "opus"; give the absolute worktree path and do NOT set Agent isolation: "worktree").