xp-orbs.ts

PURPOSE

Columnar typed-array storage and per-frame simulation for every XP orb in the world. Replaces the old world.xp: any[] with structure-of-arrays Float32/Int32/Uint8 columns sized for 1024 orbs (10x the legacy MAX_XP_ORBS). All per-frame logic — magnet pull (three layers), direct-contact collection, nearby/offscreen merge passes, on-screen soft cap enforcement, instanced sprite-batch draw, tutorial glow, and offscreen cull — lives in this module, so call sites never see orb objects. Designed for 60fps with 500+ active orbs and zero allocation at spawn/remove.

OWNS

  • MAX_ORBS = 1024 — pool capacity. spawn silently no-ops when full.
  • Columnar typed-array pools — _x, _y, _vx, _vy (Float32), _amount (Int32), _radius, _spawnAge, _pullAccum, _magTime, _life (Float32), _flags (Uint8, bit 0 = active, bit 1 = _collected exponential lock-on phase). Active orbs occupy indices [0, _count); _removeAt swap-with-last.
  • _count — number of currently live orbs.
  • _mergeAccum, _offscreenMergeAccum — per-pass throttle accumulators.
  • Merge tunables — XP_MERGE_RADIUS_PX = 70 (squared into XP_MERGE_RADIUS_SQ), MERGE_INTERVAL_S = 0.05 (20 Hz nearby pass), OFFSCREEN_MERGE_INTERVAL_S = 0.05 (20 Hz offscreen pass), OFFSCREEN_MARGIN_PX = 200 (anything past viewport+margin gets collapsed), MERGE_SPAWN_COOLDOWN_S = 0.08 (orbs are immune from merge for the first 80 ms after spawn), ON_SCREEN_SOFT_CAP = 40 (hard on-screen cap before excess gets force-absorbed), DRAG_PER_60FPS = 0.92 (velocity decay per 60fps frame, dt-corrected via Math.pow).
  • Collect VFX constants — XP_CORE_COUNT = 8 lime-green spark particles (100, 230, 120) with speed range 55..105 u/s, life 0.10..0.18 s, size 1.0..2.2, alpha 0.85; XP_BLOOM_COUNT = 2 near-white mint particles (200, 255, 220) with speed 30..60 u/s, life 0.06 s, size 3.0, alpha 0.6.
  • _xpCapPerOrb(game) — per-merge per-orb XP cap = max(50, (XP_THRESHOLDS[level+1] − XP_THRESHOLDS[level]) * 0.75). Prevents a single merged orb from carrying more than 75% of the player’s current level cost so one pickup can never grant more than one level. Recomputed per merge pass so end-game orbs naturally carry more XP than early-game.
  • _mergeOffscreen(game) — every orb more than OFFSCREEN_MARGIN_PX outside the viewport gets absorbed into its globally nearest neighbor whose combined XP stays under the cap; orbs with no eligible absorber stay put until the cap rises. After offscreen sweep, if onScreenCount > ON_SCREEN_SOFT_CAP it picks the largest sub-cap on-screen orb as absorber and collapses the smallest excess on-screen orbs into it (still cap-respecting, still skipping anything younger than MERGE_SPAWN_COOLDOWN_S). Absorber radius grows up to 18 in +0.4 steps.
  • _mergeNearby(game) — pairwise sweep absorbing orb i into older j when both are past spawn cooldown, within XP_MERGE_RADIUS_PX, and the combined amount stays under the cap. Survivor radius grows up to 14 in +0.8 steps, then gets a small outward velocity nudge so merged clusters spread.
  • _collectSlot(i, ship, game, xpMult) — applies Math.round(_amount[i] * xpMult) to game.xp, calls XpAccum.collect, fires the xp_pickup juice and the lime-green burst (XP_CORE_* sparks + XP_BLOOM_* flash), then _removeAt(i).
  • _swapIntoSlot(dst, src) / _removeAt(i) — internal slot helpers; _removeAt swap-with-last and clears the freed flag.

