Skip to content

P2 Drone/Decoy System 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.

Goal: Let a second local-co-op pilot (P2) recharge and deploy their own drone independently of P1 — closing the last item in the co-op sweep backlog — and remove the now-unneeded decoy-proximity damage bonus.

Architecture: Give each DroneState an owner: PlayerState field so a shared drone pool (Sim.drones, unchanged — enemies already target it as one flat list) can still tell which pilot’s stats/input/charge govern each drone. Move drone_charge off Sim onto PlayerState (per-pilot, with a P1-only forwarding accessor for back-compat). Thread Sim.tick()’s single update_drones call into a per-pilot loop mirroring the existing weapon-firing loop’s _pilot_inputs pattern. Separately, delete the decoy-proximity damage-synergy mechanic outright.

Tech Stack: Godot 4.6.3 / typed GDScript, GUT 9.6.0 test framework.

  • Single-player behavior must stay byte-identical (pilots.size() == 1 collapses to today’s exact code path) — re-verify the determinism baseline in tests/test_determinism_survival.gd after every task.
  • sim.drones: Array[DroneState] stays ONE shared flat pool — do NOT split it into per-pilot arrays. Enemy AI (boss_warden.gd, enemy_behaviors.gd) already treats it as one target list via nearest_drone_pos; this is deliberate (either pilot’s drone can distract/tank for the other) and needs zero changes to any enemy-side code.
  • decoy_type, max_drone_slots, _pending_loadout stay Sim-level shared, unchanged — both pilots’ starter loadout/slot cap mirror the one shared meta save profile, same as ship hull.
  • The mid-run Drone Bay (main.gd’s pause_menu.bay_requested/_open_bay_direct, drone_director.deploy_now/set_loadout) stays P1-only and untouched in behavior — only its one call into deploy_drones needs a sim.player argument added to keep compiling.
  • The in-play HUD decoy-charge indicator stays wired to P1’s charge only (decoy_render_info/drones_render_info return shapes are unchanged) — a P2-specific HUD element is a separate, undesigned feature.
  • The decoy-proximity damage-synergy mechanic (Sim._decoy_synergy, DECOY_SYNERGY_RADIUS/DECOY_SYNERGY_BONUS) is being DELETED, not fixed or preserved (Chris’s explicit call, 2026-07-11) — it’s a reactions-mode-era leftover, and a drone’s own attacks/abilities are sufficient value regardless of which pilot stands near it.
  • A missed call site fails LOUD at load time (GDScript’s static arg-count check), not silently — grep the whole repo to build each task’s change list, then let the importer’s error output catch anything missed.

Task 1: owner/drone_charge per-pilot data model + mechanical signature threading

Section titled “Task 1: owner/drone_charge per-pilot data model + mechanical signature threading”

Behavior-preserving for single-player. DroneState gains an owner field; drone_charge moves to PlayerState; drone_director.gd’s functions that take a DroneState read d.owner instead of sim.player; deploy_drones/update_drones/decoy_step gain an explicit pilot param; every existing call site (the one production call site in sim.gd, plus ~50 test call sites) is updated to keep compiling and single-player byte-identical. Real co-op wiring (calling update_drones once PER PILOT instead of once with P1 only) is Task 2, not this one.

Files:

  • Modify: sim/drone_state.gd (add owner field)
  • Modify: sim/player_state.gd (add drone_charge field)
  • Modify: sim/sim.gd (turn drone_charge into a forwarding accessor; fix the one update_drones call site to pass player explicitly — NOT yet the real per-pilot loop)
  • Modify: sim/drone_director.gd (signatures + internal sim.playerd.owner/pilot substitutions, listed function-by-function below)
  • Modify: 13 test files (exhaustive checklist below)
  • Test: full existing GUT suite (behavior is unchanged — the existing suite passing IS the test)

Interfaces:

  • Produces:

    • DroneState.owner: PlayerState (new field, sim/drone_state.gd)
    • PlayerState.drone_charge: float (new field, sim/player_state.gd, default 0.0)
    • DroneDirector.deploy_drones(sim: Sim, pilot: PlayerState, loadout: Array) -> void
    • DroneDirector.update_drones(sim: Sim, pilot: PlayerState, input: InputState, dt: float) -> void
    • DroneDirector.decoy_step(sim: Sim, pilot: PlayerState, pos: Vector2, vel: Vector2, phase: float, dt: float, speed_mult: float = 1.0) -> Array
    • DroneDirector.pilot_has_drone(sim: Sim, pilot: PlayerState) -> bool (new helper)
    • Unchanged signatures: drone_behavior(sim, d, dt), drone_logistics(sim, d, dt), drone_disruptor(sim, d, dt), drone_bomber(sim, d, dt), bomber_blast(sim, d), bomber_one_blast(sim, d, center), drone_interceptor(sim, d, dt), drone_chain_strike(sim, from, dmg, hops, already), drone_sentinel(sim, d, dt), drone_kinds_for(sim, klass), nearest_enemy_of_kinds(sim, pos, kinds), damage_drones_from_enemies(sim, dt), drone_destroyed(sim, d), drone_pulse(sim, d), pulse_at(sim, pos, dmg, radius), decoy_positions(sim), drones_active(sim), nearest_drone_pos(sim, p) (body changes, signature doesn’t), decoy_render_info(sim), drones_render_info(sim), deploy_now(sim), sentinel_cfg(sim), current_loadout(sim), enemy_speed_scale(sim, i).
  • Consumes: PlayerState (sim/player_state.gd, existing fields pos, hp, max_hp, damage_mult, decoy_power_mult, decoy_life_mult), Sim._nearest_pilot(from: Vector2) -> PlayerState (already exists, used by nearest_drone_pos’s new fallback).

  • Step 1: Add the owner field to DroneState

sim/drone_state.gd — OLD:

var klass: String = "sentinel" # drone class id (Phase 2 adds bomber/interceptor/disruptor/logistics)
var policy: int = 0 # targeting policy (0 = auto; the priority enum lands in Phase 3)

