Achievement Tracker

Per-run counters that the engine accumulates while a mission is live, plus the stable lifetime-progress key list those counters feed into on mission end. Drives challenges, prologue beats, and the hub’s achievement display.

Two layers

The tracker has two distinct surfaces — the in-run mutable bag and the lifetime persistent counters.

Layer 1 — game.tracking (per-run, in-memory)

A single object on the GameState (see engine/core/types.tsGameTracking, initialized in engine/core/state.ts) that the engine mutates during a run. Fields are typed and pre-allocated at run start — no map churn. The engine writes to them from engine/bridge.ts as events fire:

  • damageTaken — accumulated on hull hit
  • totalHeal, totalShieldRegen — accumulated on regen ticks
  • lowestHpPercent — running minimum of hp / hpMax
  • deathDefianceUsed, reviveTokenUsed — boolean flags set when the token is consumed
  • eventsCompleted, eventsAttempted — bumped on event start/finish
  • weaponsFound, upgradesChosen, artifactsCollected — string arrays pushed on acquire
  • artifactBestTier — optional Record<artifactId, 0-4> of highest runtime tier seen
  • abilityUses, maxDistance, detections, trapKills, trapKillPct
  • gatesHit, gatesRequired, payloadHpRemainingPct, objectHpRemainingPct — per-event-type scratch fields
  • poisVisited, subZoneEntered, enemyBulletsFired, enemyBulletsHit

This bag is the canonical per-run source of truth. It is wiped on every mission start.

Layer 2 — PROGRESS_KEYS (lifetime, server-persisted)

data/progress-keys.ts exports the canonical list of ~15 stable counter names that survive across runs. These are the keys the achievement system and Rookie Week prologue check against:

total_arcade_runs, total_kills, elite_kills, boss_kills,
events_completed, crates_destroyed, max_survival_seconds,
missions_deployed, missions_claimed, pulls_made, merges_done,
buildings_placed, ships_owned, max_run_level, weapon_chests_opened

ProgressKey is the union type derived from this list. Anything that consumes lifetime progress (prologue beats, challenge predicates, hub achievement tiles) addresses counters by these exact strings.

Mission-end flow

When a run ends, engine/bridge.ts fires onGameOver with the engine’s MissionResult. The metagame’s services/runProgressionService.tsfinalizeRun(result, planetIndex) builds the RPC payload and calls the Supabase finalize_run RPC. The payload carries the run summary the server needs to compute rewards and update lifetime counters:

ship_id, duration_s, kills, wave_reached, xp_earned,
result (survived|died), credits_earned, scrap_earned,
tier_reached, planet_id, weapons_at_end, events_completed

Server-side, finalize_run atomically:

  1. inserts match_history
  2. computes currency rewards (credits / scrap)
  3. updates player_currencies + inserts transactions
  4. (post-migration 031) returns newly-completed challenges + updated planet kill/event totals
  5. (post-migration 033) returns updated planet XP

The response is the canonical wallet snapshot — runProgressionService replaces local wallet state from it, then updates the tier store, challenge store, and planet-progress store from the same response. Sync status is flipped syncing → synced (or error).

The lifetime PROGRESS_KEYS counters are kept in sync with these run-end payloads — the server is authoritative; clients display whatever the latest snapshot says.

Consumers

  • Challenges — predicates compare PROGRESS_KEYS deltas (e.g. total_kills reaching N on planet X).
  • Prologue beatsdata/prologue-config.ts declares each beat’s completion as { tracking: ProgressKey, target: number }. The four beats currently key off total_kills, max_run_level, events_completed, and pulls_made.
  • Hub achievement display — the metagame reads the same lifetime counters to paint progress tiles and unlock states.

Why two layers

In-run mutations need to be cheap and untyped-stringly (the engine ticks every frame). Lifetime persistence needs a stable, narrow, server-validated key set so RPC schemas and challenge predicates don’t break when engine internals change. The MissionResult is the contract between them — engine flattens game.tracking + game.stats into the result shape, then finalizeRun projects that into the RPC payload.

Files

  • src/starship-survivors/data/progress-keys.tsPROGRESS_KEYS const, ProgressKey type
  • src/starship-survivors/engine/core/types.tsGameTracking interface
  • src/starship-survivors/engine/core/state.tstracking initialization
  • src/starship-survivors/engine/bridge.ts — per-run game.tracking.* writes
  • src/starship-survivors/services/runProgressionService.tsfinalizeRun RPC + store updates
  • src/starship-survivors/data/prologue-config.ts — example ProgressKey consumer