P2 Upgrade Pipeline — Design
P2 Upgrade Pipeline — Design
Section titled “P2 Upgrade Pipeline — Design”Date: 2026-07-10 Status: Approved by Chris, ready for implementation plan.
Problem
Section titled “Problem”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).
Non-goals
Section titled “Non-goals”- 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_slotsstays 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).
Design
Section titled “Design”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.player→pilotsim.active_weapon_ids→pilot.arsenal.active_weapon_idssim._weapon_by_id→pilot.arsenal.weapon_by_idsim.blade_element_idx(and the other*_element_idxweapon fields) →pilot.arsenal.blade_element_idxetc.sim.locked_weapons,sim._weapon_levels,sim._thresholds_done→pilot.arsenal.locked_weapons/.weapon_levels/.thresholds_donesim.pending_levelups→pilot.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), thenlevel_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 > 0still, 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_pilotis alwaysplayer, 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 topilotin co-op (using the same per-device patternInputRouter.device_edges/poll_devicealready 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).
Testing
Section titled “Testing”- Existing single-player upgrade tests (
test_upgrade_effects.gd,test_buildcraft.gd,test_evolutions.gd, etc.) must be updated to passsim.playerexplicitly wherever they call intoupgrade_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 onlyplayer2.arsenal(notplayer.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(notsim.rng), so this refactor should not touch the spawn-stream determinism baseline. Re-verifytests/test_determinism_survival.gdafter the refactor as a matter of course, not because a change is expected.