Skip to content

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-chunk cycle (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).

  • Determinism MUST stay byte-identical — survival 1405185210/3122397125, crystals 91572468/1173256610. Drones are opt-in: the baseline never deploys one, so Sim.drones stays 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).
  • /sim purity: DroneState is RefCounted, no Node/Engine/Input/Time/OS/File/JSON. The loadout is plain data passed in. Randomness (none needed for Sentinel parity) flows through Sim.rng.
  • Parity: the ported Sentinel must reproduce the current decoy exactly — taunt/aggro pull, the weaving _decoy_step flight, the DECOY_PULSE_* AoE + element apply, recharge (time + on-damage), synergy bonus, heal/extras config, life. Same constants (rename DECOY_* → DRONE_* as moved).
  • Commit specific files only (other agent is on feat/ui-polish; never git add -A; never touch CLAUDE.md). One chunk = one commit on feat/drone-system.
  • sim/decoy_state.gd → rename to sim/drone_state.gd (class_name DroneState), + klass/policy.
  • sim/sim.gddecoydrones pool; deploy_drones; generalize the decoy paths; Sentinel tick.
  • render/decoy_renderer.gdrender/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.gddrone_slots (default 1) for Phase 1’s slot cap (filled by P1.3).
  • Tests: rename/extend tests/test_decoy*.gdtests/test_drones.gd; new tests/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.gdsim/drone_state.gd; sim/sim.gd (DecoyStateDroneState, 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; change class_name DecoyStateclass_name DroneState; add the two fields.
  • Step 2: in sim.gd change the type var decoy: DecoyStatevar decoy: DroneState = DroneState.new() and add const 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 a test_drone_state.gd asserting a fresh DroneState defaults klass=="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 single decoy).
  • 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 active DroneState at player.pos with its resolved numbers. Called from the input edge (below). The baseline never calls it → drones empty.
  • 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) on Sim (rename decoy.charge → a Sim.drone_charge since 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, recharge drone_charge; on input.decoy edge with full charge → deploy_drones(_pending_loadout) (the loadout handed in by main via a Sim.set_loadout(loadout) seam, default a single Sentinel built from current decoy_type cfg 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 targeting decoy.active/_nearest_decoy_posdrones_active()/nearest_drone_pos (nearest taunting Sentinel). Identical math.
  • Synergy: _decoy_synergy reads “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 → drones has 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 the DRONE_* consts (renamed from DECOY_*). 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.

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.gdrender/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_infodrones_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_decoy still picks the flavour. Determinism unchanged. Commit.
  • 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.
  • 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.