PURPOSE

Typed destructible world clutter — a sibling of crates.ts that reads its visual + drop config from the typed catalog in data/props.ts. Each pool slot carries a typeId so a single pool can render and tick multiple prop kinds (currently 7). Owns off-screen spawn cadence, ship-contact one-shot break, weapon-fire chip damage, XP-orb drops, and per-type break-synergy procs (Volatile Crystal afterburn, Comet Fragment speed boost, Drone Wreck invuln, Scrap Pile magnet pulse, Mineral Vein cascade, Supply Pod cascade, Magnetar Pulse gravity well).

OWNS

  • PropPool class — pool of 48 PropSlot entries (active, x, y, typeId, hp, lastHitT, bobPhase, bobAmp)
  • PROP_BREAK_AUDIO — per-prop-type break audio cue dispatch map (7 entries: scrap_pile, comet_fragment, drone_wreck, volatile_crystal, mineral_vein, supply_pod, magnetar_pulse)
  • Per-prop-type break-synergy procs (constants and spawnXxx methods):
    • Volatile Crystal afterburn — 25% chance, 180px proximity damage of 4, 14 fire particles in burning_aura palette
    • Comet Fragment speed boost — 35% chance, 1.5× current velocity capped at 2.0× maxSpeed, 14 trailing spark particles
    • Drone Wreck invuln — 30% chance, 0.5s invuln window via ship.invulnerable + invulnTimer, 12 cyan ring particles
    • Scrap Pile magnet pulse — 15% chance, calls xpOrbs.triggerGlobalMagnet(), 10 grey inward ring particles
    • Mineral Vein cascade — 30% chance, spawns comet_fragment + magnetar_pulse at 70px offsets, 18 green→blue bridge particles
    • Supply Pod cascade — 100% chance, fan-spawns 5 props (2×scrap_pile, mineral_vein, comet_fragment, magnetar_pulse) at 110px ring, 24 amber particles
    • Magnetar Pulse gravity well — 100% chance, 220px radius enemy pull at 360 px/s impulse + up to 28px proximity-scaled displacement, 24 violet inward swirl particles
  • forceSpawnAt(x, y, typeId): boolean — public bypass for spawn cadence/viewport/cone bias, used by cascade synergies
  • triggerSupplyPodCascade(x, y, types?): number — public; reused by boss-defeat hook and Vortex-style sub-events
  • triggerMagnetarPull(x, y, world): void — public thin pass-through wrapper (tick 62) for bridge.ts Vortex dispatch
  • forEachEdgeArrowProp(cb) — iterates active props with edgeArrow: true for HUD directional indicators
  • count(), clear(), update(dt, ship, camera_, world, gameState), draw(ctx)
  • Module-level singleton: getPropPool() (lazy) and _resetPropPoolForTests()
  • Per-slot bob animation (sampleBob using BOB_PERIOD_SEC = 8.0, amplitude 6–14)
  • Density mapping totalDensityForPlanet — reads PLANETS[planetId].destructibles.crates slider 0–100, applies MAX_DENSITY = 10 (half of crate cap)
  • Weighted rollPropType() using each type’s densityMult as the weight

READS FROM

  • data/props.tsPROP_TYPES, getPropType, PropTypeDef (catalog: id, radius, hp, densityMult, orbCount, orbXpFracPerOrb, emoji, rimColor, edgeArrow, vfx.sparkRgb/sparkCount/chunkRgb/ringColor/ringBurstCount)
  • data/planet-configPLANETS[planetId].destructibles.crates (0–100 slider, props piggyback on the crate slider)
  • engine/core/stateW, H, camera, game.uiTime, shared ship singleton
  • engine/rendering/cameraCamera.toS for world→screen projection
  • engine/world/levelingxpForLevel (drives perOrb XP via fraction-of-level-bar)
  • engine/affixes/palettegetAffixVfxColor('burning_aura') for Volatile Crystal afterburn fire color
  • world.playerBullets[]b.x, b.y, b.dmg, b.alive, b.pierce, b.pierceCount, b.hits Set (piercing-idempotency)
  • world.enemies[]e.x, e.y, e.vx, e.vy, e.alive (Magnetar gravity well)
  • ship.x/y/vx/vy/maxSpeed/invulnerable/invulnTimer/outerRadius/radius/alive (collision, speed boost, invuln synergies)