NEW:

var owner: PlayerState = null # which pilot fielded this drone (co-op, M-A task, 2026-07-11)
var klass: String = "sentinel" # drone class id (Phase 2 adds bomber/interceptor/disruptor/logistics)
var policy: int = 0 # targeting policy (0 = auto; the priority enum lands in Phase 3)
  • Step 2: Add drone_charge to PlayerState

sim/player_state.gd — OLD:

var decoy_power_mult: float = 1.0 # meta: scales decoy pulse damage
var decoy_life_mult: float = 1.0 # meta: scales decoy duration

NEW:

var decoy_power_mult: float = 1.0 # meta: scales decoy pulse damage
var decoy_life_mult: float = 1.0 # meta: scales decoy duration
# Per-pilot launch charge, 0..1 (recharges over time + faster on damage taken; consumed on
# deploy). Sim.drone_charge delegates here for P1/single-player back-compat, same pattern as
# pending_levelups (co-op, M-A task, 2026-07-11).
var drone_charge: float = 0.0
  • Step 3: Turn Sim.drone_charge into a forwarding accessor

sim/sim.gd — OLD:

var drones: Array[DroneState] = [] # deployed drones (empty = none; Phase 1 = Sentinels)
var drone_charge: float = 0.0 # 0..1 shared launch charge (recharges over time + on damage)
var drone_recharge_mult: float = 1.0 # INPUT from the shop "drone-recharge" upgrade (1.0 = none → baseline-safe)

NEW:

var drones: Array[DroneState] = [] # deployed drones (empty = none; Phase 1 = Sentinels)
# Per-pilot forwarding accessor (co-op, M-A task, 2026-07-11) — delegates to player.drone_charge
# (P1) for single-player/back-compat callers; player2.drone_charge accrues independently.
var drone_charge: float:
get: return player.drone_charge
set(value): player.drone_charge = value
var drone_recharge_mult: float = 1.0 # INPUT from the shop "drone-recharge" upgrade (1.0 = none → baseline-safe)
  • Step 4: Apply the following function-by-function replacements to sim/drone_director.gd

update_drones — OLD:

func update_drones(sim: Sim, input: InputState, dt: float) -> void:
if sim.drones.is_empty():
sim.drone_charge = minf(sim.drone_charge + DECOY_RECHARGE_RATE * sim.drone_recharge_mult * dt, 1.0)
if input.decoy and sim.drone_charge >= 1.0:
deploy_drones(sim, current_loadout(sim))
sim.drone_charge = 0.0
return
var i := sim.drones.size() - 1
while i >= 0:
var d: DroneState = sim.drones[i]
d.life -= dt
if d.life <= 0.0:
drone_destroyed(sim, d)
sim.drones.remove_at(i)
i -= 1
continue
drone_behavior(sim, d, dt)
i -= 1

NEW:

func update_drones(sim: Sim, pilot: PlayerState, input: InputState, dt: float) -> void:
if not pilot_has_drone(sim, pilot):
pilot.drone_charge = minf(pilot.drone_charge + DECOY_RECHARGE_RATE * sim.drone_recharge_mult * dt, 1.0)
if input.decoy and pilot.drone_charge >= 1.0:
deploy_drones(sim, pilot, current_loadout(sim))
pilot.drone_charge = 0.0
return
var i := sim.drones.size() - 1
while i >= 0:
var d: DroneState = sim.drones[i]
if d.owner != pilot:
i -= 1
continue
d.life -= dt
if d.life <= 0.0:
drone_destroyed(sim, d)
sim.drones.remove_at(i)
i -= 1
continue
drone_behavior(sim, d, dt)
i -= 1
# True if any drone in the shared pool belongs to this pilot. Gates a pilot's own
# recharge-and-deploy cycle independently of any drone the OTHER pilot may already have out.
func pilot_has_drone(sim: Sim, pilot: PlayerState) -> bool:
for d in sim.drones:
if d.owner == pilot:
return true
return false

Note the gate changed from “is the WHOLE shared pool empty” to “does THIS pilot own a drone” — in single-player these are always equivalent (only one possible owner), so this is behavior-preserving for 1P, but it’s a REQUIRED correctness fix for the shared-pool co-op case landing in Task 2 (otherwise P1 having a drone out would block P2 from ever recharging/deploying their own).

drone_logistics — OLD:

func drone_logistics(sim: Sim, d: DroneState, dt: float) -> void:
var to := sim.player.pos - d.pos
var dist := to.length()
if dist > 1.0:
d.pos += (to / dist) * LOGI_SPEED * float(d.cfg.get("speed", 1.0)) * dt
d.pulse_timer -= dt
if d.pulse_timer <= 0.0:
d.pulse_timer = LOGI_CD
var power := float(d.cfg.get("dmg", 1.0))
if sim.player.hp < sim.player.max_hp:
sim.player.hp = minf(sim.player.hp + LOGI_REPAIR * power, sim.player.max_hp)
var rr := LOGI_RADIUS * float(d.cfg.get("radius", 1.0))
for other in sim.drones:
if other != d and d.pos.distance_squared_to(other.pos) <= rr * rr:
other.life += LOGI_DRONE_REPAIR
sim.fx_events.append({"kind": "drone_pulse", "pos": d.pos, "element": sim.blade_element_idx})
var oc := int(d.cfg.get("overcharge", 0))
if oc > 0:
d.charge += dt
if d.charge >= LOGI_OVERCHARGE_CD:
d.charge = 0.0
sim.player.hp = minf(sim.player.hp + LOGI_OVERCHARGE_HEAL * float(oc), sim.player.max_hp)
var orr := LOGI_RADIUS * float(d.cfg.get("radius", 1.0))
for other in sim.drones:
if other != d and d.pos.distance_squared_to(other.pos) <= orr * orr:
other.hp = minf(other.hp + other.max_hp * LOGI_OVERCHARGE_DRONE, other.max_hp)
sim.fx_events.append({"kind": "reaction", "pos": d.pos, "element": sim.blade_element_idx, "name": "OVERCHARGE"})

