How to Design a New Artifact

Before you start

An artifact is an alien pickup that gives the player a permanent in-run buff — either a passive flat stat ramp (every artifact has one), an effect that fires on a signal, an autonomous tick handler, or some combination. Most artifacts are pure data: one .ts file under src/starship-survivors/data/artifacts/, plus one line in the barrel.

The engine that runs artifact effects is the EffectEngine (src/starship-survivors/engine/effects/effect-engine.ts). It is signal-driven, data-only, and shared with passives and ship mods. Artifacts compose from a fixed palette of registered conditions and actions; you almost never write new engine code.

Pick the artifact’s shape first — that drives everything else:

ShapeWhat you writeExample
Flat stat boost onlydata file with no effects array, just a flat-bonus mappingsympathetic_resonance (effect lives in leveling.ts, see step 7)
Signal-keyed triggerdata file with one EffectDef, trigger.type: 'signal'bloodlust, chain_reaction, refresh
Always-on tick handlerdata file with trigger.type: 'signal' (signal run_start) + custom_tick actioncompanion_droid, force_field
Timer pulsedata file with trigger.type: 'timer', optional timerRepeatheat_racer
Counter (every Nth signal)data file with trigger.type: 'counter'weapon-fire counter triple (overdrive, slipstream, hunter)
Custom logic that doesn’t composedata file + a registered custom action handlerchain_reaction-class actions, lingering_flames_zone

If you cannot express the artifact as conditions + actions from the registry, you are in Step 5: Custom handlers territory.

Step 1: Pick the artifact’s identity

Settle these before opening a code editor:

FieldWhat it controls
id (snake_case)Lookup key. Must match the filename slug and the ARTIFACT_FLAT_BONUSES entry.
nameDisplay name on the reward card.
descriptionOne-line flavor on the reward card.
iconSingle emoji glyph rendered on the card.
categoryunique, stat, or weapon. unique = bespoke triggered effects, stat = stat-mod / signal triggers, weapon = autonomous entity on a tick handler.
colors{ primary, secondary, bright } hex strings. Drives HUD ring, particles, projectiles, proc VFX.
damageTag (optional)One of bullet / energy / fire / bomb. Shown as a colored pill on the reward card. Omit for type-agnostic artifacts. See concept-damage-tags.
procAudioCue (optional)Name of a RECIPES key in engine/audio/micro-sfx.ts. Plays whenever flash_artifact fires. Common-trigger procs should have flavored audio per the t66 directive.
Trigger conditionalways-on (tick handler), signal (one of the engine signals), event (signal fired by a mission event), timer, or counter.

Runtime tiers run 0 → 4 (common → uncommon → rare → epic → legendary). The data file stores a 4-tuple tiers indexed 0 = uncommon … 3 = legendary. Common is synthesized at runtime by getTierValuesAt() — it reuses uncommon’s values record and differentiates only via the flat-bonus ramp (see step 7).

Runtime tier idxNameTier color hexFlat-bonus %
0common#9d9d9d10
1uncommon#1eff0020
2rare#0070dd30
3epic#a335ee40
4legendary#ff800050

Step 2: Write the data file

One .ts file per artifact under src/starship-survivors/data/artifacts/. Filename = id with hyphens (bloodlust.ts, chain-reaction.ts). Export one named ArtifactDef constant matching the id (export const bloodlust: ArtifactDef = {...}).

ArtifactDef fields:

FieldTypeRequiredNotes
idstringyesUnique snake_case ID. Must equal the filename slug.
namestringyesReward-card display name.
descriptionstringyesFlavor sentence on the reward card.
iconstringyesSingle emoji.
category'unique' | 'stat' | 'weapon'yesCategorization.
colors{ primary, secondary, bright }yesHex strings (#rrggbb). Drive every visual cue.
tiers4-tuple of { label, values }yesIndexed 0-3 = uncommon → legendary. values is a flat number dict. Common reuses index 0 at runtime.
damageTag'bullet' | 'energy' | 'fire' | 'bomb'noPill on reward card.
effectsEffectDef[]noEffect definitions for the unified engine. Omit for flat-bonus-only artifacts.
procAudioCuestringnomicro-sfx.ts RECIPES key fired on every flash_artifact proc.

A minimal flat-bonus-only artifact has no effects array at all. A minimal signal-trigger artifact has exactly one EffectDef. Long-form artifacts (event_reward, bloodlust) often have one effect with several actions.

Step 3: Effects authoring

An EffectDef is the unit the engine schedules. Fields:

FieldTypeNotes
idstringUnique within the artifact (e.g. bloodlust_trigger).
triggerTriggerDefWhat activates the effect.
conditionsConditionDef[]All must pass (AND). Empty array = always passes.
actionsActionDef[]Executed in order when trigger + conditions pass.
cooldownnumber | stringSeconds between activations. 0 or omitted = no cooldown. Supports $var.
chargesnumberMax activations per run. -1 = unlimited.
prioritynumberSignal-listener priority band. Default 50 (effects).
showBannerbooleanWhen true, push an “[Artifact] activated” banner each proc. Throttled to 1 per artifact per 0.5 s. Leave off for per-hit / aura / run_start.

Trigger types

trigger.typeActivationRequired fields
signalFires when a named Sig signal is emittedsignal (string name)
run_startFires once after all effects register at run start
auraContinuously checks conditions; applies / removes modifiers on transitionauraInterval (sec, default 0.5)
timerFires after N seconds, optionally repeating, optionally reset by a signaltimerDuration, timerRepeat?, timerResetSignal?
counterFires after counting N occurrences of a signalcounterSignal, counterThreshold, counterResets? (default true)

$var parameter substitution

Any action or condition param expressed as a string starting with $ resolves against the effect instance’s values dict (i.e. the artifact’s tiers[idx].values). Example: value: '$luckBonus' in a modify_stat action reads luckBonus from the current-tier values record. Numeric and string literals pass through unchanged.

Registered conditions

All conditions evaluate to true/false. All must pass for actions to fire. Evaluation is cheapest-first — put random and signal_* checks before HP/stat lookups.

condition.typeParamsWhat it checks
randomchance (0-1)Math.random() < chance
signal_str1_eqvalueSnapshot’s str1 equals value
signal_str1_neqvalueSnapshot’s str1 does NOT equal value
signal_num1_gtevalueSnapshot’s num1 >= value
damage_tag_eqtagSnapshot’s str1 equals tag (bullet / energy / fire / bomb)
hp_belowthreshold (0-1)Ship HP fraction below threshold
hp_abovethreshold (0-1)Ship HP fraction above threshold
shield_activeShip shield > 0
shield_brokenShip shield ≤ 0
shield_emptyShip shield ≤ 0 (alias)
has_artifactartifactIdPlayer owns the named artifact
has_upgradeupgradeId, minLevelPlayer has ≥ minLevel of the upgrade
has_buffsourceAny modifier with that source tag is active
kill_streak_abovethresholdGame kill streak ≥ threshold
elapsed_abovesecondsGame time ≥ seconds
elapsed_belowsecondsGame time < seconds
tier_at_leasttierMission tier ≥ tier
heat_abovethresholdShip heat ≥ threshold
speed_abovethresholdShip speed ≥ threshold
speed_belowthresholdShip speed < threshold
boss_activeA boss arena is currently active

Registered actions

Actions run in order on each trigger. Numeric params resolve via resolveNumber (supports $var); string params via resolveString.

action.typeKey paramsWhat it does
modify_statstat, value, mode (flat/percent/set), duration, source, stacking (independent/refresh), maxStacksAdds a stat modifier via Modifiers.add. Source tag drives cleanup.
modify_stat_scaledstat, mode, scaleVar (heat/speed/hp_pct/shield_pct), scaleMin, scaleMax, valueAtMin, valueAtMax, sourceLinear-interpolated mod that re-evaluates each aura tick.
modify_upgrade_countupgradeId, deltaAdjusts game.upgradeCounts[upgradeId].
remove_modifierssourceRemoves all mods with matching source tag from the ship.
healamount, mode (flat/percentMax/percentMissing)Restores ship HP. Fires player_healed signal.
heal_shieldamount, mode (flat/percentMax)Restores ship shield.
grant_invulndurationSets ship.invulnerable true for duration.
set_heatvalueSets ship.heat.
damage_aoedamage, radius, damageMode (flat/hpPct), center (ship/signal), falloff (linear), falloffFarAoE damage to all enemies within radius of center.
apply_knockbackforce, radius, center (ship/signal)Adds radial velocity to all enemies in radius. Skips pack leaders.
spawn_projectilecount, damage, speed, lifetime, spread, pierceCount, homingStrength, originX (ship/signal), color, weaponId, archetype, blastRadiusPushes one or more bullets into world.playerBullets.
apply_enemy_statusstatus (stunned/shredded/burning), duration, value, maxStacksApplies status to enemy identified by signal uid1.
apply_status_aoestatus, duration, value, radius, maxStacks, center (ship/signal)Applies status to all enemies in radius.
empower_weapontarget (random/slot_0/all), damageMult, shotsRemainingSets _empowerMult / _empowerShots on a weapon.
emit_signalsignal, str1, num1, num2Fires a Sig.fire — used to chain effects.
flash_artifactTriggers the HUD icon flash, double sonar ring, particle burst, player glow, and (if procAudioCue is set) the audio cue. Use exactly once per proc.
vfxtype (sonar_ring/particles/starburst/dual_ring/player_glow/burst_and_ring), color, color2, plus type-specific paramsSpawns named VFX on the ship.
customhandler (string), arbitrary numeric paramsDispatches to a CustomHandlers[handler] callback. See step 5.
custom_tickhandler, arbitrary numeric paramsMarks the effect for per-frame ticking via CustomTickHandlers[handler]. Trigger is usually run_start.

Signal-context numbers

Signal handlers receive a SignalSnapshot capturing name, uid1, uid2, num1, num2, str1. Many actions accept center: 'signal' or originX: 'signal' — those read num1/num2 as world coordinates from the snapshot (e.g. enemy_kill fires num1/num2 at the kill position).

Step 4: Register the artifact

Add the new artifact to the imports and to the ARTIFACT_DEFS array in src/starship-survivors/data/artifacts/index.ts. Pattern:

import { my_new_artifact } from './my-new-artifact';
 
export const ARTIFACT_DEFS: ArtifactDef[] = [
  // ...existing entries...
  my_new_artifact,
];

The ARTIFACT_MAP lookup is built automatically from ARTIFACT_DEFS at module load — no other registration needed.

Every artifact also needs a flatBonus entry in ARTIFACT_FLAT_BONUSES in the same file:

my_new_artifact: { stat: 'weaponDamagePct', label: 'Weapon Damage', mode: 'flat' },

getArtifactFlatBonus(id) throws if an entry is missing — every artifact in ARTIFACT_DEFS must have one.

The valid stat keys (must match an existing ship stat understood by Modifiers.add):

stat keymodeIdentity / archetype
weaponDamagePctflatOffense (damage)
fireRatePctflatOffense (fire rate)
maxSpeedpercentMobility / aggressive
magnetRangeflatEconomy (pickup radius)
luckflatEconomy (drops + rolls)
damageReductionpercentDefense

Pick the stat that matches the artifact’s primary identity. By convention, signal-triggered artifacts use the same stat as their burst payoff (e.g. an artifact whose proc grants +fireRate should have flatBonus.stat = 'fireRatePct').

Step 5: Custom handlers

Use a custom handler when the artifact’s effect cannot be expressed by combining registered actions and conditions. The bar is high — most signal-triggered artifacts already compose cleanly from modify_stat + damage_aoe + vfx + flash_artifact.

Two custom flavors, both keyed by name string:

  • CustomHandlers — signal-driven, registered via registerCustomAction(name, fn). Invoked when an action has type: 'custom' and params.handler === name. Signature: (snap, params, game, ship, world) => void. Used for handlers that need direct access to world.enemies, fire signals, push into world.playerBullets, or call damageEnemy with custom logic (e.g. crate_buster_pulse, soul_leech_ghosts, killstreak_rain, lingering_flames_zone, grant_random_upgrade).
  • CustomTickHandlers — per-frame, registered via registerCustomTick(name, fn). Invoked every frame when an effect has an action with type: 'custom_tick' and matching params.handler. Signature: (dt, params, game, ship, world) => void. Used for autonomous entities like the combat drone (companion_droid).

Register handlers at module load. Both registries throw on duplicate names — handler names are global. Numeric params.* are pre-resolved (via $var substitution) before the handler runs; params.handler itself is stripped from the dict.

Custom handlers must live under src/starship-survivors/engine/effects/ (custom-handlers.ts or a sibling file) so they import the registry at module load — placing them next to data files would skip registration.

Step 6: Tier scaling

Each artifact lists per-tier values in its tiers 4-tuple, indexed 0-3 (uncommon → legendary). Common (runtime tier 0) reuses uncommon’s values record; differentiation between common and uncommon comes entirely from the flat-bonus ramp (10 % vs 20 %).

Two common patterns:

PatternWhat the data file looks like
Shared values, scale via flat-bonus onlyEvery tier’s values is identical. The artifact’s payoff is the flat stat ramp (10/20/30/40/50). Rare.
Per-tier overridesEach tier carries different numeric values — e.g. bloodlust ramps luckBonus 60/90/130/180 and aoeDmg 30/45/65/95 across uncommon → legendary. The flat-bonus still ramps in parallel on its own.

Both ramps stack — at legendary, the player gets the legendary tier’s values payoff AND a +50 % flat bonus to the artifact’s stat. Tune both ramps together.

When an action param needs to read a per-tier number, use $varName referring to a key in values. The engine calls resolveNumber / resolveString to substitute the current tier’s value at execution time.

Step 7: Sympathetic resonance — the one exception

Almost every artifact’s behavior lives in the unified EffectEngine registry. sympathetic_resonance is the lone exception.

Its cascade — “when you upgrade a weapon, every other weapon also gains N levels” — is implemented inline in engine/world/leveling.ts’s applyReward('weapon_upgrade') path. There is no weapon_upgrade signal in the engine and no built-in “level up a weapon” action, so the effect engine isn’t a fit. The artifact’s data file has no effects array — the inline leveling.ts block reads its tier values directly via getTierValuesAt('sympathetic_resonance', tier).bonusLevels.

Do not copy this pattern for new artifacts. It exists only because the cascade predates the EffectEngine and rebuilding the signal+action would be a meaningful refactor. New artifacts must compose from registered actions + conditions, or use the custom-handler registry (step 5).

Step 8: VFX and audio

Three audio/visual hooks fire automatically when an artifact is authored correctly:

HookWhereWhat it does
Pickup VFX + audioWorld-side artifact pickup logic (not authored per-artifact)Plays standard pickup feedback when the artifact is collected. No per-artifact wiring required.
Proc VFXThe flash_artifact actionTriggers HUD icon flash, double sonar ring (bright + primary), omni-directional particle burst, and a ship-hull glow flash — all in the artifact’s colors. Call this exactly once per proc.
Proc audioprocAudioCue field on the ArtifactDefWhen set, flash_artifact fires the named cue from engine/audio/micro-sfx.ts RECIPES. Routed through the game bus (LPF + reverb) as a spatial world event. Default is silent — common-trigger procs should add a cue per the t66 directive.

For richer per-proc effects beyond the flash_artifact defaults, layer extra vfx actions before flash_artifact in the action list. Existing patterns:

  • bloodlustdamage_aoe + vfx: burst_and_ring + flash_artifact with procAudioCue: 'artifact_bloodlust'.
  • heat_racer — two modify_stat actions + vfx: burst_and_ring + flash_artifact.
  • refreshheal_shield + heal + set_heat + two vfx actions (burst-and-ring + starburst) + flash_artifact.
  • hull_missilesspawn_projectile (missile salvo) + vfx: burst_and_ring + flash_artifact with procAudioCue: 'artifact_hull_missiles'.

Audio cue names must match a key in engine/audio/micro-sfx.ts RECIPES. New artifacts can register a new recipe there; existing recipes (e.g. artifact_bloodlust, artifact_hull_missiles) can also be reused.

Step 9: Validate

Run through the checklist before considering the artifact done:

  • Artifact rolls in chests at the expected tier — confirm via Gauntlet drop tracking or test-mode pickup spawner.
  • Trigger fires when expected — every effect proc logs [EFFECT] artifact:<id> to the console with resolved params; check that the line appears under the right conditions and not under wrong ones.
  • Effect magnitude matches design — the same [EFFECT] log lists each action’s resolved params (e.g. modify_stat(stat=luck,value=60,...)). Confirm the $var substitutions land on the right tier values.
  • Flat-bonus stacks correctly with other artifacts and with horizontal modifiers — pick the artifact at multiple tiers and verify the stat’s Modifiers total reflects the 10/20/30/40/50 ramp.
  • Reward card renders: name, description, icon, damage-tag pill (if set), tier color, primary/secondary palette on artwork.
  • Proc VFX visible: HUD ring flashes; particle burst + sonar rings + player glow appear at proc; audio cue plays (if set).
  • No console errors at run start (mis-typed stat key, unknown action type, unknown condition type, missing flatBonus entry, unknown procAudioCue recipe).
  • For custom handlers: handler name resolves (the engine throws Unknown custom action handler: <name> if not registered) and params arrive with $var substitutions applied.

Custom-element structure rule

The EffectEngine’s whole design is: register new actions, conditions, or handlers in the registries — never inline-special-case in the engine. If an existing artifact does something your design needs:

  • If it’s a combination of registered actions, you can use it directly. No new code.
  • If it dispatches via custom or custom_tick, register a new handler in custom-handlers.ts (or a sibling file under engine/effects/).
  • If it needs an entirely new action or condition shape, add a new entry to ACTION_MAP (actions.ts) or CONDITION_MAP (conditions.ts) with a single new implementation function. Update the matching type-union comment in engine/effects/types.ts.

Never add an if (artifactId === 'foo') branch inside effect-engine.ts. The one historical exception is sympathetic_resonance in leveling.ts (step 7); do not extend that pattern.