PUSHES TO

  • Sig.fire('prop_break', 0, 0, x, y, def.id) — broadcast on every break
  • Juice.fire(...)PROP_BREAK_AUDIO[def.id] per-type cue (fallback 'prop_break'), plus 'prop_hit' on chip, 'warp_collect' (comet boost), 'revive' (drone invuln), 'extraction_done' (scrap magnet), 'beacon_done' (mineral cascade), 'event_done' (supply pod cascade), 'comet_catch' (magnetar pull)
  • telemetry.recordDirectorPhase(...)prop_break:<id> with orbCount per break; plus proc-specific events: prop_break:volatile_afterburn_hit/_miss, prop_break:comet_speed_boost (resulting speed), prop_break:drone_invuln (ms), prop_break:scrap_magnet_pulse (orb count), prop_break:mineral_cascade (spawn count), prop_break:supply_pod_cascade (spawn count), prop_break:magnetar_pull (enemies pulled)
  • xpOrbs.spawn(x, y, vx, vy, value, radius) — drops def.orbCount orbs in a ring with random angular jitter; per-orb XP = ceil(barSize × def.orbXpFracPerOrb)
  • xpOrbs.triggerGlobalMagnet() — Scrap Pile magnet pulse proc
  • Particles.add(...) — break VFX layers (sparks/chunks), chip flash, and every per-type proc cloud
  • SonarRings.shockwave(x, y, 8, 80, def.vfx.ringColor, 0.3, 3.5) — rim flash on break
  • damagePlayer(sharedShip, 4, gameState, undefined, undefined, 'prop:volatile_afterburn') — Volatile Crystal proximity tick (respects invuln/shield/reduction)
  • Mutates sharedShip.vx/vy (comet boost) and sharedShip.invulnerable/invulnTimer (drone invuln)
  • Mutates enemy.vx/vy/x/y for each enemy inside the Magnetar pull radius

DOES NOT

  • Does not maintain timers or zone state for any synergy — every proc is a one-shot at break time; bleed-off is delegated to existing engine systems (drag for the comet boost, invulnTimer for drone invuln, AI overrides for magnetar enemy velocity, particle lifetimes for visuals)
  • Does not gate ship-contact break by hp (Slice 1: ship one-shots props regardless of hp); hp is only consumed by bullet hits
  • Does not route through the sprite-batch atlas — draw is Canvas 2D filled circle + emoji glyph (Slice 1 simplification)
  • Does not register a separate density slider — reuses destructibles.crates from planet-config with MAX_DENSITY = 10 (vs crate MAX_DENSITY = 20)
  • Does not visible-spawn — trySpawn rejects any candidate point inside the viewport; forceSpawnAt is the only path that bypasses the viewport gate
  • Does not chip a prop twice per frame with the same piercing bullet — uses b.hits Set for idempotency, same pattern as bullet→enemy hits
  • Does not cull on-screen props — runCull always preserves anything inside the viewport even if beyond CULL_RADIUS
  • Does not recurse Supply Pod cascades — SUPPLY_POD_CASCADE_TYPES deliberately omits supply_pod and drone_wreck/volatile_crystal (avoids infinite recursion and over-salted moments)
  • Does not silently swallow unknown typeIds in forceSpawnAtgetPropType throws (typo bug, not a runtime branch)
  • Does not abstract synergies behind a generic data-table flag — each proc is a named per-prop method so the relationship between two specific systems stays explicit

Signals

SignalDirectionPayload
prop_breakfire(0, 0, x, y, typeId) — broadcast every break
Audio (Juice.fire)fireper-type recipe via PROP_BREAK_AUDIO[id], falls back to 'prop_break'; plus per-proc cues (warp_collect, revive, extraction_done, beacon_done, event_done, comet_catch, prop_hit)
Telemetryfireprop_break:<id> + per-proc events (see PUSHES TO)

Entry points

  • getPropPool() — module-level lazy singleton; tests reset via _resetPropPoolForTests()
  • update(dt, ship, camera_, world, gameState) — per-frame; runs ship + bullet collision every frame, then throttles spawn/cull at SPAWN_TICK_MS = 250
  • draw(ctx) — Canvas 2D pass called from bridge.ts post-sticker layer
  • forceSpawnAt(x, y, typeId) — public; consumers: Mineral Vein cascade (private), triggerSupplyPodCascade (public boss-defeat / sub-event hook)
  • triggerSupplyPodCascade(x, y, types?) — public; reused by boss-defeat reward variants in encounter.ts with optional custom composition
  • triggerMagnetarPull(x, y, world) — public; reused by bridge.ts Vortex sub-event dispatch
  • forEachEdgeArrowProp(cb) — public; consumed by HUD edge-arrow renderer