NEW: (only the sim.playerd.owner substitutions; the for other in sim.drones: nearby-drone-repair loops are UNCHANGED and deliberately still iterate the whole shared pool — a Logistics drone repairs/overcharges any nearby drone regardless of owner, matching the shared-pool design)

func drone_logistics(sim: Sim, d: DroneState, dt: float) -> void:
var to := d.owner.pos - d.pos
var dist := to.length()
if dist > 1.0:
d.pos += (to / dist) * LOGI_SPEED * float(d.cfg.get("speed", 1.0)) * dt
d.pulse_timer -= dt
if d.pulse_timer <= 0.0:
d.pulse_timer = LOGI_CD
var power := float(d.cfg.get("dmg", 1.0))
if d.owner.hp < d.owner.max_hp:
d.owner.hp = minf(d.owner.hp + LOGI_REPAIR * power, d.owner.max_hp)
var rr := LOGI_RADIUS * float(d.cfg.get("radius", 1.0))
for other in sim.drones:
if other != d and d.pos.distance_squared_to(other.pos) <= rr * rr:
other.life += LOGI_DRONE_REPAIR
sim.fx_events.append({"kind": "drone_pulse", "pos": d.pos, "element": sim.blade_element_idx})
var oc := int(d.cfg.get("overcharge", 0))
if oc > 0:
d.charge += dt
if d.charge >= LOGI_OVERCHARGE_CD:
d.charge = 0.0
d.owner.hp = minf(d.owner.hp + LOGI_OVERCHARGE_HEAL * float(oc), d.owner.max_hp)
var orr := LOGI_RADIUS * float(d.cfg.get("radius", 1.0))
for other in sim.drones:
if other != d and d.pos.distance_squared_to(other.pos) <= orr * orr:
other.hp = minf(other.hp + other.max_hp * LOGI_OVERCHARGE_DRONE, other.max_hp)
sim.fx_events.append({"kind": "reaction", "pos": d.pos, "element": sim.blade_element_idx, "name": "OVERCHARGE"})

drone_disruptor — OLD line:

var anchor := sim.enemies.pos[ti] if ti != -1 else sim.player.pos

(the FIRST occurrence, inside drone_disruptor) NEW:

var anchor := sim.enemies.pos[ti] if ti != -1 else d.owner.pos

drone_bomber — OLD line (SECOND occurrence of the same pattern, inside drone_bomber):

var anchor := sim.enemies.pos[ti] if ti != -1 else sim.player.pos

NEW:

var anchor := sim.enemies.pos[ti] if ti != -1 else d.owner.pos

bomber_one_blast — OLD:

var base := BOMBER_DMG * sim.player.damage_mult * sim.player.decoy_power_mult * float(d.cfg.get("dmg", 1.0))

NEW:

var base := BOMBER_DMG * d.owner.damage_mult * d.owner.decoy_power_mult * float(d.cfg.get("dmg", 1.0))

drone_interceptor — OLD (the anchor line, THIRD occurrence of the pattern, plus the damage line):

var anchor := sim.enemies.pos[ti] if ti != -1 else sim.player.pos
var to := anchor - d.pos
var dist := to.length()
if dist > 1.0:
d.pos += (to / dist) * INTERCEPTOR_SPEED * float(d.cfg.get("speed", 1.0)) * dt
d.pulse_timer -= dt
if d.pulse_timer <= 0.0:
d.pulse_timer = INTERCEPTOR_CD
var radius := INTERCEPTOR_RADIUS * float(d.cfg.get("radius", 1.0))
var dmg := INTERCEPTOR_DMG * sim.player.damage_mult * sim.player.decoy_power_mult * float(d.cfg.get("dmg", 1.0))

NEW:

var anchor := sim.enemies.pos[ti] if ti != -1 else d.owner.pos
var to := anchor - d.pos
var dist := to.length()
if dist > 1.0:
d.pos += (to / dist) * INTERCEPTOR_SPEED * float(d.cfg.get("speed", 1.0)) * dt
d.pulse_timer -= dt
if d.pulse_timer <= 0.0:
d.pulse_timer = INTERCEPTOR_CD
var radius := INTERCEPTOR_RADIUS * float(d.cfg.get("radius", 1.0))
var dmg := INTERCEPTOR_DMG * d.owner.damage_mult * d.owner.decoy_power_mult * float(d.cfg.get("dmg", 1.0))

drone_sentinel — OLD:

func drone_sentinel(sim: Sim, d: DroneState, dt: float) -> void:
d.phase += dt
var sp := float(d.cfg.get("speed", 1.0))
var r := decoy_step(sim, d.pos, d.vel, d.phase, dt, sp)
d.pos = r[0]
d.vel = r[1]
for e in d.extra:
e["phase"] = float(e["phase"]) + dt
var er := decoy_step(sim, e["pos"], e["vel"], float(e["phase"]), dt, sp)
e["pos"] = er[0]
e["vel"] = er[1]
d.pulse_timer -= dt
if d.pulse_timer <= 0.0:
d.pulse_timer = DECOY_PULSE_INTERVAL
drone_pulse(sim, d)

NEW:

func drone_sentinel(sim: Sim, d: DroneState, dt: float) -> void:
d.phase += dt
var sp := float(d.cfg.get("speed", 1.0))
var r := decoy_step(sim, d.owner, d.pos, d.vel, d.phase, dt, sp)
d.pos = r[0]
d.vel = r[1]
for e in d.extra:
e["phase"] = float(e["phase"]) + dt
var er := decoy_step(sim, d.owner, e["pos"], e["vel"], float(e["phase"]), dt, sp)
e["pos"] = er[0]
e["vel"] = er[1]
d.pulse_timer -= dt
if d.pulse_timer <= 0.0:
d.pulse_timer = DECOY_PULSE_INTERVAL
drone_pulse(sim, d)

