PURPOSE

Pre-allocated pool of breakable crate pickups (the box emoji destructible) that spawn off-screen around the ship, drift gently in place, break on player contact, and drop XP orbs plus a three-layer VFX burst. Replaces the legacy tiered-cube destructible system: a single flat sprite type, sector-aware spawn, no on-screen pop-in or cull, zero per-frame allocations. Density is driven by each planet’s destructibles.crates slider.

OWNS

  • The CrateSlot interface and the CratePool class with its fixed-size slot array (POOL_SIZE = 80).
  • The module-level baked Canvas-2D fallback sprite (sprite: HTMLCanvasElement | null) and its lazy bake via bakeSprite().
  • The singleton crates = new CratePool() exported instance.
  • Tunable constants for density, spawn geometry, collision radius, sprite size, bob curve, drop-shadow, XP percentage range, and the three VFX layers (impact sparks, debris chunks, rim flash ring).
  • Internal state: activeCount, tickAccumMs, per-slot active/x/y/bobPhase/bobAmp.

READS FROM

  • core/stateW, H (canvas dims), camera (fallback viewport + Canvas-2D draw), game (for game.uiTime in the bob curve and game.level for XP scaling).
  • rendering/cameraCamera.toS(x, y) for world→screen projection in the Canvas-2D draw path.
  • rendering/sprite-batchgetSpriteBatch() for the primary WebGL instanced draw.
  • rendering/atlas-buildertryGetAtlasRegion('crate') for the atlas region and bakeStickerEmoji(...) for the Canvas-2D fallback bake.
  • data/planet-configPLANETS[planetId].destructibles.crates (0–100 slider) via densityForPlanet().
  • engine/world/levelingxpForLevel(level) to compute the current XP bar size.
  • The ship argument passed into update() — reads ship.x, ship.y, ship.vx, ship.vy, ship.alive, ship.outerRadius/ship.radius.
  • The world argument — reads world.planetId for the density slider lookup.
  • The DOM as a last resort: document.querySelector('canvas') if W/H are zero before first resize.

PUSHES TO

  • core/signals — fires Sig.fire('crate_break', 0, 0, c.x, c.y, '') on each break (consumed by the crate-buster artifact and any other listeners).
  • vfx/juice — fires Juice.fire('crate_break') for the SFX/screen-juice channel.
  • engine/world/xp-orbs — calls xpOrbs.spawn(x, y, vx, vy, amount, lifeSeconds) 3–5 times per break.
  • vfx/particles — pushes CRATE_SPARK_COUNT (16) hot-yellow/white spark particles and CRATE_DEBRIS_COUNT (5) saddle-brown debris chunks via Particles.add(...).
  • vfx/sonar-rings — emits one warm-amber rim flash via SonarRings.shockwave(x, y, startR, endR, color, lifetime, lineW).
  • The active WebGL SpriteBatch — adds two passes (shadow at +SHADOW_Y_OFFSET, sprite with bob) per active crate per frame.
  • A 2D canvas context in the fallback path — issues drawImage/fillRect for the shadow and sprite passes.

DOES NOT

  • Allocate at runtime. The slot array, particles, and orbs all come from pre-existing pools.
  • Spawn or cull crates while they are inside the camera viewport (with VIEWPORT_MARGIN padding). On-screen actives are protected even during trimToTarget until the off-screen pass is exhausted.
  • Damage the ship, apply knockback, or modify ship state on collision — collision triggers a break only.
  • Spawn anything other than the flat box emoji. There are no tiers, no variants, no rare crates.
  • Persist across runs or reuse positions between clear() calls.
  • Drive its own movement — crates are static in world space and only the visible sprite bobs vertically; the shadow stays anchored.
  • React to time-dilation for the bob curve — game.uiTime is wall-clock so the bob continues during slow-mo.
  • Spawn during the first frame if no slot is free or all attempts collide with the viewport / another active crate (it silently skips that tick).

Signals

  • Fires crate_break with payload (0, 0, c.x, c.y, '') on every successful collision break, before SFX and VFX. Order is load-bearing: signal first (so the crate-buster artifact hook fires), then juice, then XP orbs, then the three VFX layers.

