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:
- The chest enters a fly-to-center animation (
_collecting,_collectDur = 0.48s, ease-in-cubic toward the camera center). - Time freezes immediately (
game._weaponChestFreeze = true,game.timeDilation = 0). The chest and particles keep animating because they’re driven byrawDt, notdt. - Star confetti + golden spark bursts fire at the chest position.
- When the animation completes (
t >= 1), the chest is removed from the world and aweapon_boxreward is pushed togame.rewardQueuewith a_slotsFullAtCollectsnapshot: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:
prepareUpgradeShow({ type: 'shooting_star', shootingStarCategory: 'weapons' }, ship, game)snapshots each weapon’s current level for the animation.- Every entry in
ship.weaponsgetslevel = min(20, level + 1). game._rewardFamily = 'weapon_chest_auto',_weaponChestFreezestays true.startUpgradeShowOnly('weapon_chest_auto')plays the pure fly-to-slot animation — each weapon icon flies to its HUD slot showing old→new level. Theweapon_chest_autofamily is intentionally unregistered as a cinematic so it gets the fly-to-slot motion without the showroom blackout the regularweapon_boxcinematic 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%.
Related
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_collectingpattern).LevelingSystem.generateWeaponChoicesinengine/world/leveling.ts— pool roll for the 3-card offer.game.rewardQueuedispatch inengine/bridge.ts— branching on_slotsFullAtCollect.