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:
- Layer 1 — snap-on (
dist < 20). Inside 20 px the orb is collected immediately, no acceleration ramp. - Layer 2 — base pull (
dsq < magnetRange²). Pull speed =CFG.MAGNET_SPEED * 0.4 * (0.1 + pullPct² * 0.9)wherepullPct = 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. - 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]byship.xpGainMult(rounded) and adds togame.xp. - Pushes the granted XP into
XpAccumfor 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’samount, 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 insidemagnetRange, exponential lock-on after_pullAccum ≥ 4. - Level-up triggers when
game.xpcrosses the nextXP_THRESHOLDSentry. - 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_orbatlas region; size scales logarithmically with carried amount.