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.

FieldWhat it controlsReference values
idStable string id; used by spawner, telemetry, audio map, synergy switchscrap_pile, drone_wreck, comet_fragment, mineral_vein, volatile_crystal, supply_pod, magnetar_pulse
labelDebug HUD display (never user-facing)Scrap Pile, Drone Wreck
hpMax HP. Ship contact one-shots; bullets chip8 (comet) → 80 (supply pod)
radiusSilhouette radius for spawn placement + collision26 → 40
orbCountNumber of XP orbs dropped on break4 → 12
orbXpFracPerOrbPer-orb XP as fraction of current level bar0.015 → 0.035
densityMultWeight for both spawn rolls and density target (1.0 = same as workhorse)0.15 → 1.0
edgeArrowIf true, HUD draws off-screen directional arrowreserved for rare/iconic

Tier ladder reference (shipped catalog):

idhpradiusorbCountorbXp/orbdensityMultedgeArrow
scrap_pile103240.0181.0
drone_wreck183060.0200.6
comet_fragment82670.0150.4
mineral_vein4034100.0240.25
volatile_crystal253080.0220.35
supply_pod8040120.0350.15yes
magnetar_pulse353260.0200.20yes

Step 2: Write the data file

Append a PropTypeDef entry to PROP_TYPES in data/props.ts. Every field is required except edgeArrow.

FieldTypePurpose
idstringStable spawner/telemetry id; lookup throws on unknown
labelstringDebug-HUD label
hpnumberMax HP
radiusnumberSprite/collision radius
emojistringBaked sticker glyph drawn at Math.round(radius * 1.4)px
rimColorstring (hex)Inner-rim halo color around sticker
orbCountnumberXP orbs dropped on break
orbXpFracPerOrbnumberPer-orb XP as fraction of xpForLevel(level+1) - xpForLevel(level)
densityMultnumberSpawn weight and density-target multiplier (0–1)
vfx.sparkRgb[r,g,b]Spark-cloud color
vfx.chunkRgb[r,g,b]Chunky-debris color
vfx.ringColorstring (hex)Rim shockwave ring color
vfx.sparkCountnumberSpark-cloud particle count
vfx.ringBurstCountnumberExtra outward-ring burst particles (0 disables Layer 4)
edgeArrowboolean?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.

ConstantValuePurpose
POOL_SIZE48Max slots claimable across all prop types
MAX_DENSITY10Active props at planet destructibles.crates slider = 100
SPAWN_TICK_MS250Spawn-attempt cadence
SPAWN_MIN500Minimum off-screen spawn distance from ship
SPAWN_MAX1400Maximum off-screen spawn distance from ship
CULL_RADIUS2200Off-screen slots beyond this from ship are despawned
MIN_GAP160Rejection-sample distance from other active props
VIEWPORT_MARGIN80Extra px of “off-screen” before a spawn is allowed
VELOCITY_CONE_BIAS0.7Fraction of spawns biased into player’s heading cone
SPAWN_ATTEMPTS_PER_TICK6Rejection-sample retries per tick
BOB_AMP_MIN / BOB_AMP_MAX6 / 14Idle bob amplitude range
BOB_PERIOD_SEC8.0Idle 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.

StepCallNotes
1Sig.fire('prop_break', 0, 0, x, y, id)Cross-system signal; consumers include camera shake, scoring
2Juice.fire(PROP_BREAK_AUDIO[id] || 'prop_break')Per-type audio recipe with fallback to generic cue
3telemetry.recordDirectorPhase('prop_break:<id>', orbCount)Cloud telemetry
4spawnOrbs(x, y, def)Fan of orbCount orbs in even angular ring + jitter
5spawnBreakVfx(x, y, def)3-layer VFX (spark cloud + chunks + rim ring), optional Layer 4 if ringBurstCount > 0
6Per-type synergy branch (if Math.random() < CHANCE)One bespoke effect per type
7slot.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:

