Drone System — Phase 1 (Framework) Implementation Plan
Drone System — Phase 1 (Framework) Implementation Plan
Section titled “Drone System — Phase 1 (Framework) Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development or executing-plans. Each task = one
bh-dev-chunkcycle (TDD →--import→ boot-check → full GUT suite →scripts/check-test-count.sh→ determinism). Steps use- [ ].
Goal: Generalize the single decoy into a DroneState pool driven by an INPUT loadout
(Sim.deploy_drones(loadout)), porting the current decoy as the Sentinel class at full behavioural
parity. No new classes, no Bay UI, no shop trees yet — those are Phases 2–5.
Architecture: DecoyState → DroneState (adds klass + policy); Sim.decoy → Sim.drones: Array[DroneState]; the deploy/recharge/pulse/aggro/synergy/render-info paths all iterate the pool. The
resolved loadout (classes + numeric attrs) is built render-side from MetaState and passed to
deploy_drones(loadout) — /sim just runs the numbers (pure + netcode-ready, exactly like upgrades).
Tech Stack: Godot 4.6 typed GDScript; deterministic fixed-tick /sim; GUT 9.6 headless.
Branch/worktree: feat/drone-system at ~/Claude/bullet-heaven-drones (off main 6a0d08f). Merge to
origin/main via PR/fast-forward when Phase 1 is green; deploys serialized (fetch + divergence-check first).
Global Constraints
Section titled “Global Constraints”- Determinism MUST stay byte-identical — survival
1405185210/3122397125, crystals91572468/1173256610. Drones are opt-in: the baseline never deploys one, soSim.dronesstays empty and every drone path is a guarded no-op. Re-run determinism each task; if it moves, a drone code path leaked into the no-drone baseline — stop and fix (do NOT re-pin). /simpurity:DroneStateis RefCounted, no Node/Engine/Input/Time/OS/File/JSON. The loadout is plain data passed in. Randomness (none needed for Sentinel parity) flows throughSim.rng.- Parity: the ported Sentinel must reproduce the current decoy exactly — taunt/aggro pull, the
weaving
_decoy_stepflight, theDECOY_PULSE_*AoE + element apply, recharge (time + on-damage), synergy bonus, heal/extras config, life. Same constants (renameDECOY_* → DRONE_*as moved). - Commit specific files only (other agent is on
feat/ui-polish; nevergit add -A; never touchCLAUDE.md). One chunk = one commit onfeat/drone-system.
File Structure
Section titled “File Structure”sim/decoy_state.gd→ rename tosim/drone_state.gd(class_name DroneState), +klass/policy.sim/sim.gd—decoy→dronespool;deploy_drones; generalize the decoy paths; Sentinel tick.render/decoy_renderer.gd→render/drone_renderer.gd— draw the pool (Sentinel = current visual).main.gd— build a Sentinel loadout from MetaState;decoy_render_info()→drones_render_info().sim/meta_state.gd—drone_slots(default 1) for Phase 1’s slot cap (filled by P1.3).- Tests: rename/extend
tests/test_decoy*.gd→tests/test_drones.gd; newtests/test_drone_deploy.gd.
Task 1: DecoyState → DroneState (rename + class/policy fields, single instance kept)
Section titled “Task 1: DecoyState → DroneState (rename + class/policy fields, single instance kept)”Files: rename sim/decoy_state.gd→sim/drone_state.gd; sim/sim.gd (DecoyState→DroneState,
var decoy: DroneState); rename any decoy test referencing DecoyState.
Interfaces — Produces: class_name DroneState = the old DecoyState fields + var klass: String = "sentinel" + var policy: int = 0 (0 = auto; the targeting enum lands in Phase 3). Sim.DRONE_SENTINEL := "sentinel".
- Step 1:
git mv sim/decoy_state.gd sim/drone_state.gd; changeclass_name DecoyState→class_name DroneState; add the two fields. - Step 2: in
sim.gdchange the typevar decoy: DecoyState→var decoy: DroneState = DroneState.new()and addconst DRONE_SENTINEL := "sentinel"near the DECOY consts. Leave all decoy behaviour untouched (pure rename). - Step 3:
grep -rn "DecoyState" --include=*.gd .→ fix every reference (sim + tests). Add atest_drone_state.gdasserting a freshDroneStatedefaultsklass=="sentinel",active==false. - Step 4:
--import(renamed class → cache) + boot + full suite + count + determinism unchanged. Commit.
Task 2: Sim.drones pool + deploy_drones(loadout) (THE refactor) — parity Sentinel
Section titled “Task 2: Sim.drones pool + deploy_drones(loadout) (THE refactor) — parity Sentinel”Files: sim/sim.gd, tests/test_drones.gd (rewrite from the decoy tests), tests/test_drone_deploy.gd (new).
Interfaces — Produces:
Sim.drones: Array[DroneState] = [](replaces the singledecoy).Sim.deploy_drones(loadout: Array) -> void— loadout =[{klass, life_mult, power_mult, dmg, radius, heal, extras, policy}, …](plain data). For each entry under the slot cap, push a fresh activeDroneStateatplayer.poswith its resolved numbers. Called from the input edge (below). The baseline never calls it →dronesempty.Sim.drones_active() -> bool,Sim.drone_positions() -> Array,Sim.nearest_drone_pos(p) -> Vector2(taunting Sentinels only),Sim.drones_render_info() -> Array(per-drone {klass,pos,life,…} + the recharge/charge read for the HUD/button).- A single run-level charge/recharge model: keep one shared
decoy_charge/recharge (the button’s cooldown) onSim(renamedecoy.charge→ aSim.drone_chargesince charge is per-LAUNCH not per-drone). Recharge by time (DRONE_RECHARGE_RATE) + on player damage (DRONE_RECHARGE_ON_DMG), exactly as today.
Behaviour port (parity):
_update_decoy(input, dt)→_update_drones(input, dt): when none active, rechargedrone_charge; oninput.decoyedge with full charge →deploy_drones(_pending_loadout)(the loadout handed in by main via aSim.set_loadout(loadout)seam, default a single Sentinel built from currentdecoy_typecfg so parity holds), reset charge. Each tick: advance every drone’s life/flight (_drone_step, = the old_decoy_step), pulse on its timer (_drone_pulse, =_decoy_pulse), drop expired drones.- Aggro hook:
_move_enemies+ boss-missile targetingdecoy.active/_nearest_decoy_pos→drones_active()/nearest_drone_pos(nearest taunting Sentinel). Identical math. - Synergy:
_decoy_synergyreads “nearest active drone” instead of the single decoy. Identical formula (1.0 when none → baseline-safe). - End-of-tick on-damage recharge: unchanged, keyed on
drone_charge(only when no drone active). - Step 1: Rewrite the decoy tests as
test_drones.gd: deploy a Sentinel loadout →droneshas 1 active; it pulses (enemies in radius take damage); aggro (a walker targets the drone); it expires after life; recharge gates redeploy; synergy bonus when fighting near a drone; 1.0 with none.test_drone_deploy.gd:deploy_drones([])is a no-op; slot cap respected (P1.3 extends). - Step 2: Run — expect FAIL (pool/methods missing).
- Step 3: Implement the pool +
deploy_drones+ the generalized paths; delete the single-decoy branches. Keep theDRONE_*consts (renamed fromDECOY_*). The DECOY_TYPES table stays as the Sentinel cfg source for now (Phase 4 replaces it with shop-resolved attrs). - Step 4:
--import+ boot + full suite + count + determinism byte-identical (the no-deploy baseline must match the pinned hashes exactly — this is the keystone check for the whole refactor). Commit.
Task 3: Drone-slots stat (flat max-N)
Section titled “Task 3: Drone-slots stat (flat max-N)”Files: sim/meta_state.gd (drone_slots default 1 + read), sim/sim.gd (deploy_drones honours a
max_slots), sim/stat_effects.gd (optional drone_slots effect for a meta “+1 slot”), tests/test_drones.gd.
Interfaces — Produces: Sim.max_drone_slots: int = 1 (set render-side from MetaState before the
run, like other meta bonuses); deploy_drones fields at most max_drone_slots drones.
- Step 1: Test: with
max_drone_slots = 1, a 3-entry loadout deploys 1; with 3, deploys 3. - Step 2: FAIL → implement the cap. (No determinism impact — default 1, baseline never deploys.)
- Step 3: Boot + suite + count + determinism unchanged. Commit.
Task 4: Render — DroneRenderer over the pool (Sentinel = current decoy visual)
Section titled “Task 4: Render — DroneRenderer over the pool (Sentinel = current decoy visual)”Files: rename render/decoy_renderer.gd→render/drone_renderer.gd; main.gd (instantiate + feed
sim.drones_render_info()).
Interfaces — Produces: DroneRenderer.update(drones_info: Array) draws each drone; Sentinel uses the
existing decoy visual (+ companions/extras). Render-only → determinism untouched (the renderer binds no
sim state the checksum reads).
- Render-only — verify by boot + (later) playtest. Update the one main.gd call site
(
decoy_render_info→drones_render_info). Commit.
Task 5: Loadout source — main builds a Sentinel loadout from MetaState (parity bridge)
Section titled “Task 5: Loadout source — main builds a Sentinel loadout from MetaState (parity bridge)”Files: main.gd (sim.set_loadout(...) at run start from meta.selected_decoy cfg → a single
Sentinel entry; the decoy button still deploys it). Keep the existing decoy shop entries mapping to the
Sentinel cfg so nothing breaks before Phase 4.
Interfaces — Consumes: Sim.set_loadout. Produces: the in-run drone matches today’s decoy.
- Boot + playtest: pressing the drone button deploys a Sentinel that behaves exactly like the old
decoy;
selected_decoystill picks the flavour. Determinism unchanged. Commit.
Task 6: Phase-1 review + merge prep
Section titled “Task 6: Phase-1 review + merge prep”- Whole-branch review (determinism parity is the load-bearing invariant). Then merge
feat/drone-system→ origin/main (fetch + divergence-check first; serialize the deploy). Phase 2 (Bomber/Interceptor/ Disruptor/Logistics behaviours) gets its own plan.
Sequencing notes
Section titled “Sequencing notes”- Task 2 is the heavy, determinism-critical refactor (single decoy → pool). Everything else is additive or render-only. The parity test + the byte-identical determinism check at Task 2 are the gates that prove the refactor didn’t change no-drone behaviour. Phases 2–5 (classes, Bay UI, shop trees, counter-tuning) each get their own plan + deploy per the spec’s phasing.