deploy_drones — OLD:

func deploy_drones(sim: Sim, loadout: Array) -> void:
var n := mini(loadout.size(), sim.max_drone_slots)
for idx in range(n):
var spec: Dictionary = loadout[idx]
var d := DroneState.new()
d.klass = String(spec.get("klass", DRONE_SENTINEL))
d.policy = int(spec.get("policy", 0))
d.cfg = spec
d.active = true
d.pos = sim.player.pos
d.vel = Vector2.ZERO
d.phase = 0.0
d.pulse_timer = 0.0
d.max_hp = DRONE_BASE_HP * float(spec.get("durability", 1.0))
d.hp = d.max_hp
d.life = DECOY_LIFE * sim.player.decoy_life_mult * float(spec.get("life", 1.0))
d.extra = []
var extras := int(spec.get("extras", 0)) # carrier/ultimate companions
for k in range(extras):
var a := TAU * float(k) / float(maxi(extras, 1))
d.extra.append({"pos": sim.player.pos + Vector2(cos(a), sin(a)) * 90.0, "vel": Vector2.ZERO, "phase": a})
sim.drones.append(d)
if not sim.drones.is_empty():
sim.fx_events.append({"kind": "reaction", "pos": sim.player.pos, "element": sim.blade_element_idx, "name": "DRONE"})

NEW:

func deploy_drones(sim: Sim, pilot: PlayerState, loadout: Array) -> void:
var n := mini(loadout.size(), sim.max_drone_slots)
var deployed_any := false
for idx in range(n):
var spec: Dictionary = loadout[idx]
var d := DroneState.new()
d.owner = pilot
d.klass = String(spec.get("klass", DRONE_SENTINEL))
d.policy = int(spec.get("policy", 0))
d.cfg = spec
d.active = true
d.pos = pilot.pos
d.vel = Vector2.ZERO
d.phase = 0.0
d.pulse_timer = 0.0
d.max_hp = DRONE_BASE_HP * float(spec.get("durability", 1.0))
d.hp = d.max_hp
d.life = DECOY_LIFE * pilot.decoy_life_mult * float(spec.get("life", 1.0))
d.extra = []
var extras := int(spec.get("extras", 0)) # carrier/ultimate companions
for k in range(extras):
var a := TAU * float(k) / float(maxi(extras, 1))
d.extra.append({"pos": pilot.pos + Vector2(cos(a), sin(a)) * 90.0, "vel": Vector2.ZERO, "phase": a})
sim.drones.append(d)
deployed_any = true
if deployed_any:
sim.fx_events.append({"kind": "reaction", "pos": pilot.pos, "element": sim.blade_element_idx, "name": "DRONE"})

Note the if not sim.drones.is_empty():if deployed_any: change: the old check relied on the pool ALWAYS being empty before deploy_drones runs (true in the old single-owner world — the recharge path only calls it when the pool is empty, and the Bay’s deploy_now clears the pool immediately first). That invariant breaks once the pool is shared: if P1 already has a drone out and P2 deploys an EMPTY loadout, sim.drones.is_empty() would still read false (P1’s drone is still there) even though P2 deployed nothing — deployed_any is a required correctness fix, not an optional style change.

decoy_step — OLD:

func decoy_step(sim: Sim, pos: Vector2, vel: Vector2, phase: float, dt: float, speed_mult: float = 1.0) -> Array:
var ti := sim._nearest_enemy_to(pos)
var anchor := sim.enemies.pos[ti] if ti != -1 else sim.player.pos

NEW:

func decoy_step(sim: Sim, pilot: PlayerState, pos: Vector2, vel: Vector2, phase: float, dt: float, speed_mult: float = 1.0) -> Array:
var ti := sim._nearest_enemy_to(pos)
var anchor := sim.enemies.pos[ti] if ti != -1 else pilot.pos

(The rest of the function body — the wander/steering math — reads no sim.player/pilot state and is unchanged.)

drone_pulse — OLD:

func drone_pulse(sim: Sim, d: DroneState) -> void:
var cfg: Dictionary = d.cfg
var dmg := DECOY_BASE_DAMAGE * sim.player.damage_mult * DECOY_POWER * sim.player.decoy_power_mult * float(cfg.get("dmg", 1.0))
var radius := DECOY_PULSE_RADIUS * float(cfg.get("radius", 1.0))
pulse_at(sim, d.pos, dmg, radius)
for e in d.extra:
pulse_at(sim, e["pos"], dmg, radius)
var heal := float(cfg.get("heal", 0.0))
if heal > 0.0:
sim.player.hp = minf(sim.player.hp + heal, sim.player.max_hp) # logistics/carrier mend the player

NEW:

func drone_pulse(sim: Sim, d: DroneState) -> void:
var cfg: Dictionary = d.cfg
var dmg := DECOY_BASE_DAMAGE * d.owner.damage_mult * DECOY_POWER * d.owner.decoy_power_mult * float(cfg.get("dmg", 1.0))
var radius := DECOY_PULSE_RADIUS * float(cfg.get("radius", 1.0))
pulse_at(sim, d.pos, dmg, radius)
for e in d.extra:
pulse_at(sim, e["pos"], dmg, radius)
var heal := float(cfg.get("heal", 0.0))
if heal > 0.0:
d.owner.hp = minf(d.owner.hp + heal, d.owner.max_hp) # logistics/carrier mend ITS OWNER

nearest_drone_pos — OLD:

func nearest_drone_pos(sim: Sim, p: Vector2) -> Vector2:
var best := sim.player.pos
var bd := INF

NEW:

func nearest_drone_pos(sim: Sim, p: Vector2) -> Vector2:
var best := sim._nearest_pilot(p).pos # fallback (no drones out): nearest PILOT, not just P1
var bd := INF

