XP Orb Mechanics

XP orbs are the columnar, typed-array-backed pickups that carry experience from kills and prop destruction to the player. They live in engine/world/xp-orbs.ts under a structure-of-arrays layout (1024-orb capacity, swap-with-last removal, zero allocation at spawn/remove). All per-frame logic — spawning, merging, magnet pull, collection, rendering, offscreen culling — is contained in this module; callers never see orb objects.

Spawn sources

spawn(x, y, vx, vy, amount, radius=4) is called on:

  • Enemy death — projectile and contact kills emit one or more orbs whose XP value scales with the enemy’s tier.
  • Crate breaks — destructible crates pop an orb cluster on shatter.
  • Prop breaks — destructible world props drop smaller orbs when destroyed.

Each spawn writes into the next free columnar slot and sets flags = 1 (active, not collected). If the 1024-orb pool is full, spawn() silently no-ops — the merge passes below guarantee this never happens under normal play.

Magnet attraction (three layers)

The ship’s ship.magnetRange (default 100 world units, modified by build) drives a three-layer pull model evaluated every frame:

  1. Layer 1 — snap-on (dist < 20). Inside 20 px the orb is collected immediately, no acceleration ramp.
  2. Layer 2 — base pull (dsq < magnetRange²). Pull speed = CFG.MAGNET_SPEED * 0.4 * (0.1 + pullPct² * 0.9) where pullPct = 1 - dist/magnetRange. Quadratic ramp from edge to center. Each frame’s contribution accumulates in _pullAccum; once it crosses 4, the orb flips into layer 3.
  3. Layer 3 — exponential lock-on (_flags[i] & 2). Speed = 200 * 3^_magTime (seconds since flip). Velocity is zeroed — momentum no longer matters, the orb tracks the ship rigidly. Movement factor capped at 0.95 per frame so the orb can’t tunnel past the ship.

Direct contact (dsq < (shipR + orbR)²) collects regardless of layer.

triggerGlobalMagnet() flips every active orb straight to layer 3 — used by magnet pickups and boss death events to vacuum the field instantly.

Pickup, XP grant, level-up

On collect, _collectSlot():

  • Multiplies _amount[i] by ship.xpGainMult (rounded) and adds to game.xp.
  • Pushes the granted XP into XpAccum for the floating-text accumulator.
  • Fires Juice.fire('xp_pickup') for screen-shake/flash.
  • Spawns the collect VFX burst: 8 lime-green core sparks (#64E678, 55–105 u/s, 0.10–0.18 s life) plus 2 hot-white bloom sparks (#C8FFE0, size 3.0, 0.06 s). Colors match the orb sprite’s atlas glow.
  • Removes the slot via swap-with-last.

Level-up triggers when the running game.xp crosses the next XP_THRESHOLDS[lvl] entry (engine/world/leveling.ts). Crossing happens on the same frame the orb is consumed; the upgrade overlay queues from the level-up handler downstream.

Visual orb count vs aggregate batch

To keep the screen readable when a wave dies all at once, two throttled merge passes collapse orbs without losing XP value:

  • Nearby merge (20 Hz, _mergeNearby) — orbs within 70 px of each other (after the 0.08 s spawn cooldown) collapse pairwise. Survivor absorbs the absorbed orb’s amount, grows its visual radius up to 14 px, and gets a small outward velocity nudge so merged clusters spread.
  • Offscreen + soft-cap merge (20 Hz, _mergeOffscreen) — anything more than 200 px outside the camera viewport gets absorbed into its globally-nearest neighbor that fits under the per-orb XP cap. On-screen orbs beyond a soft cap of 40 collapse the smallest excess into the largest sub-cap absorber.

Per-orb XP cap (_xpCapPerOrb): max(50, (XP_THRESHOLDS[lvl+1] - XP_THRESHOLDS[lvl]) * 0.75). No single orb may carry more than 75 % of the current level cost, so one pickup can never grant more than one level. This is recomputed every merge tick so end-game orbs naturally carry more XP than early-game orbs.

The result: the player sees a small, readable visual count of orbs, but each one represents an aggregate batch of XP from many kills. Total XP into the player matches total XP from kills exactly — merging is a presentation-layer compression, not a balance lever.

Render path

draw() issues a single instanced GL call via getSpriteBatch() using the xp_orb atlas region. Per-orb size scales with stored amount: 9 * min(2.0, 1.0 + log2(max(1, amt)) * 0.16) — base 9 world units (~1/3 ship size), capped at ~18 world units for mega-orbs. The fallback (no WebGL2) silently skips orb rendering — the game remains mechanically identical, just invisible orbs.

drawTutorialGlow() overlays a golden 2 px ring around every orb during tutorial step 1, alpha-phased by the caller.

Key facts

  • Pool capacity: 1024 orbs, columnar typed arrays, zero per-spawn allocation.
  • Spawn sources: enemy death, crate breaks, prop breaks (spawn(x, y, vx, vy, amount, radius)).
  • Magnet radius: ship.magnetRange (default 100), three pull layers — snap-on at 20 px, quadratic base pull inside magnetRange, exponential lock-on after _pullAccum ≥ 4.
  • Level-up triggers when game.xp crosses the next XP_THRESHOLDS entry.
  • Per-orb XP cap: 75 % of current level cost (floor 50). Prevents single-pickup multi-level skips.
  • Merge passes (20 Hz nearby + 20 Hz offscreen/soft-cap) compress visual orb count without losing total XP value.
  • Visual orb count ≠ kill count; each on-screen orb is an aggregate batch of merged XP from one or many kills.
  • Collect VFX: 8 lime-green core sparks + 2 hot-white bloom sparks, all from the particle pool.
  • Render: single instanced GL batch via xp_orb atlas region; size scales logarithmically with carried amount.