READS FROM

  • ../core/typesShipState, GameState shapes.
  • ../core/configCFG.MAGNET_SPEED (xp uses CFG.MAGNET_SPEED * 0.4 as baseSpeed).
  • ../core/stateW, H, camera (camera position and camera.zoom define the on-screen rectangle used by the offscreen merge pass).
  • ../vfx/particlesParticles.add for collect-burst sparks; XpAccum.collect(amount) for the cumulative XP popup overlay.
  • ../vfx/juiceJuice.fire('xp_pickup') on every collect.
  • ../rendering/cameraCamera.toS for the tutorial glow’s world-to-screen projection.
  • ../rendering/sprite-batchgetSpriteBatch() for the WebGL instanced draw path.
  • ../rendering/atlas-buildertryGetAtlasRegion('xp_orb') for the batched sprite region.
  • ./levelingXP_THRESHOLDS for the per-merge XP cap.
  • ship.magnetRange (default 100), ship.radius, ship.xpGainMult (default 1.0), ship.x, ship.y from ShipState. game.level, game.xp, game.time from GameState.

PUSHES TO

  • game.xp_collectSlot increments by round(_amount[i] * ship.xpGainMult).
  • XpAccum.collect(amount) — fires once per collected orb so the popup overlay reflects the same XP delta that hit game.xp.
  • Juice.fire('xp_pickup') — fires once per collected orb.
  • Particles.add — 8 lime-green core sparks + 2 near-white bloom flashes per collect, all from the particle pool with zero allocation.
  • SpriteBatch.add — one quad per active orb per frame, with worldD = 9 * sizeScale where sizeScale = min(2.0, 1 + log2(max(1, amount)) * 0.16). Base disk is 9 world units (≈ 1/3 ship size); big merged orbs scale up to ≈ 18 world units.
  • Mutates own pools — _x, _y, _vx, _vy, _amount, _radius, _spawnAge, _pullAccum, _magTime, _life, _flags, _count.

DOES NOT

  • Compute XP-per-kill or apply XP_NERF_MULT. The flat global nerf (XP_NERF_MULT = 0.45, was 0.75 for a 25% cut, now a 55% cut) lives at the spawn site in engine/combat/damage.ts → spawnXPOrbs, alongside the 0.2025 * 0.975 * depthMult * tierMult math. The orb only carries the already-baked amount integer.
  • Determine where orbs spawn. Callers (engine/combat/damage.ts → spawnXPOrbs, engine/world/crates.ts, engine/world/props.ts, engine/world/pickups.ts) own the spawn position, angle, and initial velocity (180 u/s outward for kills); this module just stores them.
  • Convert XP into levels. engine/world/leveling.ts → LevelingSystem.update polls game.xp against XP_THRESHOLDS every tick.
  • Define the magnet range. Reads ship.magnetRange (which is owned by Modifiers / ship base config). Defaults to 100 only when unset.
  • Allocate. spawn claims a pre-allocated slot; _removeAt is swap-with-last; collect-burst particles come from Particles pool; the draw path uses one batched sprite buffer.
  • Render on WebGL2-less devices. The draw fallback path just returns silently — orbs remain mechanically active and collectible, only invisible.
  • Persist across runs. clear() wipes the pool and both merge accumulators on run reset / death.
  • Despawn by age. Orbs do not time out; the only removal paths are direct contact, magnet-layer-1 snap-on (under 20 px), merge absorption, clear, or being pushed out of the pool (which currently cannot happen because spawn no-ops at cap).
  • Emit any Sig signals. The collect path is fully synchronous and only touches the local XP / particle / juice systems.

Signals

Fires: none on the typed Sig bus.

  • Indirect VFX/audio cues: Juice.fire('xp_pickup') and the collect-burst particles fire on every successful _collectSlot.
  • XpAccum.collect(amount) is the only cumulative-XP feed seen by the HUD popup overlay for orb pickups.

