Skip to content

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 skill bh-sdd-concurrent before Task 1 and bh-dev-chunk for 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

  • /sim is pure logic: no Node/render/Input/Engine/Time APIs; every damage goes through sim.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 skill bh-determinism-repin in 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 var tunable: net/tunables_registry.gd entry + net/live_scope.gd entry + label in a tools/tunable_docs/docs.d/*.json file (never hand-edit control/src/tunable_docs.js). Recipe: skill bh-tunable-batch.
  • Annotate Dictionary.get() results as Variant, not inferred :=.
  • Run the suite only via scripts/check-test-count.sh (or -gselect=<file>.gd for one script); never pull a whole raw GUT run into context. Set GUT_LOG=<own path> when running in a worktree.
  • Work in a worktree under .claude/worktrees/rift-detonation branched off origin/main; scripts/gate.sh must 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 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 other ELEMENTAL entries), 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) -> bool on ElementalSystem (instance method, called as sim.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 = 6

Replace 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.5

In 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.gd test_reapplying_same_element_refreshes_without_stacking: rename to test_reapplying_same_element_refreshes_and_stacks, assert stacks == 2 with message "stack accumulation on (Rift Bolt detonation, 2026-10-12)"; keep the refresh assert.
    • tests/test_elemental_coverage.gd test_burn_dot_reduces_hp_and_is_not_shock_amped: expected HP becomes 1000.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_MAX

    • net/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. Then scripts/check-test-count.sh. Determinism baselines will likely fail (stacks are in snapshot_string): follow bh-determinism-repin and re-pin in this task.

  • Step 7: scripts/gate.sh → exit 0, then commit feat(elemental): weapon hits accumulate element stacks; status pinned to 1 stack.


Files:

  • Modify: sim/weapon_aim.gd (static vars block), sim/elemental_system.gd (resolve_collisions ~line 125; new detonate)
  • 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) -> void on ElementalSystem; 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 to KIND_AIM via ProjPool.kind_for. Armor 0 means damage_enemy deals the amount unreduced. If TYPE_BOSS is not accepted by is_boss_type (check sim/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.gd after DEF_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.25
static var DETONATE_RADIUS: float = 70.0
static var DETONATE_SPLASH_FRAC: float = 0.5
static var DETONATE_BOSS_FRAC: float = 0.5

In 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.0

Note: 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.gd WEAPON_AIM block, net/live_scope.gd as [LIVE, "", "read at every Rift Bolt hit"] — NOT the HOOK_WEAPON + "aim" rebuild hook, they are never copied into the weapon), and add to tools/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 biomass trap 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_STACK 0.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, commit feat(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 (const MAX_RINGS line 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 near WILDFIRE_SFX_* ~line 189)
  • Modify: tools/vfx_library/library_manifest.gd (scores dict ~line 28, _entry list ~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 every EnemyAuraLook.MAX_RINGS to EnemyAuraLook.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 := 3 with static var RINGS_MAX: int = 6 # live tunable (1-6): 3 vs 6 rings compared on device; line 130 clampi(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"} to tools/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). Note sync_from_sim caches on PAD/WIDTH/GAIN keys — add RINGS_MAX to that rebuild key the same way so a live edit redraws.

  • Step 3: FxManager. Add const RIFT_DETONATE_CAP := 8 beside MARK_POP_CAP; add "rift_detonate": true to ESSENTIAL_KINDS (comment: damage lands); add a frame counter var rift_dets := 0 beside var 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 Scatter to SOUND_FOR_FX; add static var RIFT_DETONATE_SFX_DB: float = -4.0 and static var RIFT_DETONATE_SFX_PITCH: float = 1.35 beside the wildfire pair; add "rift_detonate": return Vector2(RIFT_DETONATE_SFX_DB, RIFT_DETONATE_SFX_PITCH) to cue_shape. Register both (AUDIO script, ranges -30..6 and 0.4..2.5) and add labels to tools/tunable_docs/docs.d/sound_low_risk.json in the wildfire style. Do NOT add it to WEAPON_FIRE_KINDS (it is a payoff, not a firing cue).

  • Step 5: VFX library. In library_manifest.gd add "rift_detonate": 2, to the scores dict beside "mark_pop": 2 (honest: FxManager primitives, placeholder beside FX v2) and an entry after mark_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, then scripts/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 the rift_detonate and elemental_aura cards; confirm 6 rings read on a swarmer-sized enemy. Then git checkout -- project.godot audio/bus_layout.tres.

  • Step 8: scripts/gate.sh, commit feat(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_riftbolt lines)
  • 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 that compute_aim_for works on a fresh instance without a prior _plan call (it calls _threat_slices); if construction differs, mirror an existing tests/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.0
const 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 best

and 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.lines becomes: ["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 a python3 json round-trip that preserves the file’s existing indent style (check head -c 300 data/story.json first); confirm the diff touches only that entry. Then run tools/story_auto/story_trace.gd (via scripts/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 via bh-visual-qa.

  • Step 5: scripts/check-test-count.sh, scripts/gate.sh, commit feat(ai,tutorial): autopilot cashes in element stacks; Rift Bolt lesson explains detonation.


Files:

  • Modify: sim/weapon_aim.gd (DEF_DAMAGE, DETONATE_MULT_PER_STACK defaults)

  • 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 (Starlight title: 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 a stats_sink hook that histograms stacks at each detonate() 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 and s̄ = mean stacks at detonation, today’s average bolt damage is 6.0. Choose DEF_DAMAGE = b and DETONATE_MULT_PER_STACK = m so b * (1 + p * m * s̄) ≈ 6.0 and a 2-stack hit b * (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 adjust b/m and repeat. Write the data, chosen values and caveats to the research doc.

  • Step 4: Apply values to WeaponAim.DEF_DAMAGE / DETONATE_MULT_PER_STACK; rewrite tests/test_weapon_aim.gd’s literals to derive from WeaponAim.DEF_DAMAGE (e.g. 100.0 - WeaponAim.DEF_DAMAGE, burst 100.0 - WeaponAim.DEF_DAMAGE * <BURST_FRAC const>) so they no longer pin 6.0; update the S5.json DEF_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 per docs/architecture/remote-config.md step 6, commit balance(aim): shift Rift Bolt damage into detonation (calibrated, see docs/research).


  • Step 1: bh-reviewer agent 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 main immediately.
  • Step 3: Add a docs/RELEASE-1.0.1.md line for the feature (ticked with the merge commit) and update docs/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, or bh-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_*.

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 owns mark_*).
  • 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_DAMAGE stays 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").