How to design a new passive

This guide walks through adding a new ship passive — a permanent, presence-only ability that ships in for the entire run as soon as the hull is selected.

Before you start

Passives are presence-only. They have no procs, no cooldowns, no trigger events, and no state. Each passive is a single stat modifier (percent or flat) applied at run start by applyPassives() in services/assembleRunService.ts.

Each ship hull has exactly one passive, referenced by passiveId on its ShipDef. The default baseline passiveId is jack_of_all. A passive’s magnitude scales with the ship’s rarity tier — the same passive on a Legendary hull is stronger than on a Common hull.

If your idea needs to react to events (on-hit, on-kill, on-low-HP, periodic ticks), it is not a passive. Use the authoring-new-artifact guide instead, or define an EffectDef and attach via the optional effects? field on PassiveDef (registered with EffectEngine at run start).

Step 1 — Identity

Decide the following fields:

FieldDescription
idUnique snake_case string. Matches the file name (kebab-case). E.g. void_siphon.
nameDisplay name (Title Case). E.g. Void Siphon.
descriptionDescription template. The literal {value} is replaced with the tier’s number at resolve time. E.g. +{value} HP regen/sec.
statThe combat stat key being modified. Must match a key the run assembler knows about.
isPercenttrue if the value is a percentage modifier, false if it is a flat additive number.

Existing stat keys in the 16 shipped passives:

Stat keyUsed byUnit
alljack_of_all%
hpMaxheavy_plating%
maxSpeedafterburner%
weaponDamagePctpredator_instinct% (flat add)
fireRatePctpayload_delivery% (flat add)
shieldMaxshield_capacitor%
thrustphase_drive%
damageReductionreinforced_hullflat
overheatCoolthermal_vents%
meleeMultram_prow%
shieldRegenRateorganic_regen%
turnSpeedprismatic_agility%
magnetRangetractor_array%
luckfortune_coreflat
currencyBonusprecision_salvage% (flat add)
hpRegenvoid_siphonflat

Step 2 — Write the data file

One file per passive under src/starship-survivors/data/passives/. Kebab-case file name, snake_case export. The export must be typed PassiveDef and the named export must match the file name (e.g. void-siphon.ts exports void_siphon).

PassiveDef fields:

FieldTypeNotes
idstringUnique. Used in passiveId references on ship defs.
namestringUI display name.
descriptionstringTemplate containing {value}.
statstringCombat stat key (see Step 1 table).
isPercentbooleantrue for %, false for flat.
valuesreadonly [number, number, number, number, number]Tier values indexed by RARITY_INDEX: [common, uncommon, rare, epic, legendary].
effects?EffectDef[]Optional. Only for unified-effect-engine integration. Leave omitted for pure stat passives.

Minimal example shape (matches jack-of-all.ts):

import type { PassiveDef } from './_types';
 
export const my_passive: PassiveDef = {
  id: 'my_passive',
  name: 'My Passive',
  description: '+{value}% to something',
  stat: 'someStatKey',
  isPercent: true,
  values: [5, 10, 16, 23, 32],
};

Step 3 — Scaling curves

All 16 shipped passives use one of six fixed 5-tier curves. Pick the curve that matches the strength budget of your stat — do not invent a new curve unless tuning explicitly requires it.

Curve[C, U, R, E, L]Used byWhen to use
Standard %[5, 10, 16, 23, 32]heavy_plating, afterburner, shield_capacitor, phase_drive, organic_regen, prismatic_agility, thermal_ventsMost defensive/utility % stats. The default.
Damage / fire rate %[4, 8, 13, 19, 28]predator_instinct, payload_deliveryDirect DPS multipliers. Slightly tighter than standard.
Economy / luck flat[3, 6, 10, 15, 22]jack_of_all, fortune_core, precision_salvageMultiplicative-feeling stats with broad reach.
Wide %[8, 15, 24, 35, 50]ram_prow, meleeMult; tractor_array, magnet rangeStats where large multipliers feel earned (niche or build-defining).
Flat DR[1, 2, 3, 5, 7]reinforced_hullFlat damage reduction subtracted from incoming hits.
HP regen flat[0.5, 1, 1.5, 2.5, 4]void_siphonSub-1.0 fractional baseline; flat HP/sec regen.

Step 4 — Register

Two edits in src/starship-survivors/data/passives/index.ts:

  1. Add an import { my_passive } from './my-passive'; line alongside the existing imports.
  2. Add my_passive, to the PASSIVES map.

That is the entire registration surface. getPassiveValue, getPassiveDescription, and resolvePassive automatically pick up new entries from the map.

Step 5 — Ship assignment

Attach the passive to one or more hulls by setting passiveId: 'my_passive' on the relevant ship def in src/starship-survivors/data/ships.ts.

Notes on assignment:

  • The default BASELINE_STATS.passiveId is 'jack_of_all'. Any hull without an explicit override inherits this.
  • Per-hull passiveId overrides live in the SHIP_STATS_S1[hull_class] records and are picked up by getShipDef() at lookup time (the s1Over?.passiveId branch).
  • Multiple hulls can share the same passiveId. The 5 ship rarities (Common → Legendary) automatically index the corresponding row in the passive’s values array via RARITY_INDEX, so a Common hull with void_siphon gets 0.5 HP/sec and a Legendary hull with the same passive gets 4 HP/sec.

Step 6 — Validate

After adding the file, registering it, and assigning it to a hull:

  1. The passive appears on the ship’s stat card in the meta UI with the correct name and tier value substituted into {value}.
  2. The effect magnitude scales with hull rarity — pick the same hull at Common vs. Legendary and confirm the value changes per the chosen curve.
  3. No TypeScript errors and no runtime errors at run start (getPassiveValue throws on unknown IDs, so a typo in passiveId will crash immediately).
  4. The stat key resolves correctly inside applyPassives() — if the stat key is unrecognized, the modifier silently does nothing, which is the one failure mode to watch for.

Custom-element structure rule

Passives are restricted to single-stat modifications. The system has no hook surface for procs, periodic ticks, or conditional triggers from inside PassiveDef.

If your design needs reactive behavior:

  • Trigger on game events (on-hit, on-kill, on-damage-taken, periodic interval): use the authoring-new-artifact guide. Artifacts have full effect-engine access and event subscriptions.
  • Stat change plus a small reactive bonus: attach an EffectDef[] via the optional effects? field on PassiveDef. The stat modifier still applies via applyPassives(), and the effects register with EffectEngine at run start in parallel.
  • Multi-stat bundle: use stat: 'all' like jack_of_all, which broadcasts the percentage to every stat. Do not stack multiple PassiveDef entries on a single hull — one passive per ship is a hard rule.