Both of this function’s current callers (boss_warden.gd:147, enemy_behaviors.gd:427/441/493/556) already guard with if not sim.drones.is_empty() before calling it, so this fallback path is currently unreachable via any live call site — fix it anyway for correctness/consistency, since it’s a one-line change matching the established _nearest_pilot convention used everywhere else in this codebase for exactly this kind of P1-only fallback.

deploy_now — OLD:

func deploy_now(sim: Sim) -> void:
sim.drones.clear()
deploy_drones(sim, current_loadout(sim))
sim.drone_charge = 0.0

NEW:

func deploy_now(sim: Sim) -> void:
sim.drones.clear()
deploy_drones(sim, sim.player, current_loadout(sim))
sim.drone_charge = 0.0

(Stays P1-only per the Bay non-goal — only the new required pilot argument is added. sim.drone_charge = 0.0 still works correctly unchanged: it’s now the forwarding accessor from Step 3, which resolves to sim.player.drone_charge = 0.0.)

  • Step 5: Fix the one production call site in sim/sim.gd

sim/sim.gd:795 — OLD:

# NOTE: drone/decoy is still P1-only — `drones`/`drone_charge` are singular Sim fields, not
# per-pilot, and only P1's `input` is passed here. P2's decoy edge is computed correctly
# (M-A task 11's per-device edge machine) but has no effect. See CLAUDE.md M-A known gaps.
drone_director.update_drones(self, input, dt)
drone_director.damage_drones_from_enemies(self, dt) # drones stay destroyable even while paused

NEW:

# Still P1-only in THIS task — Task 2 replaces this with a real per-pilot loop. This step
# only keeps the project compiling against update_drones's new pilot param.
drone_director.update_drones(self, player, input, dt)
drone_director.damage_drones_from_enemies(self, dt) # drones stay destroyable even while paused
  • Step 6: Fix every test-file call site

Below is the exhaustive, grep-verified (2026-07-11) list. For EVERY line, insert <sim var>.player as the argument immediately after the sim argument. Two examples:

# BEFORE
sim.drone_director.deploy_drones(sim, [_spec()])
# AFTER
sim.drone_director.deploy_drones(sim, sim.player, [_spec()])
# BEFORE
sim.drone_director.update_drones(sim, InputState.new(Vector2.ZERO), Sim_Const.DT)
# AFTER
sim.drone_director.update_drones(sim, sim.player, InputState.new(Vector2.ZERO), Sim_Const.DT)

deploy_drones call sites (sim var shown in parentheses):

  • tests/test_drone_cfg_scales.gd — 23, 38, 51 (sim)
  • tests/test_drone_classes.gd — 17, 28, 63, 76, 89, 102, 116, 148, 156, 166 (sim)
  • tests/test_drone_sentinel_novel.gd — 18, 28, 38, 53 (sim)
  • tests/test_drone_hp.gd — 15, 24, 35, 45 (sim); 19 (sim2)
  • tests/test_drone_logistics_novel.gd — 20 (s); 33, 42 (sim); 52 (sim, multi-line call — the args span two lines: sim.drone_director.deploy_drones(sim, [_spec(2), then a continuation line {"klass": "sentinel", ...}]) — insert sim.player right after the first sim, on the first line, same as every other call)
  • tests/test_drone_disruptor_novel.gd — 20 (s); 32, 56, 67 (sim)
  • tests/test_drone_dock.gd — 19 (s)
  • tests/test_drone_bomber_novel.gd — 20, 39 (sim)
  • tests/test_drones_render.gd — 18, 26 (sim)
  • tests/test_drone_interceptor_novel.gd — 22, 37, 55 (sim)
  • tests/test_drones.gd — 45, 66, 85, 93, 103, 114, 120 (sim)
  • tests/test_boss.gd — 161 (sim)

update_drones call sites:

  • tests/test_drone_sentinel_novel.gd — 59 (sim)
  • tests/test_drones.gd — 17, 34, 37, 53, 57, 87, 88 (sim)
  • tests/test_decoy_types.gd — 13 (sim)

After editing, re-run this grep to confirm zero remaining old-signature calls:

Terminal window
grep -rn "drone_director\.\(deploy_drones\|update_drones\)(" --include="*.gd" main.gd tests sim marketing ui

Every remaining match must already show the .player argument you just added.

  • Step 7: Run the full test suite and confirm it’s unchanged

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit

Expected: the same Scripts count as before this task, 0 failing (a missing call-site fix shows up here as a GDScript parse error naming the file/line). Godot SIGABRTs (exit 134) at teardown even on a clean run — read the printed “Run Summary” block, not the exit code.

  • Step 8: Verify the determinism baseline is unchanged

Confirm the full suite run in Step 7 still passes tests/test_determinism_survival.gd’s pinned assertion (this task shouldn’t move the numbers — drones are already documented as never active in the raw baseline window).

  • Step 9: Commit
Terminal window
git add sim/drone_state.gd sim/player_state.gd sim/sim.gd sim/drone_director.gd tests/
git commit -m "refactor(drones): thread owner: PlayerState through the drone system
Mechanical, behaviour-preserving sweep — DroneState gains an owner
field, drone_charge moves to PlayerState (P1-only forwarding accessor
kept on Sim), and every drone_director.gd function that read
sim.player now reads d.owner or an explicit pilot param. Every
existing call site updated to pass sim.player, so single-player is
byte-identical. Lays the groundwork for P2's own drone deploy/recharge."

Task 2: Sim.tick() co-op wiring — per-pilot drone update + per-pilot damage recharge

Section titled “Task 2: Sim.tick() co-op wiring — per-pilot drone update + per-pilot damage recharge”

The real, player-visible feature: each pilot recharges and deploys their own drone independently, mirroring the existing weapon-firing loop’s _pilot_inputs pattern. The damage-driven recharge boost (fighting takes damage → extra charge) also becomes per-pilot instead of reading only P1’s hp_before/hp delta.

