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:

TierName
0common
1uncommon
2rare
3epic
4legendary

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 (rollArtifactChoices with mode='any') — mixes new and upgrade picks, skips max-tier artifacts.
  • In-run artifact-box / artifact event (rollArtifactChoices with mode='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:

  1. Cap check. Skip if inst.tier >= ARTIFACT_TIER_MAX. Legendaries never re-trigger this path because rollArtifactChoices filters them out, but the guard is defensive.
  2. 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_generator decrements gm.upgradeCounts['more_projectiles'] by the current tier’s extraProjectiles).
    • _removeFlatBonus(inst, sh) calls Modifiers.removeBySource(eid, 'artifact:<id>:flatbonus') to strip the current-tier flat-bonus modifier off the ship.
  3. Bump. inst.tier++.
  4. Reapply at the new tier. _applyStatEffects(inst, gm, sh) reads getTierValuesAt(def, inst.tier) for the new values:
    • Bespoke per-id reapply (echo generator re-adds the new tier’s extraProjectiles).
    • _applyFlatBonus(inst, sh) calls Modifiers.add(eid, fb.stat, fb.mode, getArtifactFlatBonusValueAt(id, tier), 0, 0, 'artifact:<id>:flatbonus').
  5. 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 via getTierValuesAt(def, inst.tier). This is how scaling values (radius, damage, chain count) flow into trigger-driven effects without rewriting the effect spec.
  6. Record best tier. _recordBestTier(id, inst.tier, gm) writes gm.tracking.artifactBestTier[id] = max(prev, tier).
  7. Legendary unlock detection. If the bump landed on ARTIFACT_TIER_MAX, _maybeRecordLegendaryUnlock(id, gm) pushes the id onto gm.newlyUnlockedArtifactIds (unless the player already owns it as a starter). The bridge flushes this list to useArtifactUnlocksStore on run end and the run-stats screen reveals the unlock.
  8. Mirror to serializable state. The matching entry in gm.artifacts is updated to the new tier (or pushed at tier 0 for a fresh acquisition).
  9. 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 flat Record<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 by getArtifactFlatBonus(id) and getArtifactFlatBonusValueAt(id, tier).
  • tierLabels: string[] — the tier-specific sentence shown on the reward card. getTierLabelAt(def, nextTier) is what rollArtifactChoices uses for statLabel; the global def.description is 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 when tier >= ARTIFACT_TIER_MAX && v.explodePct.
  • crate_buster: chain-jump second pulse when tier >= ARTIFACT_TIER_MAX && v.chainJump.
  • event_healer: bonus heal when tier >= ARTIFACT_TIER_MAX && v.bonusPct.
  • personal_space: brief invuln on knockback when tier >= 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. grantArtifact always inserts at tier: 0 for the _map.has(id) === false branch. 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), _maybeRecordLegendaryUnlock still 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-run gm.tracking and gm.newlyUnlockedArtifactIds.
  • gameplay/concepts/artifact-pickup-flow.md — how the artifact box / event reward routes from spawn to grantArtifact.
  • gameplay/concepts/effect-engine.md — how EffectEngine.register(effects, token, valueBag) consumes the per-tier value bag.
  • gameplay/concepts/modifier-source-tags.md — the artifact:<id>:flatbonus source-tag convention used by Modifiers.removeBySource.
  • gameplay/concepts/legendary-vfx-scale.md — visual upscaling that tracks the tier ladder.