Skip to content

P2 Drone/Decoy System — Design

Date: 2026-07-11 Status: Approved by Chris, ready for implementation plan.

Local co-op lets P2 play alongside P1, and P2’s decoy button already computes a real per-device edge (InputRouter.poll_device/device_edges, fixed 2026-07-04/05) — but it’s dropped on the floor before reaching the drone system. sim/drone_director.gd (the deployable-drone system: loadout management, per-class AI behaviors, the recharge-and-launch ability) and the fields it operates on (sim.drones, sim.drone_charge) are entirely singular/P1-only, despite being a stated M-A milestone goal. Sim.tick() calls drone_director.update_drones(self, input, dt) exactly once per tick with only P1’s InputState — P2’s decoy tap never fires anything, and P2 never accrues drone charge.

This is the last item in the co-op sweep backlog (kill-XP, the upgrade pipeline, ship stat bonuses, boss/enemy hit-checks, and render-layer telegraphs are already fixed and per-pilot).

  • The decoy-synergy damage bonus is being REMOVED, not fixed or preserved (Chris, 2026-07-11): sim._decoy_synergy — a flat damage multiplier for fighting close to your active decoy, applied to every damage instance via elemental_system.damage_enemy — is a leftover from when reactions-mode-era elemental synergies made this kind of proximity bonus meaningful. It’s computed from P1’s position only (sim.gd:719-724) and can’t be cleanly made per-pilot without threading attacker identity through the entire anonymous damage_enemy(sim, ei, amount, ...) pipeline (every weapon, every drone class, every hazard calls it with no pilot context at all) — a much bigger change than this spec. Rather than leave a P1-biased quirk or take on that unrelated rewrite, the mechanic is deleted outright: a drone’s own attacks/abilities are sufficient value on their own, regardless of which pilot is standing near it. See Design section 4 below for the removal itself.
  • The mid-run Drone Bay (opened via the pause menu’s “Bay” button, or holding the decoy button — main.gd’s pause_menu.bay_requested/_open_bay_direct) stays P1-only and untouched. It clears and redeploys the entire drone pool from the one shared meta save profile (sim.drone_director.set_loadout + deploy_now), and giving P2 independent Bay access is a separate, undesigned feature (does P2 even get their own Bay UI, given there’s no separate save profile for a local second pilot?). This spec covers ONLY the quick-deploy decoy button (tap to recharge/launch a Sentinel drone), matching the framing already in NEXT_TASKS.md.
  • decoy_type, max_drone_slots, and _pending_loadout stay Sim-level shared fields — both pilots’ starter loadout and slot cap necessarily mirror the one shared meta save (there’s no separate account for a local P2), the same way P2 already mirrors P1’s selected ship hull (fixed 2026-07-09).
  • The in-play HUD decoy-charge indicator (the ability-button readout) stays wired to P1’s charge only. A P2-specific decoy-charge UI element is a separate, undesigned feature — same reasoning as leaving ui/weapon_panel.gd’s HUD dock P1-only in the prior P2-upgrade-pipeline plan.
  • No changes to per-class drone AI behavior (Bomber/Interceptor/Disruptor/Logistics targeting logic, damage numbers, cooldowns) — only which pilot’s stats a drone reads and which pilot’s input/charge governs deployment.
  • sim.drones: Array[DroneState] stays exactly as-is: one shared flat pool. Enemy AI already treats it that way (boss_warden.gd, enemy_behaviors.gd call drone_director.nearest_drone_pos against the whole pool regardless of which pilot fielded which drone) — this is a deliberate, approved choice (either pilot’s drone can distract/tank enemies near the other pilot; zero changes needed to any enemy-side targeting code).
  • DroneState (sim/drone_state.gd or wherever it’s defined — confirm exact file during implementation) gains a new field: var owner: PlayerState. Set once, at deploy time, and never reassigned. Every function in drone_director.gd that currently reads sim.player for a specific drone’s positioning/healing/damage-scaling (deploy_drones, drone_sentinel/ decoy_step, drone_pulse, drone_logistics, bomber_one_blast, drone_interceptor) reads d.owner instead — a direct substitution, since d: DroneState is already threaded through every one of these as a parameter.
  • sim.drone_charge: float moves to a new per-pilot field, PlayerState.drone_charge: float. Sim.drone_charge becomes a P1-only forwarding accessor (get: return player.drone_charge / set(value): player.drone_charge = value) for backward compatibility — the same pattern already used for pending_levelups and the weapon-arsenal fields.
  • decoy_type, max_drone_slots, _pending_loadout stay exactly as they are today (Sim-level, singular, unchanged) per the Non-goals section above.

