# Saves and progression

> Typed, versioned saves kept on the device and synced to the game's backend when it has one, plus currencies, a shop, unlocks, XP, daily streaks, offline earnings, player settings and a shareable score card.

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.

```tsx
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

```ts
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

| 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](https://docs.moonspell.app/engine/saves.md#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](https://docs.moonspell.app/engine/saves.md#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](https://docs.moonspell.app/engine/saves.md#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.

```ts
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:

- `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.

| 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

```tsx
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

```tsx
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 (`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](https://docs.moonspell.app/engine/net.md)), 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](https://docs.moonspell.app/backend/overview.md) 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.

```ts
// 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](https://docs.moonspell.app/engine/net.md): the player id, leaderboards and rooms.
- [Audio](https://docs.moonspell.app/engine/audio.md) and [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md): what follows the settings, and where to show currencies.
- [Puzzle module](https://docs.moonspell.app/modules/puzzle.md) and [Kingdom module](https://docs.moonspell.app/modules/kingdom.md): both keep their progress in an engine save.
- [Examples](https://docs.moonspell.app/examples.md): the puzzle examples (Moon Blocks, Moonrise, Gem Cascade) and Oath of Ashvale save progress through their modules.
- [Backend overview](https://docs.moonspell.app/backend/overview.md): `PlayerSaves` and the rest of the game's backend.