Files:

  • Modify: sim/sim.gd (the update_drones call site from Task 1’s Step 5; hp_before; the post-hit-check recharge block)
  • Test: new file tests/test_drone_coop.gd

Interfaces:

  • Consumes: Task 1’s DroneDirector.update_drones(sim, pilot, input, dt), DroneDirector.pilot_has_drone(sim, pilot); Sim._pilot_inputs: Array[InputState] (already exists, built once near the top of tick(), index-aligned with pilots).

  • Step 1: Write the failing tests

Create tests/test_drone_coop.gd:

extends GutTest
# Co-op drone/decoy: P2's decoy tap (already computing a real per-device edge via InputRouter)
# reaches the drone system independently of P1 (M-A co-op task, 2026-07-11). Companion to
# tests/test_drones.gd (single-player) and tests/test_coop.gd (general co-op mechanics).
func _sim() -> Sim:
return Sim.new(3, SimContentFixture.db())
func _spec() -> Dictionary:
return {"klass": "sentinel", "life": 1.0, "dmg": 1.0, "radius": 1.0, "heal": 0.0, "extras": 0}
func test_p2_charges_drone_independently_of_p1() -> void:
var sim := _sim()
sim.add_pilot()
sim.drone_director.deploy_drones(sim, sim.player, [_spec()]) # P1 already has a drone out
var c0 := sim.player2.drone_charge
for _i in range(60):
sim.drone_director.update_drones(sim, sim.player2, InputState.new(Vector2.ZERO), Sim_Const.DT)
assert_gt(sim.player2.drone_charge, c0,
"P2 still accrues their own charge even though P1's drone is already fielded")
func test_p2_decoy_tap_deploys_a_drone_owned_by_p2() -> void:
var sim := _sim()
sim.add_pilot()
sim.player2.drone_charge = 1.0
sim.drone_director.update_drones(sim, sim.player2, InputState.new(Vector2.ZERO, Vector2.ZERO, true), Sim_Const.DT)
assert_eq(sim.drones.size(), 1, "P2's tap deployed a drone")
assert_eq(sim.drones[0].owner, sim.player2, "the drone is owned by P2, not P1")
assert_almost_eq(sim.player2.drone_charge, 0.0, 0.001, "P2's own charge was consumed")
func test_p1_and_p2_can_both_have_drones_simultaneously() -> void:
var sim := _sim()
sim.add_pilot()
sim.drone_director.deploy_drones(sim, sim.player, [_spec()])
sim.drone_director.deploy_drones(sim, sim.player2, [_spec()])
assert_eq(sim.drones.size(), 2, "both pilots' drones are in the shared pool")
assert_eq(sim.drones[0].owner, sim.player)
assert_eq(sim.drones[1].owner, sim.player2)
func test_dead_pilot_drone_charge_stops_accruing() -> void:
var sim := _sim()
sim.add_pilot()
sim.player2.hp = 0.0
var c0 := sim.player2.drone_charge
for _i in range(60):
sim.tick_single(InputState.new(Vector2.ZERO), InputState.new(Vector2.ZERO))
assert_eq(sim.player2.drone_charge, c0, "a downed pilot's drone charge does not accrue")
func test_damage_taken_recharge_is_per_pilot() -> void:
var sim := _sim()
sim.add_pilot()
sim.player.pos = Vector2.ZERO
sim.player2.pos = Vector2(500, 500) # far from P1, won't take the same contact hit
sim.enemies.add(Vector2.ZERO, Vector2.ZERO, 14.0, 100.0, 0.0, 0.0, 20.0, 1.0) # sits on P1
var p1_c0 := sim.player.drone_charge
var p2_c0 := sim.player2.drone_charge
sim.tick_single(InputState.new(Vector2.ZERO), InputState.new(Vector2.ZERO))
assert_gt(sim.player.drone_charge - p1_c0, DroneDirector.DECOY_RECHARGE_RATE * Sim_Const.DT,
"P1 took damage -> extra charge beyond the time-based trickle")
assert_almost_eq(sim.player2.drone_charge, p2_c0 + DroneDirector.DECOY_RECHARGE_RATE * Sim_Const.DT, 0.001,
"P2 took no damage -> only the ordinary time-based trickle, unaffected by P1's hit")
  • Step 2: Run the tests to verify they fail (or pass for the right reason)

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_drone_coop -gexit

test_p2_charges_drone_independently_of_p1 and test_p2_decoy_tap_deploys_a_drone_owned_by_p2 should already PASS — they only exercise drone_director.update_drones(sim, pilot, ...) directly, already fully pilot-parameterized as of Task 1. test_dead_pilot_drone_charge_stops_accruing and test_damage_taken_recharge_is_per_pilot should FAIL — they call sim.tick_single, which still routes drone updates through Task 1’s Step 5 (P1-only update_drones call, and the unmodified Sim-level hp_before/recharge-on-damage block), so P2 never gets a tick-driven update at all.

  • Step 3: Implement the per-pilot drone update loop

sim/sim.gd — OLD (from Task 1’s Step 5):

# Still P1-only in THIS task — Task 2 replaces this with a real per-pilot loop. This step
# only keeps the project compiling against update_drones's new pilot param.
drone_director.update_drones(self, player, input, dt)
drone_director.damage_drones_from_enemies(self, dt) # drones stay destroyable even while paused

NEW:

# Every pilot recharges/deploys their own drone independently (M-A co-op task, 2026-07-11).
# Mirrors the weapon-firing loop above: index-aligned _pilot_inputs, dead pilots skipped.
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) # drones stay destroyable even while paused
  • Step 4: Make the damage-driven recharge boost per-pilot

sim/sim.gd — OLD:

var hp_before := player.hp # for the decoy's damage-driven recharge (end of tick)

NEW:

# Per-pilot damage-driven recharge (M-A co-op task, 2026-07-11) — index-aligned with `pilots`,
# same convention as _pilot_inputs.
var _hp_before_pilots: Array[float] = []
for pilot in pilots:
_hp_before_pilots.append(pilot.hp)

