PURPOSE

Pre-allocated pool of small floating metal-shard pieces that spawn in off-screen clusters around the ship, bob and rotate slowly in place, and deal a flat percentage of the player’s HP+shield pool on contact (the piece breaks on hit, no XP drop). Sister system to crates.ts — shares the same biome 0–100 slider framework, the same off-screen velocity-cone spawn bias, the same shared-period bob, and the same singleton pattern — but adds cluster spawn with rejection-sampled placement, per-piece rotation, and procedural irregular-polygon-with-spikes geometry drawn as a single composite silhouette per piece.

OWNS

  • The DebrisPiece and Cluster interfaces.
  • The DebrisField class with its fixed pool: DebrisPiece[] of length POOL_SIZE = 120 and its clusters: Map<number, Cluster> keyed by monotonic nextClusterId.
  • The singleton debris = new DebrisField() exported instance.
  • All tunable constants for density (MAX_PIECES = 100), spawn geometry (SPAWN_MIN/MAX, CULL_RADIUS, CLUSTER_GAP, VELOCITY_CONE_BIAS, SPAWN_TICK_MS, SPAWN_ATTEMPTS_PER_TICK), cluster shape (CLUSTER_RADIUS, CLUSTER_MIN/MAX, PLACE_TRIES, PIECE_SPACING_FRAC), bob (BOB_PERIOD_SEC), damage (DMG_FRAC), polygon shape (MIN_VERTS/MAX_VERTS, ANGLE_JITTER, RADIUS_MIN/MAX), spikes (MIN_SPIKES/MAX_SPIKES, SPIKE_LEN_MIN/MAX, SPIKE_OFFSET_RANGE, SPIKE_STRIDE), rotation (ROT_SPD_MAX), fill (FILL_DARK, FILL_LIGHT, FILL_TILT_X), stroke (STROKE_COLOR, STROKE_WIDTH, HALO_COLOR, HALO_WIDTH_MULT), and collision (COLLISION_SIZE_FRAC, SIZE_SMALL).
  • The pre-squared constants CULL_RADIUS_SQ and CLUSTER_GAP_SQ.
  • The module-scoped _scratchEdgeIdx: Int32Array(MAX_VERTS) used by the partial Fisher-Yates shuffle in generateSpikes.
  • The free functions densityForPlanet, generateIrregularPolygon, and generateSpikes.
  • Per-piece state: active, x, y, clusterId, size, verts/vertCount, spikes/spikeCount, bobPhase, bobAmp, rot0, rotSpd.
  • Per-cluster state: id, cx, cy, fogR, fogAlpha, pieceCount.
  • Aggregate counters: activeCount, tickAccumMs, nextClusterId.

READS FROM

  • core/stateW, H (canvas dims) and the global camera (zoom + center for the viewport fallback inside draw and trimToTarget).
  • rendering/cameraCamera.toS(x, y) for world→screen projection in draw (both fog and piece passes).
  • core/clockClock.now() for the wall-clock seconds driving the shared bob curve and per-piece rotation in draw.
  • data/planet-configPLANETS[planetId].destructibles.debris (0–100 slider) via densityForPlanet().
  • The ship argument into update() — reads ship.x, ship.y, ship.vx, ship.vy, ship.alive, ship.outerRadius/ship.radius, ship.hpMax, ship.shieldMax.
  • The world argument — reads world.planetId for the density slider lookup.
  • The camera_ argument — reads cam.x, cam.y, cam.zoom in viewport() (falls back to the global camera when missing).
  • The DOM as a last resort: document.querySelector('canvas') inside viewport() if W/H are zero before first resize.

PUSHES TO

  • combat/damage — calls damagePlayer(ship, dmg, game, angle) on contact with dmg = max(1, (hpMax + shieldMax) * DMG_FRAC) and angle = atan2(dy, dx) from ship to piece.
  • vfx/particles — calls Particles.burst(p.x, p.y, 6 + floor(p.size/6), 'spark', { r: 110, g: 100, b: 92 }, 60) on each break (guarded by typeof Particles.burst === 'function').
  • A 2D canvas context in draw(ctx) — issues createRadialGradient/fill for cluster fog under globalCompositeOperation = 'multiply', then createLinearGradient/fill plus two stroke passes (black halo, red rim) per active piece.

