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
PropPoolclass — pool of 48PropSlotentries (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
spawnXxxmethods):- Volatile Crystal afterburn — 25% chance, 180px proximity damage of 4, 14 fire particles in
burning_aurapalette - 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_pulseat 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
- Volatile Crystal afterburn — 25% chance, 180px proximity damage of 4, 14 fire particles in
forceSpawnAt(x, y, typeId): boolean— public bypass for spawn cadence/viewport/cone bias, used by cascade synergiestriggerSupplyPodCascade(x, y, types?): number— public; reused by boss-defeat hook and Vortex-style sub-eventstriggerMagnetarPull(x, y, world): void— public thin pass-through wrapper (tick 62) for bridge.ts Vortex dispatchforEachEdgeArrowProp(cb)— iterates active props withedgeArrow: truefor HUD directional indicatorscount(),clear(),update(dt, ship, camera_, world, gameState),draw(ctx)- Module-level singleton:
getPropPool()(lazy) and_resetPropPoolForTests() - Per-slot bob animation (
sampleBobusingBOB_PERIOD_SEC = 8.0, amplitude 6–14) - Density mapping
totalDensityForPlanet— readsPLANETS[planetId].destructibles.cratesslider 0–100, appliesMAX_DENSITY = 10(half of crate cap) - Weighted
rollPropType()using each type’sdensityMultas the weight
READS FROM
data/props.ts—PROP_TYPES,getPropType,PropTypeDef(catalog:id,radius,hp,densityMult,orbCount,orbXpFracPerOrb,emoji,rimColor,edgeArrow,vfx.sparkRgb/sparkCount/chunkRgb/ringColor/ringBurstCount)data/planet-config—PLANETS[planetId].destructibles.crates(0–100 slider, props piggyback on the crate slider)engine/core/state—W,H,camera,game.uiTime, sharedshipsingletonengine/rendering/camera—Camera.toSfor world→screen projectionengine/world/leveling—xpForLevel(drivesperOrbXP via fraction-of-level-bar)engine/affixes/palette—getAffixVfxColor('burning_aura')for Volatile Crystal afterburn fire colorworld.playerBullets[]—b.x,b.y,b.dmg,b.alive,b.pierce,b.pierceCount,b.hitsSet (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 breakJuice.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)— dropsdef.orbCountorbs in a ring with random angular jitter; per-orb XP =ceil(barSize × def.orbXpFracPerOrb)xpOrbs.triggerGlobalMagnet()— Scrap Pile magnet pulse procParticles.add(...)— break VFX layers (sparks/chunks), chip flash, and every per-type proc cloudSonarRings.shockwave(x, y, 8, 80, def.vfx.ringColor, 0.3, 3.5)— rim flash on breakdamagePlayer(sharedShip, 4, gameState, undefined, undefined, 'prop:volatile_afterburn')— Volatile Crystal proximity tick (respects invuln/shield/reduction)- Mutates
sharedShip.vx/vy(comet boost) andsharedShip.invulnerable/invulnTimer(drone invuln) - Mutates
enemy.vx/vy/x/yfor 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,
invulnTimerfor 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);hpis 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.cratesfrom planet-config withMAX_DENSITY = 10(vs crateMAX_DENSITY = 20) - Does not visible-spawn —
trySpawnrejects any candidate point inside the viewport;forceSpawnAtis the only path that bypasses the viewport gate - Does not chip a prop twice per frame with the same piercing bullet — uses
b.hitsSet for idempotency, same pattern as bullet→enemy hits - Does not cull on-screen props —
runCullalways preserves anything inside the viewport even if beyondCULL_RADIUS - Does not recurse Supply Pod cascades —
SUPPLY_POD_CASCADE_TYPESdeliberately omitssupply_podanddrone_wreck/volatile_crystal(avoids infinite recursion and over-salted moments) - Does not silently swallow unknown typeIds in
forceSpawnAt—getPropTypethrows (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
| Signal | Direction | Payload |
|---|---|---|
prop_break | fire | (0, 0, x, y, typeId) — broadcast every break |
Audio (Juice.fire) | fire | per-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) |
| Telemetry | fire | prop_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 atSPAWN_TICK_MS = 250draw(ctx)— Canvas 2D pass called frombridge.tspost-sticker layerforceSpawnAt(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 inencounter.tswith optional custom compositiontriggerMagnetarPull(x, y, world)— public; reused bybridge.tsVortex sub-event dispatchforEachEdgeArrowProp(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 bydensityMultfrom 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. forceSpawnAtis 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.hitsSet for piercing-bullet idempotency, identical to the bullet→enemy path. Non-piercing bullets die on first contact; piercing bullets decrementpierceCountand 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 + theprop_hitcue. Onlyhp ≤ 0triggers the full break sequence. - Bob animation uses un-dilated
game.uiTimeso props stay in visual sync with crates regardless of slow-mo / hitstop. - Visible-spawn ban.
trySpawnrejects any candidate point inside the viewport. Players never see a prop pop in — they appear off-screen, drift in via camera motion.forceSpawnAtis 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_auraelite-affix palette (no new color), routes damage throughdamagePlayer(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.