sim/sim.gd — OLD:

# Decoy recharges faster the more damage the player took this tick.
var dmg_taken := hp_before - player.hp
if dmg_taken > 0.0 and drones.is_empty():
drone_charge = minf(drone_charge + dmg_taken * DECOY_RECHARGE_ON_DMG, 1.0)

NEW:

# Decoy recharges faster the more damage EACH pilot took this tick — independent per pilot,
# gated on that pilot not already having a drone of their own out (not on the shared pool
# being globally empty, which would block P2's recharge purely because P1 has a drone out).
for pi in range(pilots.size()):
var pilot: PlayerState = pilots[pi]
var dmg_taken: float = (_hp_before_pilots[pi] if pi < _hp_before_pilots.size() else pilot.hp) - pilot.hp
if dmg_taken > 0.0 and not drone_director.pilot_has_drone(self, pilot):
pilot.drone_charge = minf(pilot.drone_charge + dmg_taken * DECOY_RECHARGE_ON_DMG, 1.0)
  • Step 5: Run the tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_drone_coop -gexit Expected: PASS, all 5 tests.

  • Step 6: Run the full suite + determinism check

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit

Expected: same Scripts count as Task 1’s Step 7 plus this task’s new file, 0 failing, determinism baseline still passes. Single-player collapses to exactly today’s behavior: pilots.size() == 1 means the new loops in Steps 3-4 each run their body exactly once, for player, with _pilot_inputs[0] == input — identical to Task 1’s Step 5 single call.

  • Step 7: Commit
Terminal window
git add sim/sim.gd tests/test_drone_coop.gd
git commit -m "feat(coop): P2 recharges and deploys their own drone independently
Sim.tick() now loops every pilot for drone recharge/deploy (mirroring
the existing weapon-firing loop's _pilot_inputs pattern) and tracks
each pilot's own damage-taken recharge boost. Closes the last item in
the co-op sweep backlog — P2's decoy input edge (already computed
correctly) now actually reaches the drone system."

Task 3: Remove the decoy-proximity damage-synergy mechanic

Section titled “Task 3: Remove the decoy-proximity damage-synergy mechanic”

Deletes Sim._decoy_synergy and its consts/computation entirely (Chris’s explicit call, 2026-07-11) — a reactions-mode-era mechanic no longer needed now that a drone’s own attacks/abilities are sufficient value regardless of which pilot stands near it.

Files:

  • Modify: sim/sim.gd (remove 2 consts, 1 field, 1 computation block)
  • Modify: sim/elemental_system.gd (remove the multiplier from damage_enemy)
  • Modify: tests/test_drones.gd (delete the 2 test functions that test the removed mechanic)
  • Modify: docs/architecture/meta-and-story.md (update the stale “Decoy overhaul” bullet)

Interfaces: None — pure removal, no new interfaces produced or consumed.

  • Step 1: Remove the consts

sim/sim.gd — OLD:

const DECOY_RECHARGE_ON_DMG: float = 0.012 # extra charge per HP of damage taken
const DECOY_SYNERGY_RADIUS: float = 240.0 # fight THIS close to your decoy for a damage bonus
const DECOY_SYNERGY_BONUS: float = 1.0 # up to +100% damage when right on top of it (working closely)
var decoy_type: String = "basic" # set by main from the shop selection (meta.selected_decoy)

NEW:

const DECOY_RECHARGE_ON_DMG: float = 0.012 # extra charge per HP of damage taken
var decoy_type: String = "basic" # set by main from the shop selection (meta.selected_decoy)
  • Step 2: Remove the field

sim/sim.gd — OLD:

var _dmgnum_count: int = 0 # per-tick budget for floating damage numbers (reset each tick)
var _decoy_synergy: float = 1.0 # damage multiplier from fighting close to your active decoy
var mods: ModState

NEW:

var _dmgnum_count: int = 0 # per-tick budget for floating damage numbers (reset each tick)
var mods: ModState
  • Step 3: Remove the computation block

sim/sim.gd — OLD:

# Decoy synergy: the closer you fight to your active decoy, the more damage you do
# (up to +100% on top of it). 1.0 when no decoy -> determinism-safe (never active in baseline).
_decoy_synergy = 1.0
if not drones.is_empty():
var dd := player.pos.distance_to(drone_director.nearest_drone_pos(self, player.pos))
if dd < DECOY_SYNERGY_RADIUS:
_decoy_synergy = 1.0 + DECOY_SYNERGY_BONUS * (1.0 - dd / DECOY_SYNERGY_RADIUS)
if story != null:

NEW:

if story != null:
  • Step 4: Remove the multiplier from damage_enemy

⚠️ Check this exact line against the live file before editing — a concurrent commit (dev-tools work, landed 2026-07-11 after this plan was written) may have appended a further multiplier to the same line, e.g. * sim.dev_damage_mult. If so, preserve every OTHER multiplier in the chain exactly as found and remove ONLY the sim._decoy_synergy factor — do not revert the line to the plan’s literal OLD text if the live file has evolved since. As of the last check (2026-07-11, post-merge with origin/main), the live line reads:

sim/elemental_system.gd — OLD (confirmed current, includes the dev-tools cheat multiplier):

var dealt := effective * vuln_mult(sim, ei) * sim._decoy_synergy * weaken_mult(sim, ei) * mark_mult(sim, ei) * sim.dev_damage_mult

NEW:

var dealt := effective * vuln_mult(sim, ei) * weaken_mult(sim, ei) * mark_mult(sim, ei) * sim.dev_damage_mult
  • Step 5: Delete the 2 tests covering the removed mechanic

tests/test_drones.gd — delete test_synergy_scales_with_closeness and test_synergy_increases_damage_dealt in their entirety (currently lines 63-81, but re-locate by function name rather than trusting the line numbers, since earlier tasks may have shifted them):