Does not subscribe to any signals.

Entry points

  • spawn(x, y, vx, vy, amount, radius = 4) — claim a slot. Silent no-op when _count >= MAX_ORBS. Initial vx/vy decay via DRAG_PER_60FPS so the spawn burst dampens over a few frames. Called from engine/combat/damage.ts → spawnXPOrbs (enemy kills), engine/world/crates.ts (crate breaks), engine/world/props.ts (destructible props), engine/world/pickups.ts, testing/stress-tests.ts.
  • count() — current live count. Used by bridge.ts for diagnostics (diagSetPoolStats, tutorial trigger, render diag) and by props.ts for telemetry (prop_break:scrap_magnet_pulse).
  • clear() — wipe pool and reset merge accumulators. Called by engine/world/pickups.ts → reset and bridge.ts on run reset.
  • triggerGlobalMagnet() — flip every live orb into the exponential lock-on phase (_flags |= 2, _magTime = 0, velocity zeroed). Called from engine/world/pickups.ts → triggerGlobalMagnet, engine/world/props.ts (scrap-magnet pulse on destructible break), and engine/bridge.ts (magnet shooting-star events + boss death drop).
  • update(ship, game, dt) — per-frame tick. Runs throttled offscreen and nearby merge passes, then for every orb (backwards loop for cheap swap-remove): ages _life and _spawnAge; magnet layer 1 snap-on (dist < 20); magnet layer 2 base pull (dsq < magnetRange²) building _pullAccum until it crosses 4 and promotes to layer 3; magnet layer 3 exponential lock-on (accelSpeed = 200 * 3.0^_magTime, applied as a unit-vector step capped at 0.95 * dist); direct contact collect (dsq < (shipR + orbR)²); velocity decay (vx *= drag^(dt*60)). Called from engine/world/pickups.ts → update.
  • draw(ctx, webglReady) — emits one instanced GL quad per orb via SpriteBatch.add with the xp_orb atlas region. Silent fallback on non-WebGL2 devices. Called from engine/bridge.ts inside the world-render pass.
  • drawTutorialGlow(ctx, phase) — golden ring (#fbbf24, line width 2 * camera.zoom) around every active orb with globalAlpha = phase. Drives tutorial step 1’s “pick up XP” pulse. Called from engine/bridge.ts after the orb draw when the tutorial overlay is up.
  • snapshot() — debug/test shallow copy of { x, y, amount } for each live orb. Used by testing/stress-tests.ts and unit tests. Do not mutate the returned objects (positions in the snapshot are decoupled from internal state, but amount is a primitive copy).

Pattern notes

  • Storage shape: structure-of-arrays with one Uint8 _flags byte (bit 0 active, bit 1 collected/locked-on). Removal is unconditional swap-with-last, so iteration order is undefined and reuse-safe. All hot loops walk indices [0, _count) once and use the typed-array slots directly — no object allocation per orb, per frame, or per collect.
  • Three-layer magnet model:
    • Layer 1 (snap-on): when dist < 20 the orb collects immediately, regardless of magnet range or collected-flag state. This guarantees no orb can sit “almost touching” the ship.
    • Layer 2 (base pull): when dsq < magnetRange², the orb gets pulled by baseSpeed * (0.1 + pullPct² * 0.9) where pullPct = 1 − dist/magnetRange and baseSpeed = CFG.MAGNET_SPEED * 0.4. The 0.4 multiplier is the legacy PickupSystem._magnetPull xp-orb tuning. Pull strength integrates into _pullAccum; crossing the threshold of 4 promotes the orb to layer 3.
    • Layer 3 (exponential lock-on): _magTime grows by dt per frame; the per-frame step is min(0.95, 200 * 3^_magTime * dt / max(1, dist)), applied as a unit-vector hop toward the ship and overriding all velocity. triggerGlobalMagnet jumps every orb straight into this phase. The 3x exponential growth means lock-on orbs reach the ship within roughly 0.5-1.0 s regardless of starting distance.
  • Three merge passes with disjoint roles, all gated by _xpCapPerOrb so no merged orb can grant >1 level on pickup (floor 50, ceiling 0.75 * (next_level_cost − current_level_cost)):
    • Nearby merge (20 Hz, XP_MERGE_RADIUS_PX = 70): pairwise consolidate clumps after the spawn cooldown.
    • Offscreen merge (20 Hz, OFFSCREEN_MARGIN_PX = 200): collapse tail orbs into their nearest cap-respecting neighbor anywhere in the world. The tightened 200 px margin (down from 500) means anything just past the viewport edge collapses.
    • On-screen soft cap (ON_SCREEN_SOFT_CAP = 40, same pass as offscreen merge): if more than 40 sub-cap orbs are visible, smallest excess get force-absorbed into the largest sub-cap absorber so the visual field stays readable when a wave dies all at once. Spawn-cooldown immunity still applies so fresh orbs are allowed to pop visually.
  • Per-orb cap rises with level. _xpCapPerOrb recomputes (XP_THRESHOLDS[level+1] − XP_THRESHOLDS[level]) * 0.75 every merge pass, so end-game orbs naturally hold more XP than early-game orbs even though the constant is fixed. The floor of 50 prevents the cap from collapsing to nothing at very early levels. Without this cap, telemetry confirmed queueLength spikes of 4 at biome tier 2 (one mega-orb pushing the player across 3-4 thresholds at once).
  • The flat global XP nerf is not applied here. XP_NERF_MULT = 0.45 lives in engine/combat/damage.ts → spawnXPOrbs and is folded into the integer amount at spawn time. The orb pool only sees the already-discounted value. History: 0.75 (25% cut) → 0.45 (55% cut, +30 ppt). Changing that constant is the single global throttle on XP rate.
  • ship.xpGainMult is applied at collect time, not spawn time. _collectSlot does round(_amount[i] * xpMult) so modifier picks that raise XP gain only affect orbs being picked up afterward (which matches the modifier semantics for already-spawned orbs in flight).
  • Capacity sizing: 1024 slots is 10x the legacy MAX_XP_ORBS = 100. The merge and offscreen-cull passes keep live counts bounded well below capacity in normal play; the 1024 ceiling exists for stress-test correctness and degenerate spawn bursts (boss death drops, massive prop chains). spawn is a silent no-op at full pool — callers don’t need to check.
  • Velocity decay uses Math.pow(DRAG_PER_60FPS, dt * 60) to remain frame-rate independent. At dt = 1/60 the multiplier is exactly 0.92; at dt = 1/30 it’s 0.92² ≈ 0.846. Stops orbs from drifting forever after the initial 180 u/s outward burst from kills.
  • Draw scaling: sizeScale = min(2.0, 1.0 + log2(max(1, amount)) * 0.16) is a log curve so a merged orb carrying 100x the XP of a base orb only draws roughly 2x larger — keeps the screen readable when a cluster collapses to a single large value.
  • WebGL2 fallback intentionally skips rendering. Game stays fully playable on legacy devices; the orbs are mechanically identical (same magnet, same collection, same XP grant), only invisible. This keeps the fallback path tiny and avoids maintaining a Canvas2D draw path for a 60fps system that runs 500+ entities.
  • Tutorial integration: bridge.ts enters tutorial step 1 whenever xpOrbs.count() > 0 and calls drawTutorialGlow(ctx, phase) to overlay the golden pulse on every live orb until the step advances.
  • Animation timestamp dropped: an earlier version stored _mergeTime for a merge “pop” animation, but the batched sprite is static and no renderer reads it, so the column was removed (the absorb code still references game.time but immediately discards it via void gameTime).