Entry points

  • crates.init() — bakes the Canvas-2D fallback sprite. Called once during game boot.
  • crates.clear() — deactivates every slot and resets counters. Called on new-run / arena teardown.
  • crates.update(dt, ship, camera_, world, game) — per-frame: runs collision every frame, throttles spawn/cull/trim on a SPAWN_TICK_MS (200 ms) accumulator. Computes target density from world.planetId.
  • crates.draw(ctx) — per-frame draw. Primary path is the WebGL sprite batch (two passes: shadow then sprite-with-bob). Falls back to Canvas-2D with the module-baked sprite if the batch or atlas region is missing.

Pattern notes

  • Fixed pool, no allocations. POOL_SIZE = 80 slots are constructed once in the CratePool constructor. findFreeSlot() linear-scans for an inactive slot; activeCount is maintained explicitly to avoid scanning during the budget check.
  • Sector-aware spawn with velocity-cone bias. Spawns happen in a ring SPAWN_MIN..SPAWN_MAX (400–1400 units) around the ship. If the ship is moving (speed > 1), VELOCITY_CONE_BIAS = 0.7 of attempts pick an angle within ±π/2 of the ship’s heading so crates appear ahead of motion. SPAWN_ATTEMPTS_PER_TICK = 8 candidate positions are tried per tick; each is rejected if it lands in the viewport or within MIN_GAP = 120 units of another active crate.
  • Asymmetric spawn/cull radii. Crates spawn out to SPAWN_MAX = 1400 but cull at CULL_RADIUS = 2000, so a moving ship doesn’t immediately re-cull what it just spawned.
  • Viewport never culls or pops. runCull skips any active crate inside the (margin-padded) viewport. trimToTarget does two passes when the slider drops: off-screen first, then on-screen only if still over target.
  • Density slider. densityForPlanet(planetId) reads PLANETS[planetId].destructibles.crates (0–100), clamps, and scales linearly to MAX_DENSITY = 20. Missing planet or missing slider falls back to MAX_DENSITY.
  • Collision radius is sprite-tracked. COLLISION_RADIUS = 36 is documented as the visible silhouette (emoji + halo) at the current bake size with ~5% buffer; comment includes the derivation formula.
  • Bob is shared-period, per-slot phase + amplitude. BOB_PERIOD_SEC = 8.0 is global so the field reads as one drift; each slot gets a random bobPhase (0..2π) and bobAmp (BOB_AMP_MIN..BOB_AMP_MAX, 7..18). Sampled via sampleBob(c) using game.uiTime.
  • Drop shadow matches ship/enemy pass. SHADOW_Y_OFFSET = 25, SHADOW_ALPHA = 0.35 mirror the bridge.ts shadow pass for visual coherence; shadows do not bob.
  • XP drop scales with player level. Each break rolls CRATE_XP_PCT_MIN..CRATE_XP_PCT_MAX (3–9%) of the current level’s XP bar (xpForLevel(level+1) - xpForLevel(level)), splits across 3–5 orbs ejected at 150 u/s on random angles from a small radial offset (8..24 units).
  • Three-layer break VFX (all constants named). Layer 1 — 16 spark particles, hot-yellow base (#FFD750) with every 4th switched to hot-white (#FFF5C8), speed 60..190, life 0.12..0.30. Layer 2 — 5 saddle-brown chunks (#8B5A2B), slower (25..80) and larger, life 0.35..0.60. Layer 3 — one SonarRings.shockwave warm-amber ring (#ffb840) expanding 8 → 72 over 0.28 s at line width 3.5.
  • Render path split. WebGL SpriteBatch path is preferred and does both passes in instanced draws against the 'crate' atlas region. Canvas-2D fallback uses source-in composite to tint the baked sprite into a black silhouette for the shadow pass, then redraws normally for the sprite pass; both fallback passes off-screen-cull by rect against W/H.
  • Viewport fallback to live <canvas>. viewport() reads W/H from core/state but if those are zero (pre-first-resize), queries document.querySelector('canvas') and uses clientWidth/clientHeight.
  • Singleton export. export const crates = new CratePool() — the rest of the engine imports the instance, not the class.