func test_synergy_scales_with_closeness() -> void:
var sim := _sim()
sim.player.pos = Vector2.ZERO
sim.drone_director.deploy_drones(sim, sim.player, [_spec()])
sim.drones[0].life = 10.0
sim.drones[0].pos = Vector2.ZERO # right on the player -> max synergy
sim.tick_single(InputState.new(Vector2.ZERO))
assert_gt(sim._decoy_synergy, 1.5, "fighting on top of a drone gives a big damage bonus")
sim.drones[0].pos = Vector2(2000, 0) # far away -> no synergy
sim.tick_single(InputState.new(Vector2.ZERO))
assert_almost_eq(sim._decoy_synergy, 1.0, 0.05, "drone far away -> no bonus")
func test_synergy_increases_damage_dealt() -> void:
var sim := _sim()
sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 1000.0, 0.0, 0.0, 5.0, 1.0)
sim._decoy_synergy = 2.0
var hp0 := sim.enemies.data[0]
sim.elemental_system.damage_enemy(sim, 0, 10.0)
assert_almost_eq(hp0 - sim.enemies.data[0], 20.0, 0.5, "synergy doubles the damage dealt (big numbers)")

Remove both functions (and the blank line between them) entirely — do not replace with anything. Note: these two functions already contain a sim.player/deploy_drones call in the OLD form shown above with the Task-1 fix already applied (sim.player inserted) — by the time Task 3 runs, that line will already read sim.drone_director.deploy_drones(sim, sim.player, [_spec()]) per Task 1’s Step 6 fix; delete the whole function regardless of that intermediate state.

  • Step 6: Update the stale architecture doc

docs/architecture/meta-and-story.md — OLD (one bullet, currently mid-file — locate by content, not line number):

- **Decoy overhaul:** flies an organic wander-seek (not an orbit); **synergy** `Sim._decoy_synergy` (in `_damage_enemy`: fight within `DECOY_SYNERGY_RADIUS` of your decoy → up to +100% ALL damage; 1.0 when no decoy so baseline-safe); boss missiles + walk-mobs chase the NEAREST decoy. **Selectable decoy TYPES** (`Sim.DECOY_TYPES` + `Sim.decoy_type`): basic / damage(Striker) / tank(Bulwark) / healer(Mender, heals you per pulse) / ultimate(Swarm — spawns companion decoys in `DecoyState.extra`, all pull aggro + pulse). Decoy stat upgrades go through `StatEffects` (`decoy_power`/`decoy_life` → `player.decoy_*_mult`). Chosen in the shop: `MetaState.selected_decoy` + `owns_decoy()`; tapping an OWNED `decoy:<type>` unlock card EQUIPS it. HUD decoy bar crackles + "DECOY READY" flash when full.

NEW:

- **Decoy overhaul:** flies an organic wander-seek (not an orbit); boss missiles + walk-mobs chase the NEAREST decoy. (The proximity damage-synergy bonus this section used to describe was removed 2026-07-11 — a reactions-mode-era mechanic no longer needed once drones carry their own value; see `docs/superpowers/specs/2026-07-11-p2-drone-decoy-design.md`.) **Selectable decoy TYPES** (`Sim.DECOY_TYPES` + `Sim.decoy_type`): basic / damage(Striker) / tank(Bulwark) / healer(Mender, heals you per pulse) / ultimate(Swarm — spawns companion decoys in `DecoyState.extra`, all pull aggro + pulse). Decoy stat upgrades go through `StatEffects` (`decoy_power`/`decoy_life` → `player.decoy_*_mult`). Chosen in the shop: `MetaState.selected_decoy` + `owns_decoy()`; tapping an OWNED `decoy:<type>` unlock card EQUIPS it. HUD decoy bar crackles + "DECOY READY" flash when full.
  • Step 7: Run the full suite + determinism check

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit

Expected: Scripts count is 2 LOWER than Task 2’s Step 6 only in terms of test FUNCTIONS removed (same script/file count — test_drones.gd still exists, just with 2 fewer functions in it), 0 failing, determinism baseline still passes (this mechanic was already documented as inert in the baseline window — “1.0 when no decoy so baseline-safe” — so removing it changes nothing there).

  • Step 8: Commit
Terminal window
git add sim/sim.gd sim/elemental_system.gd tests/test_drones.gd docs/architecture/meta-and-story.md
git commit -m "refactor(sim): remove the decoy-proximity damage-synergy mechanic
Sim._decoy_synergy (fight near your decoy -> +damage) is a
reactions-mode-era mechanic that's no longer needed now that drones
carry their own value regardless of pilot proximity — and it can't be
made correctly per-pilot without threading attacker identity through
the entire anonymous damage_enemy() pipeline. Chris's call."

Self-Review Notes (for whoever executes this plan)

Section titled “Self-Review Notes (for whoever executes this plan)”
  • Spec coverage: Task 1 covers spec Section 1 (data model). Task 2 covers Section 2 (tick threading) plus the damage-recharge half of Section 3 that the spec’s Non-goals didn’t explicitly call out but is required for correctness (found during planning: the old hp_before/recharge-on-damage block was Sim-level/P1-only, same class of gap as the rest of this system). Task 3 covers Section 4 (decoy-synergy removal) in full, including the architecture-doc update the spec didn’t explicitly list as a file to touch but which the project’s own convention requires (“keep docs current when you change the code”).
  • Determinism: every task ends with a full-suite run including the pinned determinism test. Drones (and the removed synergy mechanic) are documented as inert within the raw 600-tick single-player baseline window, so no re-pin is expected — but the plan re-verifies every time rather than assuming this.
  • Task boundaries: Task 1 cannot be split further — drone_director.gd’s changed functions call each other internally (update_dronesdeploy_drones, drone_sentineldecoy_step), so a partially-migrated file would not compile. Task 3 (synergy removal) is fully independent of Tasks 1-2 and could theoretically run first, but is sequenced last since it was discovered during this plan’s own research rather than being the spec’s primary subject.