sim.gd:795’s single call to drone_director.update_drones(self, input, dt) is replaced with a per-pilot loop placed at the same point in the tick, mirroring the existing per-pilot weapon-firing loop immediately above it (which already has an index-aligned _pilot_inputs: Array[InputState] built earlier in tick()):

var _pi2 := 0
for pilot in pilots:
if pilot.hp > 0.0:
var pilot_input: InputState = _pilot_inputs[_pi2] if _pi2 < _pilot_inputs.size() else null
if pilot_input != null:
drone_director.update_drones(self, pilot, pilot_input, dt)
_pi2 += 1
drone_director.damage_drones_from_enemies(self, dt) # unchanged — iterates the shared pool generically

update_drones(sim, pilot, input, dt) gains the explicit pilot param: it recharges/reads pilot.drone_charge instead of sim.drone_charge, and any newly-deployed DroneState gets owner = pilot. damage_drones_from_enemies is untouched — it operates on the shared pool generically and never referenced sim.player.

Single-player collapses to exactly today’s behavior: one pilot in the loop, one call per tick, byte-identical. A dead pilot (hp <= 0.0) simply stops accruing charge and firing existing drones’ behavior update while down — matching how weapon firing already skips dead pilots in the loop directly above.

3. drone_director.gd function-by-function changes

Section titled “3. drone_director.gd function-by-function changes”
  • deploy_drones(sim, pilot, loadout) — gains pilot param; every sim.player.pos/ .decoy_life_mult read becomes pilot.pos/.decoy_life_mult; every newly-created DroneState gets owner = pilot before being appended to sim.drones.
  • update_drones(sim, pilot, input, dt) — gains pilot param; reads/writes pilot.drone_charge instead of sim.drone_charge; passes pilot through to deploy_drones/current_loadout where needed.
  • drone_sentinel/decoy_step/drone_pulse/drone_logistics/bomber_one_blast/ drone_interceptor — no new parameters (they already take d: DroneState); every sim.player reference inside becomes d.owner.
  • deploy_now(sim) (the Bay’s explicit re-field) is UNCHANGED per the Non-goals section — it still operates P1-only (sim.drones.clear() + redeploy against sim.player/the shared _pending_loadout), since the Bay itself stays out of scope. Its call to deploy_drones now needs to pass sim.player explicitly as the new required pilot param, to keep compiling — no behavior change.
  • decoy_render_info(sim)/drones_render_info(sim) — unchanged in return shape; charge in decoy_render_info keeps reading sim.drone_charge (the P1 forwarding accessor), matching the HUD non-goal above.
  • sim.gd:722’s player.pos.distance_to(drone_director.nearest_drone_pos(self, player.pos)) is the decoy-synergy distance check itself — removed entirely by section 4, not touched here.

Delete the mechanic entirely rather than fix or preserve it (see Non-goals):

  • sim/sim.gd:96-97 — remove const DECOY_SYNERGY_RADIUS and const DECOY_SYNERGY_BONUS.
  • sim/sim.gd:516 — remove var _decoy_synergy: float = 1.0.
  • sim/sim.gd:719-724 — remove the whole _decoy_synergy computation block (the if not drones.is_empty(): var dd := ... if dd < DECOY_SYNERGY_RADIUS: ... block).
  • sim/elemental_system.gd:146 — remove sim._decoy_synergy from the dealt := ... multiplier chain, leaving effective * vuln_mult(sim, ei) * weaken_mult(sim, ei) * mark_mult(sim, ei).
  • tests/test_drones.gd:70,73,78 — remove or rewrite the 3 assertions that reference sim._decoy_synergy directly (they test the mechanic being removed).
  • Determinism is unaffected: _decoy_synergy is already gated behind if not drones.is_empty(), and drones are never deployed within the raw 600-tick single-player baseline trace (documented as “never active in baseline” at the removal site itself) — this is a pure no-op removal from the baseline’s perspective, same as the rest of the drone system.
  • New co-op tests needed: P2’s drone_charge accrues independently of P1’s; a tap of P2’s decoy input deploys a drone with owner == player2 (not player); that drone’s positioning/pulse damage/healing reads player2’s stats, not player’s; P1 and P2 can each have a drone deployed simultaneously, both present in sim.drones; a dead pilot’s charge stops accruing.
  • Existing single-player drone tests must all still pass unchanged (byte-identical behavior), EXCEPT the 3 tests/test_drones.gd assertions covering the removed decoy-synergy mechanic (section 4) — those are deliberately removed/rewritten, not preserved.
  • Determinism: drone_rng is already documented as “never drawn in the no-drone baseline” — this refactor doesn’t change that. Re-verify tests/test_determinism_survival.gd as a matter of course regardless.