Skip to main content

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:

PartUse it for
defineSaveThe game's own state with a typed shape: levels reached, story flags, a built base.
defineProgressionOne 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 shareScoreA 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 version and add migrations[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​

OptionTypeDefaultWhat it does
versionnumberrequiredThe shape's version, a whole number from 1. Bump it when old data needs converting.
defaultsTrequiredA new player's save. New fields here reach existing saves by themselves.
migrationsRecord<number, (old) => unknown>nonemigrations[n] turns a version n - 1 save into version n.
legacy(store) => unknownnoneReads what the game kept before it used saves (its own old keys), the first time only, into the main slot.
sanitize(data: T) => TnoneRuns after every load and change: clamp numbers, drop items that no longer exist.
merge(local: T, remote: T) => Tlast change winsCombines two copies that changed the same slot.
keystring'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).
syncboolean or stringtruetrue syncs when GAME_BACKEND_URL is set. false keeps the save on this device. A string is a backend URL to use instead.
storeKeyValuethe game's on-device storeWhere to keep it. Tests pass memoryStore().
transport, debounceMs, liveSync wiring for tests (see Testing).

SaveSlot methods​

MethodReturnsWhat it does
get()TThe current data. Treat it as read-only.
update(fn)Tfn 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)TReplaces the data.
reset()TBack to the defaults on every device. Other devices take the reset instead of merging it away.
subscribe(listener)unsubscribe functionCalled 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()booleanWhether 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 functionCalled 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​

OptionDefaultWhat it does
currencies{ coins: 0 }Starting balances. Also the list of currencies.
capsnoneHighest balance per currency.
shop[]ShopItem[], see below.
xp{}The XP curve, see below.
levelRewardsnoneWhat reaching a level pays: a table ({ 5: { gems: 1 } }) or a function of the level.
dailynone{ rewards: Price[] }, the daily calendar (day 1, day 2 and so on), cycling.
offlinenone{ currency, perSecond(p), capHours?, minSeconds? }: income while away. capHours defaults to 8, minSeconds to 60.
recordsevery key 'desc'Which bests count lower as better: { time: 'asc' }.
startingnoneA new player's unlocked, equipped and items.
game{}Your own fields and their defaults, kept in p.game.
version, migrations1, noneBump with a migration when your own fields change shape.
legacynoneReads old progress once. Return a partial Progress.
mergeGamethe copy played lastCombines your own fields from two devices.
key'progress'Storage key prefix.
sync, store, transport, debounceMs, liveSame as defineSave.

Shop items​

FieldDefaultWhat it does
idrequiredThe item's id.
namethe idShown in the shop.
pricerequiredA 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.
maxLevel10Upgrade levels.
growth1.6Each upgrade level costs this many times the last.
gives1Consumable: how many one purchase adds. Bundle: the currencies it adds, as a Price.
requiresnone{ level?, unlocked? }: only for sale from that player level, or once those ids are unlocked.

Progression methods​

MethodWhat 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, specThe underlying SaveSlot, the shop list and the spec.

buy fails with one of these reasons:

reasonMeaning
unknownNo shop item has that id.
ownedAn unlock the player already has.
maxedAn upgrade at maxLevel.
lockedrequires is not met.
poorNot 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​

FieldDefaultWhat it does
base100XP from level 1 to level 2.
growth1.2Each level needs this many times the last.
maxLevel99Highest level. XP stops counting there.
tablenoneExact 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:

  • unlocked joins both lists.
  • level, xp and totalXp come from the copy with more total XP.
  • upgrades, stats and bests keep the better value per key.
  • The wallet, items, equipped and game come from the copy played last. Pass mergeGame(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.

FunctionWhat 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
}
FieldDefaultWho reads it
soundtrueThe engine: the audio mixer's effect and UI buses, and one-shot sounds.
musictrueThe engine: the audio mixer's music bus.
hapticstrueThe engine: every haptics.* call.
volume1The engine: master volume, clamped to 0..1.
leftHandedfalseYour game: mirror the controls.
sensitivity1Your game: scale look, steering and aim. Clamped to 0.25..2.
reducedMotionfalseYour 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.

FunctionWhat 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 propDefaultWhat it does
titlerequiredThe game's name.
scorerequiredA number (formatted with thousands separators) or a string.
label'SCORE'Under the score: 'BEST TIME', 'LEVEL'.
bestnoneShown as "Best ..." unless newBest is set.
newBestfalseShows a "NEW BEST" tag.
lines[]Small lines under the score.
colorsdark gray, white{ background, text, accent, muted? }.
fontFamily, bodyFontFamilysystemDisplay font and UI font. bodyFontFamily defaults to fontFamily.
width300Card width in points.
childrennoneDrawn 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 (useSave or useProgression), a live socket brings in what the player's other devices write.
  • Conflicts merge: your merge, field by field in defineProgression, 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, Set or class instances. Store times as numbers (gameNow()).
  • Do not mutate get(). Change data only through update or set, or listeners will not fire and nothing is written.
  • Define a save once, at module scope. Two defineSave calls with the same key in one game fight over the same storage.
  • Set GAME_ID in src/engine/net/config.ts to 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 the slug from your app.json instead and warns once in development, so two copies of the template only share saves if their app.json slugs 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: PlayerSaves and the rest of the game's backend.