How to design a new prop
Step-by-step guide for adding a new destructible world prop (Scrap Pile, Drone Wreck, etc.) to the shared prop pool.
Before you start
Props are breakable world objects that join the off-screen spawn loop alongside crates. Player ship contact instantly breaks them (one-shot, max-hp damage in a single tick). Player weapon bullets chip HP per hit and trigger the chip-flash VFX until HP drops to zero. On break, every prop fires a deterministic sequence (signal → audio → telemetry → orb fan → break VFX → optional per-type synergy → slot deactivate). Each type can carry one bespoke on-break synergy that reaches into another subsystem (ship state, XP orbs, enemy velocity, or the prop pool itself).
All props share one PropPool with a fixed slot count, one density slider, one spawn cadence, and one weighted-roll spawn pipeline. Adding a new prop = one entry in PROP_TYPES + (if it has a custom synergy) one new branch in breakProp + one constants block.
Step 1: Identity
Pick the load-bearing identity values up front. These determine where the prop sits in the tier ladder.
| Field | What it controls | Reference values |
|---|---|---|
id | Stable string id; used by spawner, telemetry, audio map, synergy switch | scrap_pile, drone_wreck, comet_fragment, mineral_vein, volatile_crystal, supply_pod, magnetar_pulse |
label | Debug HUD display (never user-facing) | Scrap Pile, Drone Wreck |
hp | Max HP. Ship contact one-shots; bullets chip | 8 (comet) → 80 (supply pod) |
radius | Silhouette radius for spawn placement + collision | 26 → 40 |
orbCount | Number of XP orbs dropped on break | 4 → 12 |
orbXpFracPerOrb | Per-orb XP as fraction of current level bar | 0.015 → 0.035 |
densityMult | Weight for both spawn rolls and density target (1.0 = same as workhorse) | 0.15 → 1.0 |
edgeArrow | If true, HUD draws off-screen directional arrow | reserved for rare/iconic |
Tier ladder reference (shipped catalog):
| id | hp | radius | orbCount | orbXp/orb | densityMult | edgeArrow |
|---|---|---|---|---|---|---|
scrap_pile | 10 | 32 | 4 | 0.018 | 1.0 | — |
drone_wreck | 18 | 30 | 6 | 0.020 | 0.6 | — |
comet_fragment | 8 | 26 | 7 | 0.015 | 0.4 | — |
mineral_vein | 40 | 34 | 10 | 0.024 | 0.25 | — |
volatile_crystal | 25 | 30 | 8 | 0.022 | 0.35 | — |
supply_pod | 80 | 40 | 12 | 0.035 | 0.15 | yes |
magnetar_pulse | 35 | 32 | 6 | 0.020 | 0.20 | yes |
Step 2: Write the data file
Append a PropTypeDef entry to PROP_TYPES in data/props.ts. Every field is required except edgeArrow.
| Field | Type | Purpose |
|---|---|---|
id | string | Stable spawner/telemetry id; lookup throws on unknown |
label | string | Debug-HUD label |
hp | number | Max HP |
radius | number | Sprite/collision radius |
emoji | string | Baked sticker glyph drawn at Math.round(radius * 1.4)px |
rimColor | string (hex) | Inner-rim halo color around sticker |
orbCount | number | XP orbs dropped on break |
orbXpFracPerOrb | number | Per-orb XP as fraction of xpForLevel(level+1) - xpForLevel(level) |
densityMult | number | Spawn weight and density-target multiplier (0–1) |
vfx.sparkRgb | [r,g,b] | Spark-cloud color |
vfx.chunkRgb | [r,g,b] | Chunky-debris color |
vfx.ringColor | string (hex) | Rim shockwave ring color |
vfx.sparkCount | number | Spark-cloud particle count |
vfx.ringBurstCount | number | Extra outward-ring burst particles (0 disables Layer 4) |
edgeArrow | boolean? | If true, HUD draws off-screen arrow toward this prop |
getPropType(id) throws on unknown id — typo’d ids are bugs, not silent fallbacks.
Step 3: Spawn pool
All props share the single PropPool declared in engine/world/props.ts. Tuning constants are pool-wide, not per type — your prop inherits them automatically.
| Constant | Value | Purpose |
|---|---|---|
POOL_SIZE | 48 | Max slots claimable across all prop types |
MAX_DENSITY | 10 | Active props at planet destructibles.crates slider = 100 |
SPAWN_TICK_MS | 250 | Spawn-attempt cadence |
SPAWN_MIN | 500 | Minimum off-screen spawn distance from ship |
SPAWN_MAX | 1400 | Maximum off-screen spawn distance from ship |
CULL_RADIUS | 2200 | Off-screen slots beyond this from ship are despawned |
MIN_GAP | 160 | Rejection-sample distance from other active props |
VIEWPORT_MARGIN | 80 | Extra px of “off-screen” before a spawn is allowed |
VELOCITY_CONE_BIAS | 0.7 | Fraction of spawns biased into player’s heading cone |
SPAWN_ATTEMPTS_PER_TICK | 6 | Rejection-sample retries per tick |
BOB_AMP_MIN / BOB_AMP_MAX | 6 / 14 | Idle bob amplitude range |
BOB_PERIOD_SEC | 8.0 | Idle bob period |
Density-target math: Math.round((planet.destructibles.crates / 100) * MAX_DENSITY). Props share the same slider as crates (no separate slider).
Spawn weight math: rollPropType() sums every type’s densityMult and rolls proportionally. Your new prop’s densityMult is BOTH the spawn-roll weight AND the density-target multiplier — there’s no separate weight field. A densityMult: 1.0 prop dominates the field; 0.15 makes up roughly 10% of spawns.
Step 4: Break sequence
Every break runs the same deterministic sequence in breakProp. Order is load-bearing — synergies fire AFTER orbs/VFX so they can read final positions.
| Step | Call | Notes |
|---|---|---|
| 1 | Sig.fire('prop_break', 0, 0, x, y, id) | Cross-system signal; consumers include camera shake, scoring |
| 2 | Juice.fire(PROP_BREAK_AUDIO[id] || 'prop_break') | Per-type audio recipe with fallback to generic cue |
| 3 | telemetry.recordDirectorPhase('prop_break:<id>', orbCount) | Cloud telemetry |
| 4 | spawnOrbs(x, y, def) | Fan of orbCount orbs in even angular ring + jitter |
| 5 | spawnBreakVfx(x, y, def) | 3-layer VFX (spark cloud + chunks + rim ring), optional Layer 4 if ringBurstCount > 0 |
| 6 | Per-type synergy branch (if Math.random() < CHANCE) | One bespoke effect per type |
| 7 | slot.active = false; activeCount-- | Slot returns to pool |
Per-orb XP: Math.max(1, Math.ceil(barSize * orbXpFracPerOrb)) where barSize = xpForLevel(level+1) - xpForLevel(level).
Break VFX layers:
| Layer | Source | Count | Particle |
|---|---|---|---|
| 1 spark cloud | vfx.sparkRgb + vfx.sparkCount | sparkCount | speed 70–210, life 0.18–0.40s, size 1.2–2.8 |
| 2 chunk debris | vfx.chunkRgb | 5 (fixed) | speed 30–90, life 0.4–0.7s, size 2.6 |
| 3 rim ring | vfx.ringColor | 1 shockwave | SonarRings.shockwave(x, y, 8, 80, color, 0.3, 3.5) |
| 4 ring burst (optional) | vfx.sparkRgb + vfx.ringBurstCount | ringBurstCount (skipped if 0) | speed 200–260, life 0.5–0.7s, size 1.8 |
Audio map (PROP_BREAK_AUDIO): every prop id maps to a flavored recipe key; missing keys fall back to 'prop_break'.
| id | Recipe key |
|---|---|
scrap_pile | prop_break_scrap_pile |
drone_wreck | prop_break_drone_wreck |
comet_fragment | prop_break_comet_fragment |
mineral_vein | prop_break_mineral_vein |
volatile_crystal | prop_break_volatile_crystal |
supply_pod | prop_break_supply_pod |
magnetar_pulse | prop_break_magnetar_pulse |
Step 5: Per-type synergy
Each shipped type has one bespoke on-break synergy that reaches into another subsystem. Pattern: gate on def.id === '<id>' && Math.random() < CHANCE, then call a private helper. Synergies compose — a Supply Pod cascade can chain up to 6 synergies in one beat because the cascaded props each respect their own break rolls.
| Prop | Synergy | Reaches into | Chance | Key effect | Key constants |
|---|---|---|---|---|---|
scrap_pile | Global XP-orb magnet pulse | xpOrbs.triggerGlobalMagnet | 15% | All active orbs lock-on to player; grey inward ring around ship | SCRAP_MAGNET_CHANCE = 0.15, SCRAP_MAGNET_PARTICLE_COUNT = 10 |
drone_wreck | Ship invuln window | ship.invulnerable + ship.invulnTimer | 30% | invulnerable = true, timer set to max of current and 0.5s; cyan ring on ship | DRONE_INVULN_CHANCE = 0.30, DRONE_INVULN_DURATION = 0.5, DRONE_INVULN_PARTICLE_COUNT = 12 |
comet_fragment | Ship velocity boost | ship.vx / ship.vy | 35% | Multiply velocity by 1.5×, cap at 2.0× maxSpeed; ice-blue trail behind ship | COMET_SPEED_BOOST_CHANCE = 0.35, COMET_SPEED_BOOST_MULT = 1.5, COMET_SPEED_BOOST_MAX_RATIO = 2.0, COMET_SPEED_BOOST_PARTICLE_COUNT = 14 |
mineral_vein | Cascade-spawn comet + magnetar | forceSpawnAt | 30% | Fan-spawn MINERAL_CASCADE_TYPES at offset radius; green→blue bridge cloud | MINERAL_CASCADE_CHANCE = 0.30, MINERAL_CASCADE_TYPES = ['comet_fragment', 'magnetar_pulse'], MINERAL_CASCADE_SPAWN_OFFSET_PX = 70, MINERAL_CASCADE_PARTICLE_COUNT = 18 |
volatile_crystal | Burning-aura afterburn | damagePlayer + burning_aura palette | 25% | Proximity damage if ship within radius; reuses burning_aura affix palette | VOLATILE_AFTERBURN_CHANCE = 0.25, VOLATILE_AFTERBURN_RADIUS_PX = 180, VOLATILE_AFTERBURN_DAMAGE = 4, VOLATILE_AFTERBURN_PARTICLE_COUNT = 14 |
supply_pod | Multi-prop loot cascade | forceSpawnAt (5 props on a ring) | 100% | Fan-spawn SUPPLY_POD_CASCADE_TYPES around break; amber burst cloud | SUPPLY_POD_CASCADE_CHANCE = 1.0, SUPPLY_POD_CASCADE_TYPES = ['scrap_pile','scrap_pile','mineral_vein','comet_fragment','magnetar_pulse'], SUPPLY_POD_CASCADE_RING_PX = 110, SUPPLY_POD_CASCADE_PARTICLE_COUNT = 24 |
magnetar_pulse | Enemy gravity-pull field | world.enemies vx/vy + position | 100% | Inward velocity impulse + proximity-scaled position displacement on enemies in radius; violet swirl | MAGNETAR_PULL_CHANCE = 1.0, MAGNETAR_PULL_RADIUS = 220, MAGNETAR_PULL_SPEED = 360, MAGNETAR_PULL_MAX_DISPLACEMENT = 28, MAGNETAR_PULL_PARTICLE_COUNT = 24 |
Synergy procs also emit their own telemetry phase keys so cloud tuning can correlate proc rate with reward density:
| Synergy | Telemetry phase key | Value recorded |
|---|---|---|
| Volatile hit | prop_break:volatile_afterburn_hit | damage applied |
| Volatile miss | prop_break:volatile_afterburn_miss | 0 |
| Comet boost | prop_break:comet_speed_boost | post-boost speed |
| Drone invuln | prop_break:drone_invuln | duration ms |
| Scrap magnet | prop_break:scrap_magnet_pulse | active orb count |
| Mineral cascade | prop_break:mineral_cascade | spawned count |
| Supply cascade | prop_break:supply_pod_cascade | spawned count |
| Magnetar pull | prop_break:magnetar_pull | enemies pulled |
Public cascade entry points: triggerSupplyPodCascade(x, y, types?) and triggerMagnetarPull(x, y, world) are exposed on the pool so external systems (boss defeats, bridge sub-events) can reuse the same loot/CC beats without duplicating the loop body.
Step 6: VFX palette
Every break VFX layer reads colors from the per-type vfx block. Pick a palette that reads at a glance against the dark space background and against other props’ palettes.
| Prop | sparkRgb | chunkRgb | ringColor | sparkCount | ringBurstCount |
|---|---|---|---|---|---|
scrap_pile | [220,220,220] | [110,110,110] | #cfd6df | 14 | 0 |
drone_wreck | [120,220,255] | [80,120,160] | #7fd5ff | 18 | 0 |
comet_fragment | [220,235,255] | [150,180,220] | #dfeaff | 20 | 8 |
mineral_vein | [120,230,130] | [60,140,80] | #7fe89a | 24 | 10 |
volatile_crystal | [255,100,220] | [180,60,180] | #ff3fb0 | 22 | 18 |
supply_pod | [255,200,100] | [200,150,80] | #ffcc44 | 30 | 14 |
magnetar_pulse | [180,110,255] | [110,50,200] | #a060ff | 26 | 16 |
rimColor (separate from VFX) drives the always-on halo around the sticker plus the chip-flash brighten on bullet hits. Use the same hue family as sparkRgb so the prop reads identical before, during, and after damage.
Step 7: Edge arrows
Set edgeArrow: true to opt into the HUD’s off-screen directional indicator. The pool exposes forEachEdgeArrowProp(cb) which the HUD layer calls every frame; the callback gets (x, y, typeId) so the HUD picks the per-type arrow color without re-importing the catalog. Currently only supply_pod and magnetar_pulse opt in — reserve this flag for rare, high-value tiers where finding the prop is the goal.
Step 8: Validate
After editing data/props.ts (and, if your prop has a custom synergy, engine/world/props.ts), confirm:
| Check | Expected behavior |
|---|---|
| Spawns off-screen on configured planets | New prop appears in zones where destructibles.crates > 0, never inside the viewport |
| Player contact one-shots | Ship ramming the prop triggers the full break sequence |
| Bullets chip HP | Each player bullet reduces hp by bullet.dmg, fires chip VFX (CHIP_SPARK_COUNT = 6, HIT_FLASH_DURATION = 0.18s), piercing bullets only hit once per prop |
| Orb fan emits | orbCount orbs spawn in an even ring around break point |
| Audio fires per id | Console-free check: PROP_BREAK_AUDIO[id] recipe exists in micro-sfx.ts, otherwise falls back to prop_break |
| Synergy procs at expected rate | Telemetry prop_break:<id> increments per break; synergy-specific phase keys increment per proc |
| No unknown-id throws | getPropType(newId) returns the new def, no other code path passes a stale id |
| Pool cap holds | Active count never exceeds POOL_SIZE = 48 even under cascade chains |
| Edge arrow renders | If edgeArrow: true, HUD shows off-screen indicator that fades when the prop enters view |
Custom-element structure rule
Reuse the existing synergy patterns whenever possible. The seven shipped synergies cover four reach-into surfaces:
| Reaches into | Existing example | Reuse for |
|---|---|---|
| Ship state (vx/vy, invuln) | comet boost, drone invuln | Any “buff lands on player” effect |
| XP orbs | scrap magnet | Anything that operates on the existing orb field |
Prop pool (forceSpawnAt) | mineral cascade, supply pod cascade | Multi-prop spawn rings |
| Enemy state (vx/vy, position) | magnetar pull | Crowd-control / displacement effects |
Damage system (damagePlayer) | volatile afterburn | Cross-system “hazard” reads |
If the new synergy fits one of these patterns, mirror the matching helper’s structure (proc-roll gate → Juice.fire of a reused audio recipe → particle layer + state mutation → telemetry phase key) and avoid introducing new primitives. Only add a new switch branch in breakProp AND a new private helper when the synergy genuinely engages a subsystem none of the existing seven touches (e.g. spawning a projectile, opening a portal, mutating world geometry). Every helper must respect existing engine gates (invuln chain via damagePlayer, density cap via forceSpawnAt’s false return) — never bypass them.