Saves and progression
Everything a game keeps between sessions goes through src/engine/save: levels reached, coins, skins, upgrades, a high score, a tutorial already shown. A save lives on the device first, so the game opens instantly and plays offline. When the game has a backend, the same save syncs to it by itself and shows up on the player's other devices. Nothing in your game code changes between the two.
Use it whenever a game remembers anything. Do not write your own keys to MMKV, AsyncStorage or localStorage: an engine save survives game updates (versions and migrations), merges two devices, and never throws data away on a bad read.
The module has four parts:
| Part | Use it for |
|---|---|
defineSave | The game's own state with a typed shape: levels reached, story flags, a built base. |
defineProgression | One save with an economy: currencies, a shop, unlocks and items, XP and levels, a daily streak, offline earnings, bests and stats, plus your own fields. |
Settings (useSettings, firstTime) | Sound, music, vibration, volume, left-handed controls, sensitivity, reduced motion, one-time tips. Kept on the device, never synced. |
ScoreCard and shareScore | A best-score card shared as an image. |
Everything below is exported from @/engine.
Smallest working example
A save with two fields, a button that changes it, and a screen that re-renders when it changes.
import React from 'react';
import { Pressable, Text, View } from 'react-native';
import { defineSave, useSave, type GameDefinition, type GameScreenProps } from '@/engine';
// Define each save once, at module scope.
const save = defineSave({
version: 1,
defaults: { coins: 0, bestRun: 0 },
});
const Game = ({ onExit }: GameScreenProps) => {
const data = useSave(save); // re-renders on every change, local or synced
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<Text>Coins: {data.coins}</Text>
<Pressable onPress={() => save.update((s) => { s.coins += 10; })}>
<Text>Earn 10</Text>
</Pressable>
<Pressable onPress={onExit}>
<Text>Exit</Text>
</Pressable>
</View>
);
};
const game: GameDefinition = {
id: 'coin-tap',
title: 'Coin Tap',
description: 'Tap to earn coins. They are still there next time.',
Component: Game,
};
export default game;
Close the game and open it again: the coins are still there.
defineSave
import { defineSave, useSave } from '@/engine';
export const save = defineSave({
version: 2,
defaults: { coins: 0, skins: ['classic'], bestRun: 0, tutorialDone: false },
migrations: {
// Version 1 kept skins as a comma string. Each step gets a copy of the old data.
2: (old) => ({ ...old, skins: String(old.skins ?? 'classic').split(',') }),
},
});
save.get().coins; // read anywhere, outside React too
save.update((s) => { s.coins += 10; }); // change a copy in place, or return a new value
save.set({ ...save.get(), bestRun: 1800 });
save.reset(); // start over, on every device
const data = useSave(save); // in a component
defineSave returns the save's main slot, a SaveSlot. Save<T> is the type name for it.
Changing the shape
- Adding a field needs no migration. Put it in
defaults. Old saves get it the next time they load. This works recursively inside nested objects. - Renaming, retyping or splitting a field needs one. Bump
versionand addmigrations[newVersion]. Migrations run in order, from the version the save was written with up to the current one, each on a copy. - A field whose type no longer matches the default (a number that became an object) takes the default.
- Fields the defaults do not know are kept, so a newer save opened by an older build loses nothing.
What is never lost
- A save that cannot be read is kept aside under the storage key
<key>.<slot>.corrupt, and the game starts from the defaults. - A migration that throws keeps the old save under
<key>.<slot>.v<old version>and carries on with what it has. - When two devices changed the same slot and you gave no
merge, the copy changed last wins and the other is kept under<key>.<slot>.backup.
The default key is save, so the main slot is stored as save.main.
SaveSpec options
| Option | Type | Default | What it does |
|---|---|---|---|
version | number | required | The shape's version, a whole number from 1. Bump it when old data needs converting. |
defaults | T | required | A new player's save. New fields here reach existing saves by themselves. |
migrations | Record<number, (old) => unknown> | none | migrations[n] turns a version n - 1 save into version n. |
legacy | (store) => unknown | none | Reads what the game kept before it used saves (its own old keys), the first time only, into the main slot. |
sanitize | (data: T) => T | none | Runs after every load and change: clamp numbers, drop items that no longer exist. |
merge | (local: T, remote: T) => T | last change wins | Combines two copies that changed the same slot. |
key | string | 'save' | Storage key prefix. Two saves in one game need different keys; the key keeps them apart on the device and on the backend (see Sync). |
sync | boolean or string | true | true syncs when GAME_BACKEND_URL is set. false keeps the save on this device. A string is a backend URL to use instead. |
store | KeyValue | the game's on-device store | Where to keep it. Tests pass memoryStore(). |
transport, debounceMs, live | Sync wiring for tests (see Testing). |
SaveSlot methods
| Method | Returns | What it does |
|---|---|---|
get() | T | The current data. Treat it as read-only. |
update(fn) | T | fn gets a copy to change in place, or returns a new value. Written to the device at once, sent to the backend a moment later. |
set(data) | T | Replaces the data. |
reset() | T | Back to the defaults on every device. Other devices take the reset instead of merging it away. |
subscribe(listener) | unsubscribe function | Called with the data after every change: local, pulled or merged. |
slot(id) | SaveSlot<T> | Another slot of the same save. Ids are 1 to 32 lowercase letters, digits, - or _. |
slots() | string[] | Slot ids with data on this device or on the server. |
exists() | boolean | Whether anything was ever saved in this slot, for a slot picker. |
meta() | SaveMeta | { slot, version, at, dirty, synced }. |
syncStatus() | SyncStatus or 'local' | See Sync. |
onSyncStatus(listener) | unsubscribe function | Called when the sync status changes. |
syncNow() | Promise<void> | Sends and fetches now, for example before showing a "synced" mark. |
Most games need only the main slot. Use save.slot('2') for a second save file.
defineProgression
One save that holds a whole economy. Define it once, for example in src/game/progress.ts, and import it wherever you need it.
import { defineProgression, useProgression } from '@/engine';
export const progress = defineProgression({
currencies: { coins: 0, gems: 0 },
caps: { gems: 9999 },
shop: [
{ id: 'neon', name: 'Neon board', price: { coins: 500 } }, // unlock, bought once
{ id: 'magnet', kind: 'upgrade', price: { coins: 100 }, maxLevel: 5, growth: 1.8 }, // levels, dearer each time
{ id: 'revive', kind: 'consumable', price: { gems: 1 } }, // into the inventory
{ id: 'coin-pack', kind: 'bundle', price: { gems: 5 }, gives: { coins: 1000 } }, // currency exchange
{ id: 'night-city', price: { coins: 2000 }, requires: { level: 10 } },
],
xp: { base: 100, growth: 1.25, maxLevel: 50 },
levelRewards: (level) => (level % 5 === 0 ? { gems: 5 } : { coins: 50 * level }),
daily: { rewards: [{ coins: 50 }, { coins: 75 }, { coins: 100 }, { gems: 2 }, { coins: 150 }, { coins: 200 }, { gems: 5 }] },
offline: { currency: 'coins', perSecond: (p) => 0.5 + (p.upgrades.magnet ?? 0) * 0.5, capHours: 8 },
records: { distance: 'desc', time: 'asc' },
starting: { unlocked: ['classic'], equipped: { board: 'classic' } },
game: { tutorialDone: false, worldsSeen: [] as string[] },
});
progress.earn('coins', 25);
const result = progress.buy('magnet'); // { ok: true, ... } or { ok: false, reason: 'poor', missing }
progress.addXp(40); // { gained, level, rewards }
In a component, useProgression(progress) returns the data and re-renders on change. It also marks the player as present when the app goes to the background and when the screen unmounts, so offline earnings count from when the player really left.
ProgressionSpec options
| Option | Default | What it does |
|---|---|---|
currencies | { coins: 0 } | Starting balances. Also the list of currencies. |
caps | none | Highest balance per currency. |
shop | [] | ShopItem[], see below. |
xp | {} | The XP curve, see below. |
levelRewards | none | What reaching a level pays: a table ({ 5: { gems: 1 } }) or a function of the level. |
daily | none | { rewards: Price[] }, the daily calendar (day 1, day 2 and so on), cycling. |
offline | none | { currency, perSecond(p), capHours?, minSeconds? }: income while away. capHours defaults to 8, minSeconds to 60. |
records | every key 'desc' | Which bests count lower as better: { time: 'asc' }. |
starting | none | A new player's unlocked, equipped and items. |
game | {} | Your own fields and their defaults, kept in p.game. |
version, migrations | 1, none | Bump with a migration when your own fields change shape. |
legacy | none | Reads old progress once. Return a partial Progress. |
mergeGame | the copy played last | Combines your own fields from two devices. |
key | 'progress' | Storage key prefix. |
sync, store, transport, debounceMs, live | Same as defineSave. |
Shop items
| Field | Default | What it does |
|---|---|---|
id | required | The item's id. |
name | the id | Shown in the shop. |
price | required | A Price such as { coins: 100 }. For an upgrade, the first level's price. |
kind | 'unlock' | unlock: bought once (a skin, a character, a world). upgrade: bought level by level. consumable: bought again and again into the inventory. bundle: turns one currency into another. |
maxLevel | 10 | Upgrade levels. |
growth | 1.6 | Each upgrade level costs this many times the last. |
gives | 1 | Consumable: how many one purchase adds. Bundle: the currencies it adds, as a Price. |
requires | none | { level?, unlocked? }: only for sale from that player level, or once those ids are unlocked. |
Progression methods
| Method | What it does |
|---|---|
balance(c), earn(c, n), earnAll(price) | Read and add to the wallet. Fractions are kept (idle income); show them with Math.floor. |
canAfford(price), spend(price) | spend returns false and takes nothing when the player cannot pay. |
buy(id) | Pays and hands the item over. Returns { ok: true, state, price } or { ok: false, reason, price?, missing? }. |
checkBuy(id) | The same answer without buying, for a shop screen that says why. |
priceOf(id) | The next purchase's price (an upgrade's next level). |
upgradeLevel(id) | An upgrade's current level, 0 when never bought. |
isUnlocked(id), unlock(id) | Things owned for good. |
equip(slot, id), equipped(slot) | Puts an unlocked id in a slot. equip returns false for an id that is not unlocked. |
itemCount(id), addItem(id, n?), consumeItem(id, n?) | Counted things. consumeItem returns false when there are not enough. |
openLoot(table, rng?, rolls?) | Rolls a weighted loot table and hands over the drops. |
addXp(n) | Levels up as many times as it reaches and pays level rewards. Returns { gained, level, rewards }. |
checkIn() | Call when the game opens. Returns { status, streak, reward }. |
canClaimDaily(), claimDaily(multiplier?) | Pays today's reward once. Pass 2 for a doubled claim. Returns the reward, or null if already claimed. |
pendingOffline(), collectOffline(multiplier?) | Income while away, as { seconds, amount, currency }. Collecting restarts the clock. |
touch() | Marks the player as present now. useProgression calls it for you. |
best(key), recordBest(key, value) | Personal bests. recordBest returns { improved, previous, best }. |
stat(key), bump(key, by?) | Lifetime counters: runs played, coins ever earned. |
get(), update(fn), subscribe(fn), reset() | Anything else, including p.game. |
save, shop, spec | The underlying SaveSlot, the shop list and the spec. |
buy fails with one of these reasons:
reason | Meaning |
|---|---|
unknown | No shop item has that id. |
owned | An unlock the player already has. |
maxed | An upgrade at maxLevel. |
locked | requires is not met. |
poor | Not enough currency. missing says how much more of each. |
checkIn() returns one of four statuses: started (first check-in), continued (yesterday was checked in, the streak grows), broken (a day was missed, the streak restarts at 1) or same-day.
Loot tables are arrays of entries, each with an optional weight (default 1): { item, count? }, { currency, amount } or { nothing: true }. count and amount can be a number or a [min, max] range.
The XP curve
| Field | Default | What it does |
|---|---|---|
base | 100 | XP from level 1 to level 2. |
growth | 1.2 | Each level needs this many times the last. |
maxLevel | 99 | Highest level. XP stops counting there. |
table | none | Exact XP per level instead: table[0] is level 1 to 2. Levels past its end repeat the last entry. |
xpToNext(level, curve) and levelProgress(state, curve) (0 to 1, for a progress bar) work on any { level, xp }.
Two devices
Progression merges field by field, so no unlock, level or best is lost:
unlockedjoins both lists.level,xpandtotalXpcome from the copy with more total XP.upgrades,statsandbestskeep the better value per key.- The wallet,
items,equippedandgamecome from the copy played last. PassmergeGame(local, remote)to combine your own fields another way.
mergeProgress(local, remote, spec) is the same merge as a function.
Time
gameNow() is the server's clock once the game has talked to its backend, and the device's clock before that or without one. Daily streaks and offline earnings use it, so moving the device's date does not farm rewards once a backend exists. A clock moved backwards earns no offline income.
| Function | What it does |
|---|---|
gameNow() | Milliseconds since the epoch, server-anchored when known. |
hasServerTime() | Whether gameNow() is anchored to the server. |
dateKey(date?) | The local calendar day as YYYY-MM-DD. |
previousDay(key), daysBetween(from, to) | Day arithmetic on those keys. |
Settings
import React from 'react';
import { Pressable, Text } from 'react-native';
import { firstTime, useSettings } from '@/engine';
export const SoundToggle = () => {
const [settings, setSettings] = useSettings();
return (
<Pressable onPress={() => setSettings({ sound: !settings.sound })}>
<Text>Sound: {settings.sound ? 'on' : 'off'}</Text>
</Pressable>
);
};
// Anywhere: true the first time only, on this device.
if (firstTime('swipe-hint')) {
// show the hint
}
| Field | Default | Who reads it |
|---|---|---|
sound | true | The engine: the audio mixer's effect and UI buses, and one-shot sounds. |
music | true | The engine: the audio mixer's music bus. |
haptics | true | The engine: every haptics.* call. |
volume | 1 | The engine: master volume, clamped to 0..1. |
leftHanded | false | Your game: mirror the controls. |
sensitivity | 1 | Your game: scale look, steering and aim. Clamped to 0.25..2. |
reducedMotion | false | Your game: less shake, flashing and slow motion. |
seen | {} | firstTime(key). |
custom | {} | Your own settings: setSettings({ custom: { difficulty: 'hard' } }). |
setSettings merges seen and custom key by key. Settings belong to the device and do not sync.
| Function | What it does |
|---|---|
useSettings() | [settings, setSettings], re-rendering when any screen changes them. |
getSettings(), setSettings(change) | The same outside React. getSettings is cached and cheap. |
resetSettings() | Back to DEFAULT_SETTINGS, keeping seen. |
onSettings(listener) | Called after every change. Returns unsubscribe. |
firstTime(key) | true the first time it is asked for key, false after. |
importSettings(read) | Moves a game's own old setting keys over, once. |
Share a score
import React, { useRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import { ScoreCard, shareScore, type ScoreCardRef } from '@/engine';
export const Results = ({ score, best }: { score: number; best: number }) => {
const card = useRef<ScoreCardRef>(null);
return (
<View>
<ScoreCard
ref={card}
title="Sky Runner"
score={score}
label="METRES"
best={best}
newBest={score >= best}
lines={['Day 4 streak']}
colors={{ background: '#0b1020', text: '#ffffff', accent: '#ffcc4d' }}
/>
<Pressable onPress={() => shareScore(card, { message: `I ran ${score} m in Sky Runner. Beat that?` })}>
<Text>Share</Text>
</Pressable>
</View>
);
};
ScoreCard prop | Default | What it does |
|---|---|---|
title | required | The game's name. |
score | required | A number (formatted with thousands separators) or a string. |
label | 'SCORE' | Under the score: 'BEST TIME', 'LEVEL'. |
best | none | Shown as "Best ..." unless newBest is set. |
newBest | false | Shows a "NEW BEST" tag. |
lines | [] | Small lines under the score. |
colors | dark gray, white | { background, text, accent, muted? }. |
fontFamily, bodyFontFamily | system | Display font and UI font. bodyFontFamily defaults to fontFamily. |
width | 300 | Card width in points. |
children | none | Drawn above the title: an emblem, a medal. |
shareScore(ref, { message }) captures the view as a PNG and opens the share sheet. It works with any view, not only ScoreCard (the puzzle module shares its results panel this way). It returns false if the player cancelled or sharing failed. Inside the Moonspell app it also appends the game's share link; elsewhere the message goes out without one.
Sync
There is nothing to call. When GAME_BACKEND_URL in src/engine/net/config.ts is set:
- A save pulls the server's copy the first time it is read.
- It pushes a change about 1.5 seconds after it happens. Changes in between go together.
- While a screen shows it (
useSaveoruseProgression), a live socket brings in what the player's other devices write. - Conflicts merge: your
merge, field by field indefineProgression, otherwise the copy changed last wins and the other is kept as a backup. reset()replaces the save on every device.
useSyncStatus(save) returns 'local' (no backend), 'idle', 'syncing', 'offline' or 'error', for a small cloud indicator if you want one.
Each save has its own slots on the backend. The default save (key: 'save') stores a slot under its own id (main); a save with any other key stores it as <key>__<slot> (progress__main for a defineProgression, a hashed form for keys with dots or colons such as the puzzle module's). So a defineSave and a defineProgression both on slot main never overwrite each other. Engines before 2026-11-11 stored every save under the bare slot id, so two saves in one game did overwrite each other there; the engine now repairs that by itself: a save with its own key reads its old copy once, when the data looks like its own, and copies it to its own id, and the default save ignores another save's leftover write in its slot and writes its own copy over it.
The player is the device's anonymous id (getPlayerId() from Networking), the same one the leaderboards use. On the backend, saves live in a PlayerSaves Durable Object, one per player, with a SQLite copy of each slot. The backend accepts saves up to 64 KB of JSON. See the backend overview for how to set it up and how to make parts of an economy authoritative on the server.
Without a backend, every call above works the same and the data stays on the device. Saves start syncing on their own once a backend exists.
Testing
@/engine/save/testing has two test doubles: memoryStore() (a device) and memorySaveServer() (the backend's write rule in memory). Use them to unit-test your migrations and merges with node:test. Import defineSave from @/engine/save/save in tests: the @/engine barrel pulls in React Native, which does not load under Node.
// src/game/progress.test.ts, run with: node --import tsx --test src/game/progress.test.ts
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { defineSave } from '@/engine/save/save';
import { memoryStore } from '@/engine/save/testing';
describe('save migrations', () => {
it('turns the v1 comma string into a list', () => {
const store = memoryStore();
// A v1 save, stored the way the engine stores it.
store.set('save.main', JSON.stringify({ v: 1, at: 1, epoch: 0, synced: 0, dirty: false, data: { coins: 5, skins: 'classic,neon' } }));
const save = defineSave({
version: 2,
defaults: { coins: 0, skins: ['classic'] },
migrations: { 2: (old) => ({ ...old, skins: String(old.skins).split(',') }) },
store,
sync: false,
});
assert.deepEqual(save.get().skins, ['classic', 'neon']);
});
});
To test two devices, give each its own memoryStore() and the same server: defineSave({ ...spec, store: memoryStore(), transport: server.transport(), live: server.live, debounceMs: 60_000 }), then call syncNow(). server.slots is keyed by the backend's ids: main for the default save, progress__main for a progression.
The pure helpers behind progression are exported too, if you want them without a save: earn, spend, canAfford, missing, priceOf, checkBuy, buy, unlock, equip, addItem, consumeItem, rollLoot, addXp, checkIn, dailyReward, offlineEarnings, recordBest, betterOf, bump, fillDefaults, cloneJson.
Gotchas
- Write on events, not every frame. A coin pickup adds to a run total in memory; the end of the run calls
earn('coins', total)once. - Saves are JSON. No
Date,Map,Setor class instances. Store times as numbers (gameNow()). - Do not mutate
get(). Change data only throughupdateorset, or listeners will not fire and nothing is written. - Define a save once, at module scope. Two
defineSavecalls with the samekeyin one game fight over the same storage. - Set
GAME_IDinsrc/engine/net/config.tsto your game's slug. It names the game's on-device store; two games with the same id on one device (or one web origin) share their saves. While it is still the template's placeholder, the engine uses theslugfrom yourapp.jsoninstead and warns once in development, so two copies of the template only share saves if theirapp.jsonslugs are the same too. - Settings are not progress. Never put coins in
custom; it does not sync. - A fair economy needs the server. On the device, a determined player can edit their save. Rewards with real value, trades and competitive economies belong on the backend.
See also
- Networking: the player id, leaderboards and rooms.
- Audio and HUD and UI: what follows the settings, and where to show currencies.
- Puzzle module and Kingdom module: both keep their progress in an engine save.
- Examples: the puzzle examples (Moon Blocks, Moonrise, Gem Cascade) and Oath of Ashvale save progress through their modules.
- Backend overview:
PlayerSavesand the rest of the game's backend.