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 (nopersist, nodevtools, nosubscribeWithSelector) 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’screatefor 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.localStoragefrom the three localStorage-backed stores at construction time and onhydrate()calls.- Bootstrap and finalize-run payloads supplied by services (
bootstrap_player,finalize_run,claim_planet_reward,claim_tier_milestone) — stores exposeloadFromBootstrap/replaceFromSnapshot/updateFromRunResultentry points rather than calling RPCs themselves. - The metagame
playerStorereadsmetagame/services/auth,metagame/services/playerBootstrap, and themetagame/services/supabaseclient 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
setafter every mutation; consumers re-render throughuseXStore(s => s.field)selectors. localStoragefrom 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_savefor ship selection),modGridStore(save_mod_gridvia a 300ms debounce). All other Supabase persistence is owned by services, not stores. walletStorefrommodGridStoreforspendCreditsduring 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 metagameplayerStoreon every identity transition. window.__PLAYER_STORE_OFFLINE__global fromplayerStoreto break the import cycle with the RPC layer.console.error/console.warnon 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/persistmiddleware. Persistence is either explicit per-mutationlocalStoragewrites (artifact unlocks, level tracks, mod-grid via debounce), explicit RPC calls (session ship selection, mod-grid), or external service-driven (everything else hydrated frombootstrap_player). - Talk to each other in dependency cycles. The only direct cross-store write is
modGridStore → walletStorefor 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;RewardOrchestratorandHomeResolveControllerown 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/):
| Store | Hook | One-line purpose | Persistence |
|---|---|---|---|
artifactUnlocksStore | useArtifactUnlocksStore | Tracks unlocked alien artifacts, current pick for next run, and best runtime tier ever reached per artifact. | localStorage (v1; Supabase mirror is follow-up). |
bonusStore | useBonusStore | Holds 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. |
challengeStore | useChallengeStore | Cache of challenge completions and lifetime per-planet kills/events/runs counts. | Supabase truth via services; in-memory cache only. |
displayOverrideStore | useDisplayOverrideStore | Holds 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. |
inventoryStore | useInventoryStore | Hull-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. |
levelTrackStore | useLevelTrackStore | Per-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). |
modGridStore | useModGridStore | Account-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. |
onboardingStore | useOnboardingStore | Prologue-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. |
planetProgressStore | usePlanetProgressStore | Cache of per-planet cumulative XP and the set of claimed reward levels per planet. | Supabase truth via services; in-memory cache only. |
rewardQueueStore | useRewardQueueStore | Declarative 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. |
sessionStore | useSessionStore | Carries 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. |
supporterStore | useSupporterStore | Supporter-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. |
tierStore | useTierStore | Cache 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. |
walletStore | useWalletStore | Currency 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/):
| Store | Hook | One-line purpose | Persistence |
|---|---|---|---|
playerStore | usePlayerStore | Client 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 frombootstrap_player(orlocalStorage) 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 onbootstrapped. Bootstrap order:playerStore.init()runs first, the bootstrap RPC payload comes back, then services callloadFromBootstrap/replaceFromSnapshoton 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/loadStatusentry point; the service layer calls it after RPCs. (2) Supabase via the store itself — sessionStore (fire-and-forget on ship-pick), modGridStore (300ms debouncedsave_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 metagameplayerStore’s one-shotwelcome_shownflag. 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/persistmiddleware anywhere. Every store that persists does so explicitly — either through service-drivenloadFromBootstrapcalls or through manuallocalStoragewrites 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 (
grantShipPullon unknown hull,canPlace/addDropon unknown mod template,selecton 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.
SetandMappayloads 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.spendCreditsduring cell purchase. Everything else routes through React components reading multiple stores, or through services that call into multiple stores in a known order. The metagameplayerStoredeliberately 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 calluseXStore(s => s.someSelector(arg))for reactive reads. - Reward presentation is declarative-only.
rewardQueueStoreholds the popup queue and the phase state, but the timing/animations live inRewardOrchestrator(gameplay-side component) andHomeResolveController(hub-side component). The orchestrator readsphase, runs side effects, then writes the next state viasetPhase/finishCurrent.displayOverrideStoreis 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.
sessionStoreis the only store that carries data across screen transitions in the Hub → Loadout → Game → Results → Hub loop. The mission result lands here via the bridgeonGameOvercallback; the results screen reads it; the hub resets it on next entry.selectedShipIdis the only field of the three that persists. - Hydration is one-way at boot. Every cache store has a
loadFromBootstrap(...)orreplaceFromSnapshot(...)orloadFromStoragepath 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.