Weapon chest pickup flow

Collecting a weapon chest opens the reward UI. The branch the UI takes depends on the player’s slot state at the moment the chest is collected — not at the moment the reward dispatches — so picking up two chests back-to-back can’t desync.

Collect → queue

The chest is a weaponBoxes entity in the world. Contact is detected by the mandala collision ring (MANDALA_COLLECT_R = 50 world px, the golden ring around the chest sprite is the pickup zone). On contact:

  1. The chest enters a fly-to-center animation (_collecting, _collectDur = 0.48s, ease-in-cubic toward the camera center).
  2. Time freezes immediately (game._weaponChestFreeze = true, game.timeDilation = 0). The chest and particles keep animating because they’re driven by rawDt, not dt.
  3. Star confetti + golden spark bursts fire at the chest position.
  4. When the animation completes (t >= 1), the chest is removed from the world and a weapon_box reward is pushed to game.rewardQueue with a _slotsFullAtCollect snapshot:
    const _slotsFullSnap = ship.weapons.length >= (game.weaponSlotsMax || 4);
    game.rewardQueue.push({ type: 'weapon_box', _slotsFullAtCollect: _slotsFullSnap });
    

The snapshot exists because the reward dispatch’s live weapons.length check was racing the chest fly-in on the first-chest-after-full case. The dispatch prefers the snapshot when present and falls back to a live recheck only for legacy queue entries that lack it.

Dispatch branches

When the reward dispatch consumes the weapon_box entry, it reads _slotsFullAtCollect and forks:

Slots free + unowned weapons exist → 3-card offer. LevelingSystem.generateWeaponChoices(game, ship, 3) rolls up to 3 unowned weapons (respecting runDef.context.weaponPool if set). The level-up card screen opens. Pick one → it’s added to a free slot.

Slots full → auto-upgrade every equipped weapon. No card screen. Instead:

  1. prepareUpgradeShow({ type: 'shooting_star', shootingStarCategory: 'weapons' }, ship, game) snapshots each weapon’s current level for the animation.
  2. Every entry in ship.weapons gets level = min(20, level + 1).
  3. game._rewardFamily = 'weapon_chest_auto', _weaponChestFreeze stays true.
  4. startUpgradeShowOnly('weapon_chest_auto') plays the pure fly-to-slot animation — each weapon icon flies to its HUD slot showing old→new level. The weapon_chest_auto family is intentionally unregistered as a cinematic so it gets the fly-to-slot motion without the showroom blackout the regular weapon_box cinematic paints.

Slots free but every weapon already owned → same auto-upgrade fallback. If generateWeaponChoices returns [] (the pool is exhausted), the dispatch falls through to the same weapon_chest_auto path so the chest still resolves visibly instead of evaporating.

No reward possible → unfreeze defensively. If every modifier and every weapon is at max level and the dispatch produces no choices and no auto-upgrade, the _weaponChestFreeze flag is cleared and timeDilation is restored to 1 so the game doesn’t soft-lock.

Why the snapshot matters

The fly-to-center animation takes 0.48s. During that window the player can’t move (time is frozen), but the weapons.length value the dispatch eventually reads is decoupled from the chest’s collect moment by a queue. If a second chest were collected during the same frame the first chest’s reward dispatched, the live recheck could see a stale weapons.length and offer a 3-card screen when the player already had a full loadout. The collect-time snapshot pins the branch decision to the moment the player actually touched the chest.

Spawn cap context

Weapon chests are capped at 2 per level (_wcSpawnedThisLevel, resets on tier advance). First chest spawns within 60s of level start, second between 2:00–3:30. Chests that drift more than 800px from the player are hidden and re-popped from the next enemy death (_wcPendingChest) so the player always finds them. Enemy drop chance is 0.25%.

  • gameplay/concepts/shooting-star-system.md — the fly-to-slot animation family the auto path borrows.
  • gameplay/concepts/crate-drop-system.md — sibling pickup type (artifact boxes use the same 50px mandala radius and _collecting pattern).
  • LevelingSystem.generateWeaponChoices in engine/world/leveling.ts — pool roll for the 3-card offer.
  • game.rewardQueue dispatch in engine/bridge.ts — branching on _slotsFullAtCollect.