Pattern notes

  • Pool size 48; density cap 10. Pool overhead is fixed; only 10 active at slider=100. Half the crate cap on purpose — props are visually richer than the 📦 sticker and over-density reads as visual noise.
  • Spawn cadence. Throttled to a 250ms tick; up to 6 spawn attempts per tick, each rejecting if the candidate point is in viewport, too close to another prop (MIN_GAP = 160), or the pool is full. Cull radius 2200px; on-screen props are never culled.
  • Velocity-cone bias. 70% of spawn rolls bias the candidate angle within a 0.55π cone in the ship’s heading direction; 30% spawn anywhere in a full circle. Forces most new props to appear in front of the player while still scattering some behind.
  • Weighted type roll. rollPropType() weights by densityMult from the catalog (Scrap Pile 1.0 dominates, Magnetar Pulse 0.20 is rarest).
  • Break order mirrors crates. signal → audio → telemetry → orb drops → break VFX → per-type proc → deactivate. Proc rolls run AFTER orbs and VFX so a failed proc doesn’t suppress the base reward.
  • Cross-system synergies are bespoke, not generic. Each of the 7 break-synergy procs is a named per-prop branch inside breakProp. Each reaches into a different engine subsystem: ship-state (comet velocity, drone invuln), enemy-velocity (magnetar), xp-orbs (scrap), prop spawn pipeline (mineral, supply pod), or proximity damage (volatile). Composability emerges from Supply Pod cascading 5 props each with their own break rolls — up to 6 synergies in one beat.
  • forceSpawnAt is the cascade primitive. Bypasses cadence, viewport gating, and velocity-cone bias. Consumers: Mineral Vein cascade (2-prop list), Supply Pod cascade (5-prop list). Returns false silently when the pool is at cap; telemetry records actual spawn count.
  • Bullet collision reuses the b.hits Set for piercing-bullet idempotency, identical to the bullet→enemy path. Non-piercing bullets die on first contact; piercing bullets decrement pierceCount and die when it hits 0.
  • Chip vs break. Bullet hits flash the rim (HIT_FLASH_DURATION = 0.18s, brightening pulse + radius bump) and spawn 6 chip sparks + the prop_hit cue. Only hp ≤ 0 triggers the full break sequence.
  • Bob animation uses un-dilated game.uiTime so props stay in visual sync with crates regardless of slow-mo / hitstop.
  • Visible-spawn ban. trySpawn rejects any candidate point inside the viewport. Players never see a prop pop in — they appear off-screen, drift in via camera motion. forceSpawnAt is the only sanctioned bypass (cascades intentionally burst on-screen).
  • Per-prop audio dispatch (PROP_BREAK_AUDIO). Tick 57 introduced flavored audio recipes per prop type as the 7th instance of the T19 dispatch-site override-map pattern. Each prop gets a recipe whose shape mirrors its character (light metal scatter for scrap, crystalline tinkle for comet, gravity-field collapse for magnetar).
  • Volatile Crystal afterburn is the canonical cross-system polish example. Reuses the burning_aura elite-affix palette (no new color), routes damage through damagePlayer (respects invuln/shield/reduction), and fail-silents if the affix palette has been removed.
  • Magnetar gravity pull is the only proc that mutates enemy state. Inward velocity impulse + proximity-scaled position displacement (max 28px, scales 1 - dist/radius). The position step guarantees a visible yank even when AI overrides velocity on the next frame.
  • Mineral cascade composition (tick 29 update). Was ['comet_fragment', 'comet_fragment']; one comet was swapped for a magnetar so mineral breaks reliably chain into both speed-boost AND gravity-pull opportunities.
  • Supply Pod composition (tick-22 update). Was 3×scrap_pile + 1 mineral_vein + 1 comet_fragment; one scrap was swapped for a magnetar so boss-tier loot (boss kill calls triggerSupplyPodCascade) includes the CC tool.