Pull System
The pull system is the gacha layer that turns spent currency or tickets into hulls. Players pick a banner, choose a 1- or 10-pull, and the server returns rolled ships that are either unlocked for the first time or fed back into the existing hull as XP. Rolls are local; wallet, pity, and inventory persistence are server-authoritative.
Banners
There are exactly two banners, sun and moon, defined in data/pull-config.ts and data/pull-rates.ts (BANNERS map). Both banners share the same SHIP_RARITY_RATES table and the same hull pool — they differ only in framing (Sun is “ships of light and radiance”, Moon is “ships of shadow and fortune”). Universal pull tickets work on either banner, and pity is tracked independently per banner.
Single vs 10-pull
| Pull | Gem cost | Ticket cost | Constant |
|---|---|---|---|
| Single | 100 Warp Crystals | 1 ticket | SINGLE_PULL_GEM_COST / SINGLE_PULL_TICKET_COST |
| 10-pull | 1,000 Warp Crystals | 10 tickets | TEN_PULL_GEM_COST / TEN_PULL_TICKET_COST |
There is no bulk discount — 10 singles and one 10-pull cost the same. The 10-pull’s only mechanical edge is the uncommon+ floor: every 10-pull is guaranteed to contain at least one uncommon or higher result (TEN_PULL_GUARANTEE_RARITY / TEN_PULL_GUARANTEED_MIN_RARITY = 'uncommon'). See the pity-system concept for soft/hard pity gates that layer on top of this.
Payment
PaymentMethod in services/pullService.ts is 'tickets' | 'gems'. Tickets are universal and earned from daily ads (capped at DAILY_AD_TICKET_LIMIT = 5 per day) and missions; gems (Warp Crystals) are the premium currency. The server speaks credits and gems on the wire; the client mirrors the canonical wallet snapshot returned by the RPC.
Server authority — perform_pull
The flow in executePullPull (services/pullService.ts) is:
- Client rolls
countresults locally usingweightedRoll(SHIP_RARITY_RATES)for rarity andpickHull()for a uniformly random hull class. - Client calls the Supabase RPC
perform_pullwithp_banner_id,p_count,p_payment_type, and the rolledp_resultsarray. - Server validates wallet and pity, applies pity gates, persists
player_pity, debits the wallet, and returns the canonicalwallet,tickets,pity, andnew_entitiessnapshot. - Client replaces wallet state from the snapshot (
wallet.replaceFromSnapshot) and updates the per-banner pity counter viaplayerStore.setPity.
If the RPC throws, the client flips syncStatus to error and surfaces the message — no local mutations are applied.
Duplicates and the isNew flag
V4 pulls do not roll per-rarity hull pools — every hull is equally likely. Duplicates are expected. After the RPC succeeds, the client walks v4Results and calls inventory.grantShipPull(shipId) for each one:
- First pull of a hull:
unlocked = true,xpGained = 0, the hull is added to the inventory, andV4_PULL_UNLOCKis tracked. The hull ID is pushed ontonewShipsso the reveal animation can flag it as new (the legacyPullResulttype indata/pull-rates.tscarries this as theisNewboolean). - Subsequent pulls of the same hull:
unlocked = false,xpGained = 1, andV4_PULL_XP_GAINis tracked with the old/new XP and astarUpboolean derived fromstarFromXp. Stars increase as the hull accumulates duplicate XP.
The client snapshots pre-pull XP per shipId (localXp map) before applying grants, so a 10-pull that rolls the same hull multiple times still reports the correct per-result delta to the reveal engine.
Pity persistence
Pity is stored in the player_pity table, keyed by banner. The client reads it from playerStore.pity[bannerId] and writes it back from the RPC response — never locally. getBannerPity(bannerId) exposes the current counter for UI; loadPity is deprecated. See pity-system for the soft/hard thresholds and the 10-pull floor.
Rarity rates
Two rarity tables exist in the codebase and must stay aligned:
| Rarity | pull-config.ts | pull-rates.ts |
|---|---|---|
| common | 0.55 | 0.60 |
| uncommon | 0.27 | 0.25 |
| rare | 0.12 | 0.10 |
| epic | 0.06 | 0.05 |
| legendary | 0.00 | 0.00 |
pullService.ts imports SHIP_RARITY_RATES from pull-config.ts, so the pull-config numbers are the live rates. Legendary is currently zero — no hulls roll legendary from banners. Rarity is preserved on the wire as a UI label only; v4 hull stats are not rarity-scaled.
Source
src/starship-survivors/services/pullService.ts— RPC orchestration, local rolling, inventory grant, telemetrysrc/starship-survivors/data/pull-config.ts— costs, pity thresholds, banner definitions, live rate tablesrc/starship-survivors/data/pull-rates.ts— alternate rate table, pity thresholds,PullResultshape withisNew