DOES NOT

  • Allocate at runtime. The pool, the per-piece verts and spikes Float32Arrays, and the _scratchEdgeIdx shuffle buffer are all sized at construction or module load. Local arrays in spawnCluster (placedX, placedY, placedR) are the only per-cluster allocations.
  • Drop XP, gold, or any reward on break.
  • Fire engine signals or juice events. There is no Sig.fire or Juice.fire call — only damage, particles, and pool bookkeeping.
  • Use the WebGL sprite batch or the texture atlas. Every piece is drawn as a composite Canvas-2D path (gradient fill + halo stroke + rim stroke); there is no sprite bake.
  • Spawn or place a cluster whose center is inside the viewport (VIEWPORT_MARGIN-padded), or within CLUSTER_GAP = 280 units of any existing cluster center.
  • Overlap two pieces inside the same cluster. Placement is rejection-sampled up to PLACE_TRIES = 16 per piece using PIECE_SPACING_FRAC = 1.05 clearance; failures skip that piece silently.
  • Place more than one spike on the same edge of the same polygon — generateSpikes uses a partial Fisher-Yates shuffle so spike edges are distinct.
  • Drive its own movement or knock pieces around. World-space position is fixed at spawn; only the visible bob offset and rotation change per frame.
  • React to time-dilation. The bob and rotation curves read Clock.now() directly (wall-clock).
  • Damage the ship while ship.alive is false.
  • Cull on-screen pieces during the periodic runCullinViewport actives are protected. trimToTarget does an off-screen pass first and only touches on-screen pieces in a second pass if still over target.

Signals

  • None. debris.ts does not call Sig.fire or Juice.fire. The crate-buster style listener path used by crates.ts does not exist for debris pieces — observers must hook damagePlayer or Particles.burst if they need a notification.

Entry points

  • debris.init() — no-op. Geometry is generated per piece at spawn; there is no sprite to bake.
  • debris.clear() — deactivates every piece, clears the cluster map, resets activeCount, tickAccumMs, and nextClusterId. Called on new-run / arena teardown.
  • debris.update(dt, ship, camera_, world, game) — per-frame. Runs collision every frame against the live ship, then accumulates dt * 1000 into tickAccumMs and only runs cull/trim/spawn/prune when the accumulator hits SPAWN_TICK_MS = 250.
  • debris.draw(ctx) — per-frame, two-pass Canvas-2D render. Pass 1 walks clusters under multiply blend to lay down per-cluster radial fog patches. Pass 2 walks the pool and, for each active piece, builds a single composite path (main polygon perimeter with detours through each spike’s tip, rotation baked into every vertex transform), fills with a screen-space vertical gradient, then strokes black halo (STROKE_WIDTH * HALO_WIDTH_MULT * zoom) and red rim (STROKE_WIDTH * zoom).