LayerSourceCountParticle
1 spark cloudvfx.sparkRgb + vfx.sparkCountsparkCountspeed 70–210, life 0.18–0.40s, size 1.2–2.8
2 chunk debrisvfx.chunkRgb5 (fixed)speed 30–90, life 0.4–0.7s, size 2.6
3 rim ringvfx.ringColor1 shockwaveSonarRings.shockwave(x, y, 8, 80, color, 0.3, 3.5)
4 ring burst (optional)vfx.sparkRgb + vfx.ringBurstCountringBurstCount (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'.

idRecipe key
scrap_pileprop_break_scrap_pile
drone_wreckprop_break_drone_wreck
comet_fragmentprop_break_comet_fragment
mineral_veinprop_break_mineral_vein
volatile_crystalprop_break_volatile_crystal
supply_podprop_break_supply_pod
magnetar_pulseprop_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.

PropSynergyReaches intoChanceKey effectKey constants
scrap_pileGlobal XP-orb magnet pulsexpOrbs.triggerGlobalMagnet15%All active orbs lock-on to player; grey inward ring around shipSCRAP_MAGNET_CHANCE = 0.15, SCRAP_MAGNET_PARTICLE_COUNT = 10
drone_wreckShip invuln windowship.invulnerable + ship.invulnTimer30%invulnerable = true, timer set to max of current and 0.5s; cyan ring on shipDRONE_INVULN_CHANCE = 0.30, DRONE_INVULN_DURATION = 0.5, DRONE_INVULN_PARTICLE_COUNT = 12
comet_fragmentShip velocity boostship.vx / ship.vy35%Multiply velocity by 1.5×, cap at 2.0× maxSpeed; ice-blue trail behind shipCOMET_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_veinCascade-spawn comet + magnetarforceSpawnAt30%Fan-spawn MINERAL_CASCADE_TYPES at offset radius; green→blue bridge cloudMINERAL_CASCADE_CHANCE = 0.30, MINERAL_CASCADE_TYPES = ['comet_fragment', 'magnetar_pulse'], MINERAL_CASCADE_SPAWN_OFFSET_PX = 70, MINERAL_CASCADE_PARTICLE_COUNT = 18
volatile_crystalBurning-aura afterburndamagePlayer + burning_aura palette25%Proximity damage if ship within radius; reuses burning_aura affix paletteVOLATILE_AFTERBURN_CHANCE = 0.25, VOLATILE_AFTERBURN_RADIUS_PX = 180, VOLATILE_AFTERBURN_DAMAGE = 4, VOLATILE_AFTERBURN_PARTICLE_COUNT = 14
supply_podMulti-prop loot cascadeforceSpawnAt (5 props on a ring)100%Fan-spawn SUPPLY_POD_CASCADE_TYPES around break; amber burst cloudSUPPLY_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_pulseEnemy gravity-pull fieldworld.enemies vx/vy + position100%Inward velocity impulse + proximity-scaled position displacement on enemies in radius; violet swirlMAGNETAR_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:

SynergyTelemetry phase keyValue recorded
Volatile hitprop_break:volatile_afterburn_hitdamage applied
Volatile missprop_break:volatile_afterburn_miss0
Comet boostprop_break:comet_speed_boostpost-boost speed
Drone invulnprop_break:drone_invulnduration ms
Scrap magnetprop_break:scrap_magnet_pulseactive orb count
Mineral cascadeprop_break:mineral_cascadespawned count
Supply cascadeprop_break:supply_pod_cascadespawned count
Magnetar pullprop_break:magnetar_pullenemies 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.

PropsparkRgbchunkRgbringColorsparkCountringBurstCount
scrap_pile[220,220,220][110,110,110]#cfd6df140
drone_wreck[120,220,255][80,120,160]#7fd5ff180
comet_fragment[220,235,255][150,180,220]#dfeaff208
mineral_vein[120,230,130][60,140,80]#7fe89a2410
volatile_crystal[255,100,220][180,60,180]#ff3fb02218
supply_pod[255,200,100][200,150,80]#ffcc443014
magnetar_pulse[180,110,255][110,50,200]#a060ff2616

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:

CheckExpected behavior
Spawns off-screen on configured planetsNew prop appears in zones where destructibles.crates > 0, never inside the viewport
Player contact one-shotsShip ramming the prop triggers the full break sequence
Bullets chip HPEach 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 emitsorbCount orbs spawn in an even ring around break point
Audio fires per idConsole-free check: PROP_BREAK_AUDIO[id] recipe exists in micro-sfx.ts, otherwise falls back to prop_break
Synergy procs at expected rateTelemetry prop_break:<id> increments per break; synergy-specific phase keys increment per proc
No unknown-id throwsgetPropType(newId) returns the new def, no other code path passes a stale id
Pool cap holdsActive count never exceeds POOL_SIZE = 48 even under cascade chains
Edge arrow rendersIf 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 intoExisting exampleReuse for
Ship state (vx/vy, invuln)comet boost, drone invulnAny “buff lands on player” effect
XP orbsscrap magnetAnything that operates on the existing orb field
Prop pool (forceSpawnAt)mineral cascade, supply pod cascadeMulti-prop spawn rings
Enemy state (vx/vy, position)magnetar pullCrowd-control / displacement effects
Damage system (damagePlayer)volatile afterburnCross-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.