Artifact Tier-Up Mechanics
Every owned artifact this run carries a runtime tier index 0..ARTIFACT_TIER_MAX (4). The five tiers map directly to the rarity vocabulary used everywhere else in the run:
| Tier | Name |
|---|---|
| 0 | common |
| 1 | uncommon |
| 2 | rare |
| 3 | epic |
| 4 | legendary |
Source of truth: ARTIFACT_TIER_NAMES_ARR in engine/world/artifacts.ts. Index = ArtifactInstance.tier.
How an artifact tiers up
Artifacts only tier up by picking the same artifact again on a reward card — there is no XP track, no auto-progression, and no cross-run tier carry. The two reward surfaces that produce tier-up cards are:
- End-of-level reward screen (
rollArtifactChoiceswithmode='any') — mixes new and upgrade picks, skips max-tier artifacts. - In-run artifact-box / artifact event (
rollArtifactChoiceswithmode='upgrade_only') — owned + below legendary only, dedup so two cards in one batch never reference the same artifact.
canRollArtifactUpgrade() lets the bridge swap an artifact event to a weapon event when zero owned artifacts are still upgradeable. countUpgradeableArtifacts() and countAvailableNewArtifacts() back the same kind of mode-gating elsewhere.
When the player picks a card with artifactIsLevelUp=true, the reward routes back through grantArtifact(id), which dispatches on whether the artifact already exists in _map.
The tier-up flow inside grantArtifact
For an artifact already in _active:
- Cap check. Skip if
inst.tier >= ARTIFACT_TIER_MAX. Legendaries never re-trigger this path becauserollArtifactChoicesfilters them out, but the guard is defensive. - Unapply at the old tier.
_unapplyStatEffects(inst, gm, sh)runs first. It uses the current tier values to undo whatever was applied last time:- Bespoke per-id rollback (e.g.
echo_generatordecrementsgm.upgradeCounts['more_projectiles']by the current tier’sextraProjectiles). _removeFlatBonus(inst, sh)callsModifiers.removeBySource(eid, 'artifact:<id>:flatbonus')to strip the current-tier flat-bonus modifier off the ship.
- Bespoke per-id rollback (e.g.
- Bump.
inst.tier++. - Reapply at the new tier.
_applyStatEffects(inst, gm, sh)readsgetTierValuesAt(def, inst.tier)for the new values:- Bespoke per-id reapply (echo generator re-adds the new tier’s
extraProjectiles). _applyFlatBonus(inst, sh)callsModifiers.add(eid, fb.stat, fb.mode, getArtifactFlatBonusValueAt(id, tier), 0, 0, 'artifact:<id>:flatbonus').
- Bespoke per-id reapply (echo generator re-adds the new tier’s
- Re-register effects. If the artifact has an
effects[]template, the engine unregisters the old token (artifact:<id>) and re-registers with the new tier’s value bag viagetTierValuesAt(def, inst.tier). This is how scaling values (radius, damage, chain count) flow into trigger-driven effects without rewriting the effect spec. - Record best tier.
_recordBestTier(id, inst.tier, gm)writesgm.tracking.artifactBestTier[id] = max(prev, tier). - Legendary unlock detection. If the bump landed on
ARTIFACT_TIER_MAX,_maybeRecordLegendaryUnlock(id, gm)pushes the id ontogm.newlyUnlockedArtifactIds(unless the player already owns it as a starter). The bridge flushes this list touseArtifactUnlocksStoreon run end and the run-stats screen reveals the unlock. - Mirror to serializable state. The matching entry in
gm.artifactsis updated to the new tier (or pushed at tier 0 for a fresh acquisition). - Telemetry.
telemetry.recordArtifactEvent(id, 'pick', tier)fires.
The “remove → recompute → apply” pattern is the load-bearing part: stat-modification artifacts cannot just add a new modifier on top, because the flat-bonus modifier is keyed by source and would no-op the second add. The source key artifact:<id>:flatbonus is the unique handle Modifiers.removeBySource uses to find and strip the prior tier’s contribution.
Where the tier values come from
Per-artifact tier scaling is data, not code. ARTIFACT_MAP[id] carries:
tierValues: Record<string, number[]>— each named knob is a length-5 array indexed by tier.getTierValuesAt(def, tier)returns a flatRecord<string, number>of that tier’s slice (e.g.{ radius: 100, damagePct: 0.5, ... }), which is what gets handed to the effect engine and the bespoke_apply*switches.flatBonus— the universal +10/20/30/40/50% (or stat-appropriate equivalent) passive every artifact carries. Resolved bygetArtifactFlatBonus(id)andgetArtifactFlatBonusValueAt(id, tier).tierLabels: string[]— the tier-specific sentence shown on the reward card.getTierLabelAt(def, nextTier)is whatrollArtifactChoicesuses forstatLabel; the globaldef.descriptionis deliberately not forwarded.
This split keeps the runtime code generic: the tier-up flow above never branches on which tier you just landed on (except for the legendary-unlock side effect). All scaling is “fetch the new value bag, hand it to the same code.”
Legendary-only behavior gates
A small number of artifacts ship behavior that only activates at tier === ARTIFACT_TIER_MAX. The pattern is an explicit guard inside the tick or effect handler:
force_field: explode-on-expire pulse whentier >= ARTIFACT_TIER_MAX && v.explodePct.crate_buster: chain-jump second pulse whentier >= ARTIFACT_TIER_MAX && v.chainJump.event_healer: bonus heal whentier >= ARTIFACT_TIER_MAX && v.bonusPct.personal_space: brief invuln on knockback whentier >= ARTIFACT_TIER_MAX && v.invulnTime.
These are not on the flat-bonus rail — they read inst.tier directly at the trigger site. Tier-up rebuilds them implicitly because the effect re-registration in step 5 already swaps the value bag.
Why _recordBestTier matters
_recordBestTier is the only path that feeds the meta-progression unlock system. Each per-run record:
gm.tracking.artifactBestTier[id] = max(prev, tier)
…is read by the bridge at run end and merged into useArtifactUnlocksStore, which gates which artifacts appear in the starting-artifact picker on subsequent runs. A common-tier pickup that never tiers up still records tier=0; a legendary tier-up records tier=4 and additionally fires the legendary-unlock side effect.
For first-time legendary unlocks specifically, the gm.newlyUnlockedArtifactIds array is the trigger for the run-stats reveal animation. The starter-unlocks store is checked first to avoid double-revealing artifacts the player already had unlocked.
Edge cases worth knowing
- Fresh artifact at common.
grantArtifactalways inserts attier: 0for the_map.has(id) === falsebranch. The first card is never “uncommon” — it’s always common, and uncommon is the first tier-up. - Effect template without bespoke handler. Artifacts whose entire behavior lives in
def.effects[]need no per-id case in_applyStatEffects/_unapplyStatEffects. The flat-bonus rail and the effect re-registration handle the whole scaling story for them. - Pre-seeded legendary. If a run somehow grants an artifact directly at tier 4 (e.g. starter-artifact path through the same function),
_maybeRecordLegendaryUnlockstill fires once on the initial insert. - Run-end persistence. Nothing in this file persists tier across runs. The bridge owns the merge into
useArtifactUnlocksStore; this module only writes to per-rungm.trackingandgm.newlyUnlockedArtifactIds.
Related
gameplay/concepts/artifact-pickup-flow.md— how the artifact box / event reward routes from spawn tograntArtifact.gameplay/concepts/effect-engine.md— howEffectEngine.register(effects, token, valueBag)consumes the per-tier value bag.gameplay/concepts/modifier-source-tags.md— theartifact:<id>:flatbonussource-tag convention used byModifiers.removeBySource.gameplay/concepts/legendary-vfx-scale.md— visual upscaling that tracks the tier ladder.