RewardBatch FSM
A RewardBatch is the unit of reward presentation in Starship Survivors. Every reward source (arcade run, mission collect, pull, journey chest) is funneled through a finalizer that builds a single batch, enqueues it, and then a phase runner drives that batch through a fixed state machine: idle → reveal → collect → await_surface → resolve → milestones.
The orchestrator never inspects the source of a batch — it reads PresentationHints and walks the FSM. This is the seam that lets pull, run, mission, and chest rewards share one presentation pipeline.
The batch
Defined in data/reward-types.ts. Every batch carries:
batchId— unique handle for dedupe + phase-run keying.source— string tag ('pull','arcade_run','journey_chest', etc.). Used only to pick a custom presenter; the orchestrator never branches on it.presentation: PresentationHints— how to present the batch. Three fields:revealMode: 'default' | 'custom' | 'none'collectMode: 'default' | 'custom' | 'none'resolvePolicy: 'immediate' | 'defer_until_hub'
counters: CounterDelta[]— currency before/after pairs (credits, gems, pull tickets). Drive fly-to-target animations and counter settle.progress: ProgressDelta[]— track snapshots (currently only'journey'). Each delta carriesbefore/afterxp+level snapshots and optionalthresholdsCrossedIDs that trigger pause-and-pop bar animations.milestones: Milestone[]— chip labels withimportance: 'minor' | 'major'that fire after resolve.
isNoOpBatch() short-circuits batches with zero counters, zero progress, and zero milestones — finalizers discard them silently before enqueue.
The phases
Defined as RewardPhase in data/reward-types.ts, run by components/RewardOrchestrator.tsx:
idle— no active batch. Orchestrator is quiet.reveal— delegates topresenter.presentReveal(batch). Default presenter spawns cards, flips with burst, settles with punch (staggered 50ms per card). Pull presenter is a pass-through because the pull engine owns its own reveal flow.collect— delegates topresenter.presentCollect(batch). Default presenter flies grant cards to wallet anchors and releases display overrides so counters settle to after-values. After collect resolves, the orchestrator releases eachcounteroverride viadisplayOverrideStore.releaseCounter().await_surface— a holding phase. If the hub isn’t mounted when a batch needsresolve, the orchestrator callsdeferCurrentResolve(), moves the batch todeferredResolves, and starts the next batch. Resumed later when the hub mounts.resolve— bar fills. Handled byHomeResolveController, not the orchestrator. The controller watches for theresolvephase, animatesprogress[]bars, and callsadvanceOrFinish()when done.milestones— sequentialholdAndSettle(150) → showChip(label, tone) → holdAndSettle(180)per milestone. Tone is'premium'for major,'info'for minor.
Transitions go through setPhase(), finishCurrent(), or deferCurrentResolve() on rewardQueueStore. The orchestrator’s runningPhaseRef keyed on ${batchId}:${phase} prevents double-execution if React re-runs the effect.
Presentation hints drive the orchestrator
The orchestrator never asks “is this a pull?” or “is this a run?” — it asks the batch’s PresentationHints:
revealMode: 'none'orcollectMode: 'none'→ the orchestrator skips that phase entirely.revealMode: 'custom'orcollectMode: 'custom'→getPresenter()returns a source-specific presenter (currently onlypullPresenter, which is a no-op pass-through because the pull engine handles its own animations).resolvePolicy: 'defer_until_hub'→ inadvanceOrFinish(), if the next phase isresolveand the hub isn’t mounted, the orchestrator callsdeferCurrentResolve()instead ofsetPhase('resolve').resolvePolicy: 'immediate'→ resolve runs as soon as the controller picks it up.
This is what lets a chest opened mid-mission defer its journey-bar fill until the player lands back at the hub, while still showing reveal + collect immediately in the chest UI.
Finalizers are the only entry points
services/reward-finalizers.ts is the commit layer. Callers replace direct store mutations with one finalizer call per reward source:
finalizeRunRewards()— arcade run end. Thefinalize_runSupabase RPC has already committed wallet changes, so the finalizer just snapshots before, snapshots after, callsdescribeRunRewards(), locks display overrides, and enqueues.finalizeMissionRewards()— mission collect. Takes acommitFnthat performs the wallet/inventory mutations between the before-snapshot and the describe step.finalizePullRewards()— pull commit. Server RPC already mutated state, so this reconstructs before-ownership by subtractingpullResponse.newShipsfrom current ownership. Display overrides are NOT locked because pull usesrevealMode: 'custom'.finalizeChestRewards()— journey chests. Same pattern as mission: snapshot, commitFn, snapshot, describe, lock, enqueue.
Every finalizer follows the same six-step ritual: capture before-snapshot, lock display overrides to before-state values, commit the domain action, capture after-snapshot, call a pure describer to build the RewardBatch, enqueue (or discard if no-op).
The describer is pure — all state access happens in the finalizer. Display overrides are locked BEFORE the domain commit so UI components keep showing before-values during the theatrical reveal.
Presenters
services/reward-presenters.ts is the strategy layer. getPresenter(batch) returns one of:
defaultPresenter— card spawn + flip + burst → fly-to-target + counter settle. Used for every batch withrevealMode: 'default'/collectMode: 'default'. Currently stubbed; methods resolve immediately until the visual implementation lands.pullPresenter— pass-through. Pull engine owns its own reveal + collect lifecycle externally. Acknowledged transitional debt — long-term, pull-engine’s reveal should be extracted into a proper presenter adapter.
The contract is strict: presenters do per-phase work and resolve when their animations finish. They never advance the FSM — that’s the orchestrator’s job.
Cross-references
- Batch + phase types:
src/starship-survivors/data/reward-types.ts - Finalizers (commit layer):
src/starship-survivors/services/reward-finalizers.ts - Presenters (strategy layer):
src/starship-survivors/services/reward-presenters.ts - Phase runner:
src/starship-survivors/components/RewardOrchestrator.tsx - Resolve phase controller:
src/starship-survivors/components/HomeResolveController.tsx - Queue store (declarative state):
src/starship-survivors/stores/rewardQueueStore.ts - Display override locking:
src/starship-survivors/stores/displayOverrideStore.ts - Pure describers:
src/starship-survivors/services/reward-describers.ts