Skip to content

P2 Upgrade Pipeline — Design

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

Local co-op (main.V01_LOCK_COOP=false) lets a second pilot (P2) play, and P2 now correctly banks shared party XP into their own pending_levelups counter (fixed 2026-07-09, commit 104b24c). But nothing past that point is P2-aware: main.gd’s level-up trigger only checks sim.pending_levelups (a P1-only forwarding accessor onto player.pending_levelups), and the entire sim/upgrade_system.gd roll/apply/preview pipeline (694 lines, ~29 functions) reads and writes exclusively through P1-only Sim forwarding accessors (sim.player, sim.active_weapon_ids, sim._weapon_by_id, sim.blade_element_idx, etc.) rather than any specific pilot. P2 accumulates pending level-ups that are never presented and never applied — P2’s build never grows.

This spec covers threading the upgrade pipeline so P2 can level up, see their own upgrade choices, and apply them independently, matching how weapon update() calls and damage/hit-checks already take an explicit pilot in prior co-op sweep work (see CLAUDE.md’s Sim.pilots architecture note and the completed co-op sweep tasks for ship bonuses / hit-checks / render telegraphs).

  • No changes to XP banking itself (already correct, shared party credit to every alive pilot).
  • No cross-pilot economy coupling — weapon ownership, thresholds, and mod state are already fully independent per PilotArsenal (each pilot owns their own). max_weapon_slots stays a single Sim-level constant shared by both pilots (both already share one hull, fixed 2026-07-09).
  • No networked/M-C multiplayer work — this is local co-op (same machine, same pause) only.
  • No visual/UX changes beyond what’s specified below (no new upgrade card types, no rebalancing).

1. sim/upgrade_system.gd — explicit pilot parameter throughout

Section titled “1. sim/upgrade_system.gd — explicit pilot parameter throughout”

Every function in the file gains an explicit pilot: PlayerState parameter (alongside the existing sim: Sim first param), and every internal read/write that currently goes through a P1-only Sim forwarding accessor is changed to read/write the passed-in pilot directly:

  • sim.playerpilot
  • sim.active_weapon_idspilot.arsenal.active_weapon_ids
  • sim._weapon_by_idpilot.arsenal.weapon_by_id
  • sim.blade_element_idx (and the other *_element_idx weapon fields) → pilot.arsenal.blade_element_idx etc.
  • sim.locked_weapons, sim._weapon_levels, sim._thresholds_donepilot.arsenal.locked_weapons / .weapon_levels / .thresholds_done
  • sim.pending_levelupspilot.pending_levelups

This is a mechanical but total sweep — every function in the file is reachable from the level-up flow (confirmed during the 2026-07-09 co-op sweep pass), so there is no smaller isolable subset. Function signatures change (e.g. roll_upgrade_choices(sim, n)roll_upgrade_choices(sim, pilot, n), apply_upgrade(sim, id)apply_upgrade(sim, pilot, id)), so every call site across the repo (main.gd, tests, ui/level_up_panel.gd, and any tool/marketing script that calls into upgrade_system.*) must be updated to pass the pilot explicitly. Per the lesson from the reactions-mode rename ([[bullet-heaven-reactions-archive-rename]]), the implementation plan must mandate a whole-repo grep for every renamed/changed signature as a research step, not a call-site list built from files already known to be relevant.

max_weapon_slots and other genuinely Sim-level (not per-pilot) fields are left untouched.

2. main.gd — sequential level-up trigger across pilots

Section titled “2. main.gd — sequential level-up trigger across pilots”

Replace the single if sim.pending_levelups > 0 check with a scan over sim.pilots (in order, P1 before P2) for the first pilot with pilot.pending_levelups > 0. Track which pilot currently owns the open panel in a new _leveling_pilot: PlayerState field.

  • _open_levelup() rolls choices and shows the panel for _leveling_pilot: sim.upgrade_system.roll_upgrade_choices(sim, _leveling_pilot, 3), then level_up.show_for(sim, _leveling_pilot, ids).
  • _on_upgrade_chosen(id) applies against the same pilot: sim.upgrade_system.apply_upgrade(sim, _leveling_pilot, id).
  • After applying, if _leveling_pilot.pending_levelups > 0 still, re-open for the same pilot. Otherwise re-scan all pilots for the next one needing a panel (chains straight to P2 if P1 just finished and P2 also has pending levels — the common case, since XP is shared and both pilots level in lockstep unless one has died).
  • The whole sim stays paused (_paused_for_levelup) for the full chain — this is what “the other pilot waits” means in practice: gameplay doesn’t resume between P1’s and P2’s panels.
  • Single-player (pilots.size() == 1) collapses to exactly today’s behaviour — _leveling_pilot is always player, byte-identical to the current single-pilot path.

3. ui/level_up_panel.gd — device gating + pilot label

Section titled “3. ui/level_up_panel.gd — device gating + pilot label”

show_for(sim, ids) becomes show_for(sim, pilot, ids).

  • Device gating: _input(event) currently accepts input from any connected device. It must filter to only the device bound to pilot in co-op (using the same per-device pattern InputRouter.device_edges/poll_device already established for co-op input elsewhere) — so P1’s controller can’t drive P2’s panel and vice versa. In single-player this is a no-op (only one device is ever bound).
  • Pilot label: in co-op (sim.pilots.size() > 1), the panel header shows a small “PLAYER 1 LEVEL UP” / “PLAYER 2 LEVEL UP” label with a P1/P2 accent tint. In single-player, the label is suppressed entirely (panel looks exactly as it does today — no redundant “PLAYER 1” text).
  • Existing single-player upgrade tests (test_upgrade_effects.gd, test_buildcraft.gd, test_evolutions.gd, etc.) must be updated to pass sim.player explicitly wherever they call into upgrade_system.* directly, and must still pass unchanged (single-player behaviour is byte-identical).
  • New co-op-specific tests needed: P2 accumulates pending_levelups, gets offered choices scoped to P2’s own arsenal, applying a P2 choice mutates only player2.arsenal (not player.arsenal), and the chained-panel sequencing (P1 then P2 on a simultaneous level-up) resolves correctly with the sim remaining paused throughout.
  • Determinism: level-up choices are drawn from sim.upgrade_rng (not sim.rng), so this refactor should not touch the spawn-stream determinism baseline. Re-verify tests/test_determinism_survival.gd after the refactor as a matter of course, not because a change is expected.