Pattern notes

  • Fixed pool, no allocations on the hot path. POOL_SIZE = 120 pieces are constructed once in the DebrisField constructor with their verts (MAX_VERTS * 2 floats) and spikes (MAX_SPIKES * SPIKE_STRIDE floats) Float32Arrays pre-sized. findFreeSlot() linear-scans for an inactive slot; activeCount is maintained explicitly so the budget check never has to scan.
  • Cluster spawn with rejection-sampled placement. Each spawn attempt picks an off-screen cluster center in a ring SPAWN_MIN..SPAWN_MAX (500..1500 units) around the ship. If moving (speed > 1), VELOCITY_CONE_BIAS = 0.7 of attempts bias the angle to within ±π/2 of heading. Candidates are rejected if inside the viewport or within CLUSTER_GAP of another cluster. A valid cluster generates 2..4 pieces (CLUSTER_MIN..CLUSTER_MAX); each piece is placed inside CLUSTER_RADIUS = 200 via sqrt(random) * R (uniform-area sampling) and retried up to PLACE_TRIES = 16 with PIECE_SPACING_FRAC = 1.05 clearance against already-placed pieces in the same cluster.
  • Density slider. densityForPlanet(planetId) reads PLANETS[planetId].destructibles.debris (0..100), clamps, and rounds linearly to MAX_PIECES = 100. Missing planet or missing slider returns 0.
  • Asymmetric spawn/cull radii. Pieces spawn out to SPAWN_MAX = 1500 but cull at CULL_RADIUS = 2200, so the field does not flicker as the ship moves.
  • Shared-period bob, per-piece phase + amplitude. BOB_PERIOD_SEC = 6.0 (matches crates.ts philosophy of one global drift). Each piece picks a random bobPhase (0..2π) and bobAmp (1.5..3.0 px). Sampled in draw via sin((tSec / BOB_PERIOD_SEC) * 2π + bobPhase) * bobAmp.
  • Per-piece rotation. Each piece picks rot0 (0..2π) and rotSpdROT_SPD_MAX = 0.10 rad/s, signed). Current rotation in draw is rot0 + rotSpd * tSec. Rotation is baked into the per-vertex (vx, vy) → screen transform so spike tips track the body.
  • Procedural irregular polygon. generateIrregularPolygon picks n in MIN_VERTS..MAX_VERTS (4..6). For each vertex k, the angle stays inside slice [k*step, (k+1)*step] with ANGLE_JITTER = 0.18 margin — slices never overlap, so the angle sequence is monotonically increasing and the polygon is always simple (no self-intersections, no twisted-star failure). Radii vary in the tight band RADIUS_MIN..RADIUS_MAX (0.85..1.00) so the body reads as near-round.
  • Procedural spikes with distinct edges. generateSpikes wants 2..4 spikes (MIN_SPIKES..MAX_SPIKES), capped by vertCount. A partial Fisher-Yates shuffle on _scratchEdgeIdx picks distinct edges — every spike gets its own triangle protrusion and no two detour the same edge. Each spike stores (edgeIndex, lenFrac, offsetFrac) with lenFrac in SPIKE_LEN_MIN..SPIKE_LEN_MAX (0.45..0.95) and offsetFrac in ±SPIKE_OFFSET_RANGE (±0.40 — slides the tip along the edge tangent for asymmetric triangles).
  • Composite silhouette draw. The piece is traced as one path: start at v[0], for each edge k→next walk through the (optional) attached spike’s tip and on to v[next]; closePath handles the wrap. The outward normal of edge a→b in canvas (y-down, vertices wound CCW in math space = CW in canvas) is the right-hand perpendicular (ey, -ex) / |edge|. The result is filled, then stroked twice — wider black halo first, narrower red rim on top — leaving a thin black ring outside the red (sticker style, matches the crate halo).
  • Screen-space gradient. Each piece’s linear gradient is built in screen space (not rotated) so the lighting direction is consistent across the whole field — reads as one surface lit from above, not a swarm of independently-lit rocks. Dark stop at bottom, light stop at top, with FILL_TILT_X = 0.20 lateral tilt (~11°).
  • Cluster fog. Each cluster owns a single radial gradient patch (fogR = CLUSTER_RADIUS * 1.4, fogAlpha = 0.05) drawn under globalCompositeOperation = 'multiply' so it darkens the starfield beneath without lightening the pieces. Fog is rectangle-culled per cluster against W/H.
  • Contact damage. runCollision runs every frame (not throttled). Uses hitR = shipR + p.size * COLLISION_SIZE_FRAC where COLLISION_SIZE_FRAC = 0.95 — extends past the main polygon so visible spike barbs actually hit. Damage is max(1, (hpMax + shieldMax) * DMG_FRAC) with DMG_FRAC = 0.04. On hit: deal damage via damagePlayer, emit one Particles.burst spark puff (color rgb(110,100,92), count 6 + floor(size/6)), deactivate the piece, decrement activeCount and the owning cluster’s pieceCount.
  • Cluster lifecycle. A cluster is created when spawnCluster successfully places at least one piece (placed > 0). It is removed by pruneEmptyClusters once pieceCount <= 0 (every piece broken, culled, or trimmed). Orphaned pieces would carry clusterId = -1 but the code only sets that in the slot initializer; live pieces always carry their owning cluster id.
  • Trim is two-pass. When activeCount > target after a slider drop, trimToTarget first walks the pool deactivating off-screen pieces, then walks it again deactivating any remaining (including on-screen) until under target. Each deactivation decrements the owning cluster’s pieceCount so pruneEmptyClusters can clean up.
  • Viewport fallback. viewport() reads W/H from core/state; if zero (pre-first-resize), queries document.querySelector('canvas') for clientWidth/clientHeight. Zoom comes from cam.zoom, falling back to global camera.zoom, falling back to 1.
  • Singleton export. export const debris = new DebrisField() — the rest of the engine imports the instance, not the class.