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:
| Shape | What you write | Example |
|---|---|---|
| Flat stat boost only | data file with no effects array, just a flat-bonus mapping | sympathetic_resonance (effect lives in leveling.ts, see step 7) |
| Signal-keyed trigger | data file with one EffectDef, trigger.type: 'signal' | bloodlust, chain_reaction, refresh |
| Always-on tick handler | data file with trigger.type: 'signal' (signal run_start) + custom_tick action | companion_droid, force_field |
| Timer pulse | data file with trigger.type: 'timer', optional timerRepeat | heat_racer |
| Counter (every Nth signal) | data file with trigger.type: 'counter' | weapon-fire counter triple (overdrive, slipstream, hunter) |
| Custom logic that doesn’t compose | data file + a registered custom action handler | chain_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:
| Field | What it controls |
|---|---|
id (snake_case) | Lookup key. Must match the filename slug and the ARTIFACT_FLAT_BONUSES entry. |
name | Display name on the reward card. |
description | One-line flavor on the reward card. |
icon | Single emoji glyph rendered on the card. |
category | unique, 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 condition | always-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 idx | Name | Tier color hex | Flat-bonus % |
|---|---|---|---|
| 0 | common | #9d9d9d | 10 |
| 1 | uncommon | #1eff00 | 20 |
| 2 | rare | #0070dd | 30 |
| 3 | epic | #a335ee | 40 |
| 4 | legendary | #ff8000 | 50 |
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:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Unique snake_case ID. Must equal the filename slug. |
name | string | yes | Reward-card display name. |
description | string | yes | Flavor sentence on the reward card. |
icon | string | yes | Single emoji. |
category | 'unique' | 'stat' | 'weapon' | yes | Categorization. |
colors | { primary, secondary, bright } | yes | Hex strings (#rrggbb). Drive every visual cue. |
tiers | 4-tuple of { label, values } | yes | Indexed 0-3 = uncommon → legendary. values is a flat number dict. Common reuses index 0 at runtime. |
damageTag | 'bullet' | 'energy' | 'fire' | 'bomb' | no | Pill on reward card. |
effects | EffectDef[] | no | Effect definitions for the unified engine. Omit for flat-bonus-only artifacts. |
procAudioCue | string | no | micro-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:
| Field | Type | Notes |
|---|---|---|
id | string | Unique within the artifact (e.g. bloodlust_trigger). |
trigger | TriggerDef | What activates the effect. |
conditions | ConditionDef[] | All must pass (AND). Empty array = always passes. |
actions | ActionDef[] | Executed in order when trigger + conditions pass. |
cooldown | number | string | Seconds between activations. 0 or omitted = no cooldown. Supports $var. |
charges | number | Max activations per run. -1 = unlimited. |
priority | number | Signal-listener priority band. Default 50 (effects). |
showBanner | boolean | When 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.type | Activation | Required fields |
|---|---|---|
signal | Fires when a named Sig signal is emitted | signal (string name) |
run_start | Fires once after all effects register at run start | — |
aura | Continuously checks conditions; applies / removes modifiers on transition | auraInterval (sec, default 0.5) |
timer | Fires after N seconds, optionally repeating, optionally reset by a signal | timerDuration, timerRepeat?, timerResetSignal? |
counter | Fires after counting N occurrences of a signal | counterSignal, 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.type | Params | What it checks |
|---|---|---|
random | chance (0-1) | Math.random() < chance |
signal_str1_eq | value | Snapshot’s str1 equals value |
signal_str1_neq | value | Snapshot’s str1 does NOT equal value |
signal_num1_gte | value | Snapshot’s num1 >= value |
damage_tag_eq | tag | Snapshot’s str1 equals tag (bullet / energy / fire / bomb) |
hp_below | threshold (0-1) | Ship HP fraction below threshold |
hp_above | threshold (0-1) | Ship HP fraction above threshold |
shield_active | — | Ship shield > 0 |
shield_broken | — | Ship shield ≤ 0 |
shield_empty | — | Ship shield ≤ 0 (alias) |
has_artifact | artifactId | Player owns the named artifact |
has_upgrade | upgradeId, minLevel | Player has ≥ minLevel of the upgrade |
has_buff | source | Any modifier with that source tag is active |
kill_streak_above | threshold | Game kill streak ≥ threshold |
elapsed_above | seconds | Game time ≥ seconds |
elapsed_below | seconds | Game time < seconds |
tier_at_least | tier | Mission tier ≥ tier |
heat_above | threshold | Ship heat ≥ threshold |
speed_above | threshold | Ship speed ≥ threshold |
speed_below | threshold | Ship speed < threshold |
boss_active | — | A 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.type | Key params | What it does |
|---|---|---|
modify_stat | stat, value, mode (flat/percent/set), duration, source, stacking (independent/refresh), maxStacks | Adds a stat modifier via Modifiers.add. Source tag drives cleanup. |
modify_stat_scaled | stat, mode, scaleVar (heat/speed/hp_pct/shield_pct), scaleMin, scaleMax, valueAtMin, valueAtMax, source | Linear-interpolated mod that re-evaluates each aura tick. |
modify_upgrade_count | upgradeId, delta | Adjusts game.upgradeCounts[upgradeId]. |
remove_modifiers | source | Removes all mods with matching source tag from the ship. |
heal | amount, mode (flat/percentMax/percentMissing) | Restores ship HP. Fires player_healed signal. |
heal_shield | amount, mode (flat/percentMax) | Restores ship shield. |
grant_invuln | duration | Sets ship.invulnerable true for duration. |
set_heat | value | Sets ship.heat. |
damage_aoe | damage, radius, damageMode (flat/hpPct), center (ship/signal), falloff (linear), falloffFar | AoE damage to all enemies within radius of center. |
apply_knockback | force, radius, center (ship/signal) | Adds radial velocity to all enemies in radius. Skips pack leaders. |
spawn_projectile | count, damage, speed, lifetime, spread, pierceCount, homingStrength, originX (ship/signal), color, weaponId, archetype, blastRadius | Pushes one or more bullets into world.playerBullets. |
apply_enemy_status | status (stunned/shredded/burning), duration, value, maxStacks | Applies status to enemy identified by signal uid1. |
apply_status_aoe | status, duration, value, radius, maxStacks, center (ship/signal) | Applies status to all enemies in radius. |
empower_weapon | target (random/slot_0/all), damageMult, shotsRemaining | Sets _empowerMult / _empowerShots on a weapon. |
emit_signal | signal, str1, num1, num2 | Fires a Sig.fire — used to chain effects. |
flash_artifact | — | Triggers the HUD icon flash, double sonar ring, particle burst, player glow, and (if procAudioCue is set) the audio cue. Use exactly once per proc. |
vfx | type (sonar_ring/particles/starburst/dual_ring/player_glow/burst_and_ring), color, color2, plus type-specific params | Spawns named VFX on the ship. |
custom | handler (string), arbitrary numeric params | Dispatches to a CustomHandlers[handler] callback. See step 5. |
custom_tick | handler, arbitrary numeric params | Marks 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 key | mode | Identity / archetype |
|---|---|---|
weaponDamagePct | flat | Offense (damage) |
fireRatePct | flat | Offense (fire rate) |
maxSpeed | percent | Mobility / aggressive |
magnetRange | flat | Economy (pickup radius) |
luck | flat | Economy (drops + rolls) |
damageReduction | percent | Defense |
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 viaregisterCustomAction(name, fn). Invoked when an action hastype: 'custom'andparams.handler === name. Signature:(snap, params, game, ship, world) => void. Used for handlers that need direct access toworld.enemies, fire signals, push intoworld.playerBullets, or calldamageEnemywith custom logic (e.g.crate_buster_pulse,soul_leech_ghosts,killstreak_rain,lingering_flames_zone,grant_random_upgrade).CustomTickHandlers— per-frame, registered viaregisterCustomTick(name, fn). Invoked every frame when an effect has an action withtype: 'custom_tick'and matchingparams.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:
| Pattern | What the data file looks like |
|---|---|
| Shared values, scale via flat-bonus only | Every tier’s values is identical. The artifact’s payoff is the flat stat ramp (10/20/30/40/50). Rare. |
| Per-tier overrides | Each 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:
| Hook | Where | What it does |
|---|---|---|
| Pickup VFX + audio | World-side artifact pickup logic (not authored per-artifact) | Plays standard pickup feedback when the artifact is collected. No per-artifact wiring required. |
| Proc VFX | The flash_artifact action | Triggers 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 audio | procAudioCue field on the ArtifactDef | When 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:
bloodlust—damage_aoe+vfx: burst_and_ring+flash_artifactwithprocAudioCue: 'artifact_bloodlust'.heat_racer— twomodify_statactions +vfx: burst_and_ring+flash_artifact.refresh—heal_shield+heal+set_heat+ twovfxactions (burst-and-ring + starburst) +flash_artifact.hull_missiles—spawn_projectile(missile salvo) +vfx: burst_and_ring+flash_artifactwithprocAudioCue: '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$varsubstitutions 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’sModifierstotal 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
statkey, unknown actiontype, unknown conditiontype, missingflatBonusentry, unknownprocAudioCuerecipe). - For custom handlers: handler name resolves (the engine throws
Unknown custom action handler: <name>if not registered) andparamsarrive with$varsubstitutions 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
customorcustom_tick, register a new handler incustom-handlers.ts(or a sibling file underengine/effects/). - If it needs an entirely new action or condition shape, add a new entry to
ACTION_MAP(actions.ts) orCONDITION_MAP(conditions.ts) with a single new implementation function. Update the matching type-union comment inengine/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.