stores

PURPOSE — Zustand stores that hold the client-side runtime state for the metagame layer. Each store owns one narrow slice of player-facing state and exposes a useXStore hook plus narrow actions; consumers (screens, components, services, the engine bridge) subscribe or call imperatively. Authority for persistent data lives in Supabase; most stores are hydrated caches that mirror server truth, with a few in-memory-only and a few localStorage-backed.

OWNS

  • One Zustand store per gameplay-side domain: artifact unlocks, shop bonus bar, challenge progress, reward-display overrides, ship inventory, per-level reward tracks, account-wide mod grid, prologue/hub-reveal onboarding, per-planet XP and rewards, reward-presentation queue + phase pipeline, session-loop scratch state, supporter-club status, per-planet tier records and milestone claims, currency wallet.
  • One Zustand store on the metagame side for player identity, profile, bootstrap lifecycle, sync status, and the offline-mode escape hatch.
  • The Zustand create<...>() pattern across the whole subsystem — no middleware (no persist, no devtools, no subscribeWithSelector) at the store level except where called out.
  • Internal helpers per store (uid generators, key composers, default constants, hydration parsers) and exported DTO / row / payload types that the RPC layer and bootstrap service type their parse steps against.

READS FROM

  • zustand’s create for every store construction.
  • ../data/* modules for catalog lookups, starter constants, rarity tables, progression curves, threshold tables, reward-card maps, and content rosters. Stores treat data tables as read-only contracts.
  • localStorage from the three localStorage-backed stores at construction time and on hydrate() calls.
  • Bootstrap and finalize-run payloads supplied by services (bootstrap_player, finalize_run, claim_planet_reward, claim_tier_milestone) — stores expose loadFromBootstrap / replaceFromSnapshot / updateFromRunResult entry points rather than calling RPCs themselves.
  • The metagame playerStore reads metagame/services/auth, metagame/services/playerBootstrap, and the metagame/services/supabase client directly — it is the one store that owns its own service-call lifecycle because it owns the auth/bootstrap loop.

PUSHES TO

  • React subscribers via standard Zustand set after every mutation; consumers re-render through useXStore(s => s.field) selectors.
  • localStorage from the three localStorage-backed stores on every mutation (writes are best-effort, wrapped in try/catch).
  • Supabase via fire-and-forget RPC calls from a small set of stores that own their own persistence path: sessionStore (update_player_save for ship selection), modGridStore (save_mod_grid via a 300ms debounce). All other Supabase persistence is owned by services, not stores.
  • walletStore from modGridStore for spendCredits during cell purchase — one of the only cross-store writes; otherwise stores don’t push to each other.
  • Telemetry sinks (engine/telemetry/sender, engine/telemetry/sampler, engine/telemetry/diag) from the metagame playerStore on every identity transition.
  • window.__PLAYER_STORE_OFFLINE__ global from playerStore to break the import cycle with the RPC layer.
  • console.error / console.warn on persistence failures — never rethrown to UI.

DOES NOT

  • Own server truth for persistent domains. Wallet balances, challenge completions, planet XP, tier records, mod-grid snapshots, prologue progress, ship inventory: Supabase is authoritative. The stores are hydrated caches that get replaced by the next server response.
  • Persist via zustand/persist middleware. Persistence is either explicit per-mutation localStorage writes (artifact unlocks, level tracks, mod-grid via debounce), explicit RPC calls (session ship selection, mod-grid), or external service-driven (everything else hydrated from bootstrap_player).
  • Talk to each other in dependency cycles. The only direct cross-store write is modGridStore → walletStore for credit spending. All other inter-store communication goes through the React component tree or through service-layer orchestration.
  • Run timers, animations, or async work in the store layer. The reward-presentation pipeline declares its phase state in rewardQueueStore; RewardOrchestrator and HomeResolveController own the runtime side effects.
  • Validate gameplay legality, compute progression curves, award rewards, or render anything. Stores expose state and narrow setters; everything else is a consumer concern.
  • Retry failed RPCs or queue offline writes. Stores fail fire-and-forget; the user must trigger retry through Settings or by re-running the action.

Signals fired / Signals watched — none. The store layer has no engine-signal surface. All store-to-consumer communication is through Zustand subscriptions; all consumer-to-store communication is through method calls on the store object. The metagame playerStore watches the Supabase auth.onAuthChange subscription as its single external signal, but does not expose any signal of its own.

Entry points — one Zustand hook per store, plus narrow actions per domain.

Gameplay-side stores (live in src/starship-survivors/stores/):

StoreHookOne-line purposePersistence
artifactUnlocksStoreuseArtifactUnlocksStoreTracks unlocked alien artifacts, current pick for next run, and best runtime tier ever reached per artifact.localStorage (v1; Supabase mirror is follow-up).
bonusStoreuseBonusStoreHolds the S.I.G. Storefront 0–100 bonus bar and the FIFO queue of milestone rewards crossed at 20/40/60/80/100.In-memory only, per session.
challengeStoreuseChallengeStoreCache of challenge completions and lifetime per-planet kills/events/runs counts.Supabase truth via services; in-memory cache only.
displayOverrideStoreuseDisplayOverrideStoreHolds frozen “before” wallet/counter/progress values during reward presentation so the UI can lie for the duration of an animation.In-memory only; mostly stubbed lifecycle.
inventoryStoreuseInventoryStoreHull-keyed ship collection (one record per hull, duplicate pulls grant XP; star level derived from XP).Supabase player_entities backup via external save/load; in-memory cache.
levelTrackStoreuseLevelTrackStorePer-level 5-chest reward track at 20/40/60/80/100% progress; runs deposit tier-weighted points, chests yield resolved reward cards.localStorage (ss_level_tracks).
modGridStoreuseModGridStoreAccount-wide 4x4 mod grid: inventory, equipped placements, rotations, credit-purchased cell unlocks, and three-copies-merge-up-rarity economy.Supabase via debounced save_mod_grid RPC.
onboardingStoreuseOnboardingStorePrologue-beat progress and progressive hub-reveal state (locked → hub_lite → hub_mid → hub_full) plus rookie-boost start timestamp.Hydrated by bootstrap; external service writes the canonical record.
planetProgressStoreusePlanetProgressStoreCache of per-planet cumulative XP and the set of claimed reward levels per planet.Supabase truth via services; in-memory cache only.
rewardQueueStoreuseRewardQueueStoreDeclarative state for two coexisting surfaces: legacy popup queue and the phase-driven reward batch pipeline (reveal → collect → await_surface → resolve → milestones).In-memory only; resets on reload.
sessionStoreuseSessionStoreCarries data across the metagame screen loop (Hub → Loadout → Game → Results → Hub): selected ship, assembled run definition, last mission result.Selected ship persists via update_player_save RPC; runDef/missionResult in-memory only.
supporterStoreuseSupporterStoreSupporter-Club tier (none / free / paid) and activation timestamp; derives daily-bonus credits and XP multiplier from tier.External sync code calls loadStatus; Supabase player_supporter is the backing store.
tierStoreuseTierStoreCache of per-planet personal-best tier (the 4-minute mission-difficulty counter) and the set of claimed milestone numbers per planet.Supabase truth via services; in-memory cache only.
walletStoreuseWalletStoreCurrency balances (gems / credits / pull tickets) plus lifetime counters (total gems spent, total pulls).Supabase truth via services; in-memory cache only.

Metagame-side store (lives in src/metagame/stores/):

StoreHookOne-line purposePersistence
playerStoreusePlayerStoreClient projection of Supabase Auth user, resolved player profile, bootstrap lifecycle flags, sync status, and offline-mode latch with its background recovery loop.Supabase Auth + bootstrap_player RPC own truth; one-shot localStorage flag for the welcome modal.

Pattern notes

  • Gameplay-side vs. metagame-side split. The gameplay-side stores under starship-survivors/stores/ own per-domain run/progression/economy state — they get hydrated from bootstrap_player (or localStorage) and updated from RPC responses, then read by both the React UI and the engine bridge. The single metagame-side store (metagame/stores/playerStore) owns the auth and bootstrap lifecycle itself — it is the only store that initiates Supabase calls of its own, and it gates the entire app shell on bootstrapped. Bootstrap order: playerStore.init() runs first, the bootstrap RPC payload comes back, then services call loadFromBootstrap / replaceFromSnapshot on each gameplay-side store to seed the caches before the hub renders.
  • Authority model is consistent. Supabase is the source of truth for everything persistent; the stores are read-mostly client projections. The few earn* / spend* / addXp / optimistic mutators that exist (walletStore, planetProgressStore) update the cache assuming the server will mirror or correct on the next snapshot replace. There is no merge/conflict logic — the latest server payload wins.
  • Persistence falls into four buckets. (1) Supabase via services — wallet, inventory, challenges, planet progress, tier, onboarding, supporter status, player profile. The store exposes a loadFromBootstrap / replaceFromSnapshot / loadStatus entry point; the service layer calls it after RPCs. (2) Supabase via the store itself — sessionStore (fire-and-forget on ship-pick), modGridStore (300ms debounced save_mod_grid). These are the two exceptions to the “stores don’t call RPCs” rule. (3) localStorage — artifactUnlocksStore (starpunk.artifactUnlocks + two siblings), levelTrackStore (ss_level_tracks), plus the metagame playerStore’s one-shot welcome_shown flag. All localStorage writes are wrapped in try/catch and fail to in-memory-only on quota errors. (4) In-memory only — bonusStore, displayOverrideStore, rewardQueueStore, plus the in-memory caches above before any Supabase write. The bonus bar in particular is per-session by design.
  • No zustand/persist middleware anywhere. Every store that persists does so explicitly — either through service-driven loadFromBootstrap calls or through manual localStorage writes inside its mutators. This keeps cloud the source of truth and lets each store pick its own boundary between optimistic local writes and server confirmation.
  • Crash-on-bad-data at internal boundaries. Most stores throw on unknown ids passed to mutators (grantShipPull on unknown hull, canPlace/addDrop on unknown mod template, select on un-unlocked artifact). External boundaries (localStorage parse, RPC payload shape, bootstrap rows) are wrapped defensively — bad data degrades silently to defaults rather than crashing module load.
  • Immutable updates throughout. Set and Map payloads are cloned on every mutation so Zustand selector subscribers see a new reference. Inner records are spread before being reassigned. The pattern is consistent across the whole subsystem.
  • No cross-store dependencies in cycles. The only direct cross-store write is modGridStore → walletStore.spendCredits during cell purchase. Everything else routes through React components reading multiple stores, or through services that call into multiple stores in a known order. The metagame playerStore deliberately uses a window-global (__PLAYER_STORE_OFFLINE__) to communicate with the RPC layer instead of importing it, specifically to break the cycle that would otherwise form.
  • Selectors live on the state object. The Zustand convention in this codebase is to colocate read selectors (isCompleted, getProgress, isUnlocked, getXp, getLevel, canClaimMilestone, isPaid, canPlace, inventoryStacks, getClaimableChests, getCurrentBeat) on the store state itself rather than as external helper hooks. Components call useXStore(s => s.someSelector(arg)) for reactive reads.
  • Reward presentation is declarative-only. rewardQueueStore holds the popup queue and the phase state, but the timing/animations live in RewardOrchestrator (gameplay-side component) and HomeResolveController (hub-side component). The orchestrator reads phase, runs side effects, then writes the next state via setPhase / finishCurrent. displayOverrideStore is the seam where the UI “lies” about wallet/counter/progress values for the duration of a presentation, while the live stores remain truthful underneath.
  • Session loop scratch state. sessionStore is the only store that carries data across screen transitions in the Hub → Loadout → Game → Results → Hub loop. The mission result lands here via the bridge onGameOver callback; the results screen reads it; the hub resets it on next entry. selectedShipId is the only field of the three that persists.
  • Hydration is one-way at boot. Every cache store has a loadFromBootstrap(...) or replaceFromSnapshot(...) or loadFromStorage path that runs once during init. After that, the only writes are either RPC-confirmed (for Supabase-backed stores) or local-mutation-then-localStorage (for localStorage-backed stores). Stores never auto-refresh from the server mid-session.

14 items under this folder.