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.spawnsilently 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 =_collectedexponential lock-on phase). Active orbs occupy indices[0, _count);_removeAtswap-with-last. _count— number of currently live orbs._mergeAccum,_offscreenMergeAccum— per-pass throttle accumulators.- Merge tunables —
XP_MERGE_RADIUS_PX = 70(squared intoXP_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 viaMath.pow). - Collect VFX constants —
XP_CORE_COUNT = 8lime-green spark particles(100, 230, 120)with speed range55..105u/s, life0.10..0.18s, size1.0..2.2, alpha0.85;XP_BLOOM_COUNT = 2near-white mint particles(200, 255, 220)with speed30..60u/s, life0.06s, size3.0, alpha0.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 thanOFFSCREEN_MARGIN_PXoutside 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, ifonScreenCount > ON_SCREEN_SOFT_CAPit 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 thanMERGE_SPAWN_COOLDOWN_S). Absorber radius grows up to 18 in+0.4steps._mergeNearby(game)— pairwise sweep absorbing orbiinto olderjwhen both are past spawn cooldown, withinXP_MERGE_RADIUS_PX, and the combined amount stays under the cap. Survivor radius grows up to 14 in+0.8steps, then gets a small outward velocity nudge so merged clusters spread._collectSlot(i, ship, game, xpMult)— appliesMath.round(_amount[i] * xpMult)togame.xp, callsXpAccum.collect, fires thexp_pickupjuice and the lime-green burst (XP_CORE_*sparks +XP_BLOOM_*flash), then_removeAt(i)._swapIntoSlot(dst, src)/_removeAt(i)— internal slot helpers;_removeAtswap-with-last and clears the freed flag.
READS FROM
../core/types—ShipState,GameStateshapes.../core/config—CFG.MAGNET_SPEED(xp usesCFG.MAGNET_SPEED * 0.4asbaseSpeed).../core/state—W,H,camera(camera position andcamera.zoomdefine the on-screen rectangle used by the offscreen merge pass).../vfx/particles—Particles.addfor collect-burst sparks;XpAccum.collect(amount)for the cumulative XP popup overlay.../vfx/juice—Juice.fire('xp_pickup')on every collect.../rendering/camera—Camera.toSfor the tutorial glow’s world-to-screen projection.../rendering/sprite-batch—getSpriteBatch()for the WebGL instanced draw path.../rendering/atlas-builder—tryGetAtlasRegion('xp_orb')for the batched sprite region../leveling—XP_THRESHOLDSfor the per-merge XP cap.ship.magnetRange(default 100),ship.radius,ship.xpGainMult(default 1.0),ship.x,ship.yfromShipState.game.level,game.xp,game.timefromGameState.
PUSHES TO
game.xp—_collectSlotincrements byround(_amount[i] * ship.xpGainMult).XpAccum.collect(amount)— fires once per collected orb so the popup overlay reflects the same XP delta that hitgame.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, withworldD = 9 * sizeScalewheresizeScale = 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, was0.75for a 25% cut, now a 55% cut) lives at the spawn site inengine/combat/damage.ts → spawnXPOrbs, alongside the0.2025 * 0.975 * depthMult * tierMultmath. The orb only carries the already-bakedamountinteger. - 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.updatepollsgame.xpagainstXP_THRESHOLDSevery tick. - Define the magnet range. Reads
ship.magnetRange(which is owned byModifiers/ ship base config). Defaults to 100 only when unset. - Allocate.
spawnclaims a pre-allocated slot;_removeAtis swap-with-last; collect-burst particles come fromParticlespool; the draw path uses one batched sprite buffer. - Render on WebGL2-less devices. The
drawfallback 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
Sigsignals. 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. Initialvx/vydecay viaDRAG_PER_60FPSso the spawn burst dampens over a few frames. Called fromengine/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 bybridge.tsfor diagnostics (diagSetPoolStats, tutorial trigger, render diag) and byprops.tsfor telemetry (prop_break:scrap_magnet_pulse).clear()— wipe pool and reset merge accumulators. Called byengine/world/pickups.ts → resetandbridge.tson run reset.triggerGlobalMagnet()— flip every live orb into the exponential lock-on phase (_flags |= 2,_magTime = 0, velocity zeroed). Called fromengine/world/pickups.ts → triggerGlobalMagnet,engine/world/props.ts(scrap-magnet pulse on destructible break), andengine/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_lifeand_spawnAge; magnet layer 1 snap-on (dist < 20); magnet layer 2 base pull (dsq < magnetRange²) building_pullAccumuntil 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 at0.95 * dist); direct contact collect (dsq < (shipR + orbR)²); velocity decay (vx *= drag^(dt*60)). Called fromengine/world/pickups.ts → update.draw(ctx, webglReady)— emits one instanced GL quad per orb viaSpriteBatch.addwith thexp_orbatlas region. Silent fallback on non-WebGL2 devices. Called fromengine/bridge.tsinside the world-render pass.drawTutorialGlow(ctx, phase)— golden ring (#fbbf24, line width2 * camera.zoom) around every active orb withglobalAlpha = phase. Drives tutorial step 1’s “pick up XP” pulse. Called fromengine/bridge.tsafter the orb draw when the tutorial overlay is up.snapshot()— debug/test shallow copy of{ x, y, amount }for each live orb. Used bytesting/stress-tests.tsand unit tests. Do not mutate the returned objects (positions in the snapshot are decoupled from internal state, butamountis a primitive copy).
Pattern notes
- Storage shape: structure-of-arrays with one
Uint8_flagsbyte (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 < 20the 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 bybaseSpeed * (0.1 + pullPct² * 0.9)wherepullPct = 1 − dist/magnetRangeandbaseSpeed = CFG.MAGNET_SPEED * 0.4. The 0.4 multiplier is the legacyPickupSystem._magnetPullxp-orb tuning. Pull strength integrates into_pullAccum; crossing the threshold of 4 promotes the orb to layer 3. - Layer 3 (exponential lock-on):
_magTimegrows bydtper frame; the per-frame step ismin(0.95, 200 * 3^_magTime * dt / max(1, dist)), applied as a unit-vector hop toward the ship and overriding all velocity.triggerGlobalMagnetjumps 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.
- Layer 1 (snap-on): when
- Three merge passes with disjoint roles, all gated by
_xpCapPerOrbso no merged orb can grant >1 level on pickup (floor 50, ceiling0.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.
- Nearby merge (20 Hz,
- Per-orb cap rises with level.
_xpCapPerOrbrecomputes(XP_THRESHOLDS[level+1] − XP_THRESHOLDS[level]) * 0.75every 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 confirmedqueueLengthspikes 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.45lives inengine/combat/damage.ts → spawnXPOrbsand is folded into the integeramountat 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.xpGainMultis applied at collect time, not spawn time._collectSlotdoesround(_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).spawnis 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 exactly0.92; at dt = 1/30 it’s0.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.tsenters tutorial step 1 wheneverxpOrbs.count() > 0and callsdrawTutorialGlow(ctx, phase)to overlay the golden pulse on every live orb until the step advances. - Animation timestamp dropped: an earlier version stored
_mergeTimefor a merge “pop” animation, but the batched sprite is static and no renderer reads it, so the column was removed (the absorb code still referencesgame.timebut immediately discards it viavoid gameTime).