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
DebrisPieceandClusterinterfaces. - The
DebrisFieldclass with its fixedpool: DebrisPiece[]of lengthPOOL_SIZE = 120and itsclusters: Map<number, Cluster>keyed by monotonicnextClusterId. - 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_SQandCLUSTER_GAP_SQ. - The module-scoped
_scratchEdgeIdx: Int32Array(MAX_VERTS)used by the partial Fisher-Yates shuffle ingenerateSpikes. - The free functions
densityForPlanet,generateIrregularPolygon, andgenerateSpikes. - 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/state—W,H(canvas dims) and the globalcamera(zoom + center for the viewport fallback insidedrawandtrimToTarget).rendering/camera—Camera.toS(x, y)for world→screen projection indraw(both fog and piece passes).core/clock—Clock.now()for the wall-clock seconds driving the shared bob curve and per-piece rotation indraw.data/planet-config—PLANETS[planetId].destructibles.debris(0–100 slider) viadensityForPlanet().- The
shipargument intoupdate()— readsship.x,ship.y,ship.vx,ship.vy,ship.alive,ship.outerRadius/ship.radius,ship.hpMax,ship.shieldMax. - The
worldargument — readsworld.planetIdfor the density slider lookup. - The
camera_argument — readscam.x,cam.y,cam.zoominviewport()(falls back to the globalcamerawhen missing). - The DOM as a last resort:
document.querySelector('canvas')insideviewport()ifW/Hare zero before first resize.
PUSHES TO
combat/damage— callsdamagePlayer(ship, dmg, game, angle)on contact withdmg = max(1, (hpMax + shieldMax) * DMG_FRAC)andangle = atan2(dy, dx)from ship to piece.vfx/particles— callsParticles.burst(p.x, p.y, 6 + floor(p.size/6), 'spark', { r: 110, g: 100, b: 92 }, 60)on each break (guarded bytypeof Particles.burst === 'function').- A 2D canvas context in
draw(ctx)— issuescreateRadialGradient/fillfor cluster fog underglobalCompositeOperation = 'multiply', thencreateLinearGradient/fillplus twostrokepasses (black halo, red rim) per active piece.
DOES NOT
- Allocate at runtime. The pool, the per-piece
vertsandspikesFloat32Arrays, and the_scratchEdgeIdxshuffle buffer are all sized at construction or module load. Local arrays inspawnCluster(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.fireorJuice.firecall — 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 withinCLUSTER_GAP = 280units of any existing cluster center. - Overlap two pieces inside the same cluster. Placement is rejection-sampled up to
PLACE_TRIES = 16per piece usingPIECE_SPACING_FRAC = 1.05clearance; failures skip that piece silently. - Place more than one spike on the same edge of the same polygon —
generateSpikesuses 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.aliveis false. - Cull on-screen pieces during the periodic
runCull—inViewportactives are protected.trimToTargetdoes an off-screen pass first and only touches on-screen pieces in a second pass if still over target.
Signals
- None.
debris.tsdoes not callSig.fireorJuice.fire. The crate-buster style listener path used bycrates.tsdoes not exist for debris pieces — observers must hookdamagePlayerorParticles.burstif 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, resetsactiveCount,tickAccumMs, andnextClusterId. Called on new-run / arena teardown.debris.update(dt, ship, camera_, world, game)— per-frame. Runs collision every frame against the live ship, then accumulatesdt * 1000intotickAccumMsand only runs cull/trim/spawn/prune when the accumulator hitsSPAWN_TICK_MS = 250.debris.draw(ctx)— per-frame, two-pass Canvas-2D render. Pass 1 walksclustersunder 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 = 120pieces are constructed once in theDebrisFieldconstructor with theirverts(MAX_VERTS * 2floats) andspikes(MAX_SPIKES * SPIKE_STRIDEfloats) Float32Arrays pre-sized.findFreeSlot()linear-scans for an inactive slot;activeCountis 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.7of attempts bias the angle to within ±π/2 of heading. Candidates are rejected if inside the viewport or withinCLUSTER_GAPof another cluster. A valid cluster generates 2..4 pieces (CLUSTER_MIN..CLUSTER_MAX); each piece is placed insideCLUSTER_RADIUS = 200viasqrt(random) * R(uniform-area sampling) and retried up toPLACE_TRIES = 16withPIECE_SPACING_FRAC = 1.05clearance against already-placed pieces in the same cluster. - Density slider.
densityForPlanet(planetId)readsPLANETS[planetId].destructibles.debris(0..100), clamps, and rounds linearly toMAX_PIECES = 100. Missing planet or missing slider returns 0. - Asymmetric spawn/cull radii. Pieces spawn out to
SPAWN_MAX = 1500but cull atCULL_RADIUS = 2200, so the field does not flicker as the ship moves. - Shared-period bob, per-piece phase + amplitude.
BOB_PERIOD_SEC = 6.0(matchescrates.tsphilosophy of one global drift). Each piece picks a randombobPhase(0..2π) andbobAmp(1.5..3.0 px). Sampled indrawviasin((tSec / BOB_PERIOD_SEC) * 2π + bobPhase) * bobAmp. - Per-piece rotation. Each piece picks
rot0(0..2π) androtSpd(±ROT_SPD_MAX = 0.10rad/s, signed). Current rotation indrawisrot0 + rotSpd * tSec. Rotation is baked into the per-vertex(vx, vy) → screentransform so spike tips track the body. - Procedural irregular polygon.
generateIrregularPolygonpicksninMIN_VERTS..MAX_VERTS(4..6). For each vertex k, the angle stays inside slice[k*step, (k+1)*step]withANGLE_JITTER = 0.18margin — 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 bandRADIUS_MIN..RADIUS_MAX(0.85..1.00) so the body reads as near-round. - Procedural spikes with distinct edges.
generateSpikeswants 2..4 spikes (MIN_SPIKES..MAX_SPIKES), capped byvertCount. A partial Fisher-Yates shuffle on_scratchEdgeIdxpicks distinct edges — every spike gets its own triangle protrusion and no two detour the same edge. Each spike stores(edgeIndex, lenFrac, offsetFrac)withlenFracinSPIKE_LEN_MIN..SPIKE_LEN_MAX(0.45..0.95) andoffsetFracin±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];
closePathhandles 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.20lateral tilt (~11°). - Cluster fog. Each cluster owns a single radial gradient patch (
fogR = CLUSTER_RADIUS * 1.4,fogAlpha = 0.05) drawn underglobalCompositeOperation = 'multiply'so it darkens the starfield beneath without lightening the pieces. Fog is rectangle-culled per cluster againstW/H. - Contact damage.
runCollisionruns every frame (not throttled). UseshitR = shipR + p.size * COLLISION_SIZE_FRACwhereCOLLISION_SIZE_FRAC = 0.95— extends past the main polygon so visible spike barbs actually hit. Damage ismax(1, (hpMax + shieldMax) * DMG_FRAC)withDMG_FRAC = 0.04. On hit: deal damage viadamagePlayer, emit oneParticles.burstspark puff (colorrgb(110,100,92), count6 + floor(size/6)), deactivate the piece, decrementactiveCountand the owning cluster’spieceCount. - Cluster lifecycle. A cluster is created when
spawnClustersuccessfully places at least one piece (placed > 0). It is removed bypruneEmptyClustersoncepieceCount <= 0(every piece broken, culled, or trimmed). Orphaned pieces would carryclusterId = -1but the code only sets that in the slot initializer; live pieces always carry their owning cluster id. - Trim is two-pass. When
activeCount > targetafter a slider drop,trimToTargetfirst 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’spieceCountsopruneEmptyClusterscan clean up. - Viewport fallback.
viewport()readsW/Hfromcore/state; if zero (pre-first-resize), queriesdocument.querySelector('canvas')forclientWidth/clientHeight. Zoom comes fromcam.zoom, falling back to globalcamera.zoom, falling back to 1. - Singleton export.
export const debris = new DebrisField()— the rest of the engine imports the instance, not the class.