P2 Drone/Decoy System — Design
P2 Drone/Decoy System — Design
Section titled “P2 Drone/Decoy System — Design”Date: 2026-07-11 Status: Approved by Chris, ready for implementation plan.
Problem
Section titled “Problem”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).
Non-goals
Section titled “Non-goals”- 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 viaelemental_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 anonymousdamage_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’spause_menu.bay_requested/_open_bay_direct) stays P1-only and untouched. It clears and redeploys the entire drone pool from the one sharedmetasave 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 inNEXT_TASKS.md. decoy_type,max_drone_slots, and_pending_loadoutstay Sim-level shared fields — both pilots’ starter loadout and slot cap necessarily mirror the one sharedmetasave (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.
Design
Section titled “Design”1. Data model
Section titled “1. Data model”sim.drones: Array[DroneState]stays exactly as-is: one shared flat pool. Enemy AI already treats it that way (boss_warden.gd,enemy_behaviors.gdcalldrone_director.nearest_drone_posagainst 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.gdor 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 indrone_director.gdthat currently readssim.playerfor a specific drone’s positioning/healing/damage-scaling (deploy_drones,drone_sentinel/decoy_step,drone_pulse,drone_logistics,bomber_one_blast,drone_interceptor) readsd.ownerinstead — a direct substitution, sinced: DroneStateis already threaded through every one of these as a parameter.sim.drone_charge: floatmoves to a new per-pilot field,PlayerState.drone_charge: float.Sim.drone_chargebecomes a P1-only forwarding accessor (get: return player.drone_charge/set(value): player.drone_charge = value) for backward compatibility — the same pattern already used forpending_levelupsand the weapon-arsenal fields.decoy_type,max_drone_slots,_pending_loadoutstay exactly as they are today (Sim-level, singular, unchanged) per the Non-goals section above.
2. Sim.tick() threading
Section titled “2. Sim.tick() threading”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 := 0for 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 += 1drone_director.damage_drones_from_enemies(self, dt) # unchanged — iterates the shared pool genericallyupdate_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)— gainspilotparam; everysim.player.pos/.decoy_life_multread becomespilot.pos/.decoy_life_mult; every newly-createdDroneStategetsowner = pilotbefore being appended tosim.drones.update_drones(sim, pilot, input, dt)— gainspilotparam; reads/writespilot.drone_chargeinstead ofsim.drone_charge; passespilotthrough todeploy_drones/current_loadoutwhere needed.drone_sentinel/decoy_step/drone_pulse/drone_logistics/bomber_one_blast/drone_interceptor— no new parameters (they already taked: DroneState); everysim.playerreference inside becomesd.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 againstsim.player/the shared_pending_loadout), since the Bay itself stays out of scope. Its call todeploy_dronesnow needs to passsim.playerexplicitly as the new requiredpilotparam, to keep compiling — no behavior change.decoy_render_info(sim)/drones_render_info(sim)— unchanged in return shape;chargeindecoy_render_infokeeps readingsim.drone_charge(the P1 forwarding accessor), matching the HUD non-goal above.sim.gd:722’splayer.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.
4. Remove the decoy-synergy damage bonus
Section titled “4. Remove the decoy-synergy damage bonus”Delete the mechanic entirely rather than fix or preserve it (see Non-goals):
sim/sim.gd:96-97— removeconst DECOY_SYNERGY_RADIUSandconst DECOY_SYNERGY_BONUS.sim/sim.gd:516— removevar _decoy_synergy: float = 1.0.sim/sim.gd:719-724— remove the whole_decoy_synergycomputation block (theif not drones.is_empty(): var dd := ... if dd < DECOY_SYNERGY_RADIUS: ...block).sim/elemental_system.gd:146— removesim._decoy_synergyfrom thedealt := ...multiplier chain, leavingeffective * 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 referencesim._decoy_synergydirectly (they test the mechanic being removed).- Determinism is unaffected:
_decoy_synergyis already gated behindif 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.
Testing
Section titled “Testing”- New co-op tests needed: P2’s
drone_chargeaccrues independently of P1’s; a tap of P2’s decoy input deploys a drone withowner == player2(notplayer); that drone’s positioning/pulse damage/healing readsplayer2’s stats, notplayer’s; P1 and P2 can each have a drone deployed simultaneously, both present insim.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.gdassertions covering the removed decoy-synergy mechanic (section 4) — those are deliberately removed/rewritten, not preserved. - Determinism:
drone_rngis already documented as “never drawn in the no-drone baseline” — this refactor doesn’t change that. Re-verifytests/test_determinism_survival.gdas a matter of course regardless.