---
name: incanto-save-slots
description: Continue where you left off — Behavior serialize()/deserialize(), engine.captureState()/restoreState(), and a SaveSlots layer over the save store. A save carries behavior state keyed by node uid and reloads the scene from source; it does not snapshot the tree. Use for any game with progress worth keeping.
---

# Save slots

Let a player close the tab and come back to their run.

## What a save IS

**Which scene, plus every behavior's state, keyed by node uid.**

```json
{
  "scene": "village",
  "state": { "n_player7x": { "current": 62 }, "n_score2k": { "score": 1400 } },
  "label": "Chapter 2 · Emberwood",
  "savedAt": 1754160000000,
  "playtime": 812,
  "data": { "difficulty": "hard" }
}
```

Loading = **load the scene from its file**, then hand each behavior its state
back by uid.

## What a save is NOT, and why

It is not a snapshot of the live tree. That is deliberate, and it is about this
engine specifically:

- `Spawner.onReady` **detaches** its prefab template from the tree. A captured
  tree and a freshly booted one legitimately disagree about which nodes exist,
  on every scene with a spawner.
- A spawned enemy that carries its own spawner has already lost *its* template,
  so re-adding that subtree runs `onReady` against a node that is gone and
  throws — killing the whole load.
- Prop deltas cannot be applied without resetting every other prop to default.

Reloading from source sidesteps all three. The structure comes from the file
(authoritative, already validated); the save carries only what the file cannot
know.

**The cost, stated plainly: spawned enemies, mid-level positions and anything
else that lives in the TREE are not restored.** You resume at the scene's start
with stats, inventory, unlocks and quest flags intact — a checkpoint save. If
your game needs a position, save it: `serialize()` returns anything.

"Inventory" there means a behavior's DATA — a list of item ids, a wallet, a
quest flag. An inventory made of WIDGETS MOVED BETWEEN SLOTS
(`incanto-hud.md`'s drag recipe) is structure, so it reloads exactly as the
file has it: measured on a bench with one item dragged across, the save
mentions no slot and the reload puts the item back on its shelf. That skill
prints the `serialize` that keeps the slot map as data.

**What the run CONSUMED is remembered.** Reloading from the file brings back
every gem you already picked up, which would let a collect-five-to-win run
resume at four with five gems on the map. So the save also carries the authored
uids that are no longer in the tree, under `#freed`, and the restore frees them
again — collectibles, opened chests, destroyed crates, a named boss. Spawned
clones never have uids (`duplicateNode` drops them on purpose), so the ledger is
exactly the authored world.

Those nodes go on the **next frame** (`queueFree`, not an immediate detach), so
read `report.freed` rather than counting children the instant restore returns.

## Making a behavior saveable

Two optional hooks, exactly like the other five:

```ts
class QuestLog extends Behavior {
  accepted = false;
  wolvesKilled = 0;

  override serialize() {
    return { accepted: this.accepted, wolvesKilled: this.wolvesKilled };
  }

  override deserialize(data: JsonValue) {
    const d = data as { accepted?: unknown; wolvesKilled?: unknown };
    if (typeof d.accepted === 'boolean') this.accepted = d.accepted;
    if (typeof d.wolvesKilled === 'number') this.wolvesKilled = d.wolvesKilled;
  }
}
```

**Save only what a fresh `onReady` could not recreate.** `current` health yes;
`maxHealth` no — that comes back from the scene JSON, and duplicating it makes
old saves fight your balance patches.

**Randomness is carried for you.** `captureState()` records where
`engine.rng` has got to and `restoreState()` puts it back, because without that
a load replays the seeded sequence from the top — the first "random" event after
a save is the one the RUN opened with, and every loot roll, wander and spawn
wobble repeats the opening of the game. Nothing to do; `engine.rng.position` is
readable if you want it for something else.

`deserialize` is defensive on purpose: that data may come from a build of your
game that shipped six weeks ago. Check what you read.

Built-ins that already save: `Checkpoint` (which one was lit),
`Collector` (total), `Currency` (amount), `DayNight` (hour, paused),
`Health` (current, dead), `Phases` (which phase the fight reached),
`SavePoint` (playtime, and the poses it `keep`s),
`ScoreKeeper` (score, lives, won/lost), `WaveSpawner` (which wave).

`Checkpoint` restores the `Respawn` redirect as well as its own `lit` flag, and
when a run lit several it comes back to the one it reached LAST — the order they
announce in does not decide where you land. Without that, a continued run's
first fall went back to the level's START, past every checkpoint the session had
reached, and the report was green because a behaviour with no `serialize` is not
counted as saveable in the first place.

`WaveSpawner` RESTARTS the wave you were on rather than resuming mid-spawn —
spawned entities are never restored, so resuming "four enemies into wave 3"
would resume a wave whose enemies do not exist. `waveStarted` fires again, so a
HUD wired to it catches up on its own.

Every one of them needs a `uid` on its node, and `incanto-check` says so if it
has none — that check reads the same list.

## Tell the screen: `announce()`

`deserialize` writes fields. Your HUD is wired to **signals** — that is what
`incanto-hud.md` teaches and the only thing the editor can wire — so a restore
that only writes fields leaves the screen showing a fresh start:

```
HUD    score="0"    hp=100/100  gems="0"
TRUTH  score=1400   hp=38       gems=7
```

with no error anywhere and a restore report of `{missing: [], restored: 3}`.

So there is a third hook, called once after the whole restore pass:

```ts
override announce() {
  this.emit('questChanged', this.stage);   // what the screen SHOWS
}
```

**Emit only what displays.** Not `died`, not `won`, not `levelUp` — a save is
being read, nothing just happened, and re-firing an outcome signal on load is
how a Continue lands straight on the game-over screen it was loaded to escape.
It runs after every `deserialize` in the pass, so a handler that reads a sibling
sees restored values there too.

`Health`, `ScoreKeeper`, `Collector` and `Currency` implement it. `ScoreKeeper`
gained **`livesChanged`** for this: `lifeLost` is the EVENT (flash, sound,
respawn) and fires only on a real loss, while `livesChanged` is the COUNT and
fires on both. Wire a lives counter to `livesChanged`.

## Every node you save — and every node that VANISHES — needs a uid

A collectible does not save state; it DISAPPEARS, and disappearing is the thing
the save has to record. `#freed` is keyed by uid, so a pickup without one is
silently omitted: the score that counted it restores, and the pickup restores
too. Measured on a starter with 1 uid across 141 nodes:

```
coins after collecting : 9
save["#freed"]         : undefined
restore report         : { restored: 1, expected: 1, freed: 0 }
coins AFTER load       : 12          <- three coins resurrected
auditScene warnings    : []          <- and every checker was green
```

`captureState` now reports this as an ERROR naming the nodes, because the save
it just wrote is already wrong. It does not guess a replacement: a uid survives
a rename and a reparent, and a saved PATH would point at whatever node moved
into that slot after your next edit.

**Put the uid on the node that OWNS the state, not on its art.** A collectible
is normally one node with the script and a plain child for the sprite:

```jsonc
{ "name": "Sword", "type": "Area2D", "uid": "n_sr9scvhygy01mgxr",
  "script": { "name": "ItemPickup" },
  "children": [ { "name": "Icon", "type": "Sprite2D" } ] }   // no uid needed
```

Freeing the sword takes the icon with it, so the parent's uid in `#freed`
records both — the audit says nothing about the child, and adding a uid to it
changes nothing. What the audit still catches is the child that goes while its
parent LIVES: that one really does come back.



The uid is the join key, because it is the one identifier that survives a rename
or a reparent. The editor assigns one to every node it touches. A hand-written
scene may not have them, and then the save is silently empty — so
**`incanto-check` warns about it**, naming each node, long before you write a
save:

```
warn: these carry state a save keeps and have no uid to key it under, so the
      save comes back EMPTY and the load reports no problem: Game (ScoreKeeper),
      Game/Player (Health). Give each one a "uid" from newUid().
```

At runtime `engine.captureState()` logs the same thing per node.

Never hand-craft a uid. Use `newUid()`.

## The two-button version

For a game whose save is "this scene, right now", `GameFlow` has the verbs and
you write no TypeScript at all:

```json
{ "from": "/root/HUD/PauseMenu/Save",     "signal": "pressed", "to": "/root/Flow", "handler": "save" },
{ "from": "/root/HUD/PauseMenu/Continue", "signal": "pressed", "to": "/root/Flow", "handler": "continueFrom" }
```

with `"script": { "name": "GameFlow", "props": { "saveSlots": "beacon-isle" } }`
naming where the slots live. `save(slot)` writes `captureState()` under the
scene's own name; `continueFrom(slot)` reloads the scene and restores AFTER
`onReady`, which is the ordering everything below is about; `hasSave(slot)` is
what a CONTINUE button asks before it offers itself. A slot written by ANOTHER
scene is refused with a sentence — routing between levels is yours, and the rest
of this page is how to do it. Composed in `examples/beacon-isle-3d`.

## Saving and loading

```ts
import { SaveSlots } from 'incanto';

const slots = new SaveSlots('emberwood');

// save
slots.write('1', {
  scene: currentSceneKey,
  state: game.engine.captureState(),
  label: 'Chapter 2',
  savedAt: Date.now(),
  playtime: elapsed,
});

// load
const slot = slots.read('1');
if (slot) {
  await loadSceneByKey(slot.scene);        // your routing — the engine does not route
  const report = game.engine.restoreState(slot.state);
  if (report.missing.length) console.warn('save is older than this build:', report.missing);
}
```

`restoreState` runs AFTER the scene has loaded and `onReady` has fired —
`onReady` is where a behavior sets its starting values, so restoring first would
be overwritten.

**Not from `onReady` itself, either**, which is the tempting place to put
"continue my run" and the one that cannot work: the ready pass runs while the
scene is still loading, and `engine.scene` — which both halves of this API read
— is not assigned until `setScene`. Both say so now rather than answering
quietly:

```
[incanto] restoreState() ran before the engine had a scene, and restored NOTHING.
```

Before that it reported every uid in the save as `missing`, which is the
signature of a save format change, so the hunt started in the wrong place
entirely.

**To keep the continue INSIDE the game** (where a harness can reach it —
`runScript` boots the scene, never your `App.tsx`), do it on the first frame:

```ts
class Game extends Behavior {
  private continued = false;
  override update(): void {
    if (this.continued) return;
    this.continued = true;
    const slot = this.slots.read('1');
    if (slot) this.engine.restoreState(slot.state);
  }
}
```

It never throws. A save naming a uid this build deleted reports it in
`report.missing` and restores everything else; refusing to load would mean a
patch that moves one node deletes everyone's progress.

## Many levels: the router is three lines, and they are yours

The engine does not route scenes — deliberately, because only your game knows
what a key means. What it does is record the key, so the routing is a lookup:

```ts
import { loadScene } from 'incanto';
import { createGame2D } from 'incanto/2d';
import level1 from './level1.scene.json';
import level2 from './level2.scene.json';

const SCENES: Record<string, unknown> = { level1, level2 };   // key → scene JSON

// New game
const game = await createGame2D({ canvas, scene: SCENES.level1 });

// Next level — the SavePoint in the new scene writes `level2` from here on
(game.scene.root.getNode('Flow').behavior as GameFlow).goToScene(SCENES.level2);

// Continue
const slot = new SaveSlots('chapters').read('1');
const scene = SCENES[slot?.scene ?? 'level1'];
const game = await createGame2D({ canvas, scene });
// then `restoreOnReady: true` on that scene's SavePoint, or call restore()
```

## Where the player was standing: `keep`

A save is behavior state keyed by uid and deliberately not a snapshot of the
tree, so **mid-level positions do not come back**. That is right for a
checkpoint save and wrong for a Continue: pressing it should put you on the
street you quit on, not at the depot. The escape hatch was one sentence —
"a game that wants a position saves it" — and every game wrote the same
behaviour whose only job is to hold a transform.

The scene says it instead:

```json
{ "name": "Save", "type": "Node3D", "uid": "n_…",
  "script": { "name": "SavePoint",
              "props": { "game": "longhaul", "slot": "1",
                         "keep": ["/root/Courier", "/root/Van"] } } }
```

Each named node's `position` and `rotation` go into the slot alongside the
playtime, and come back in `announce()` — after the whole restore pass, so a
camera rig or a controller restoring on the same frame cannot overwrite them.

- **Each kept node needs a `uid`**, the same join key as the rest of the save,
  so a rename or a reparent survives. A path that names nothing, a node with no
  uid, and a node with no position each say so through `engine.log` on the way
  IN — at save time, when you can still fix it — rather than restoring quietly
  to the wrong place months later.
- A slot written before a node was added to `keep` restores everything else and
  leaves that node where the scene file puts it.
- It is a POSE, not physics: velocity is not kept, so a continued run starts at
  rest. Save a velocity yourself if a game needs one.

A `SavePoint` records `scene` as the scene's own `name` unless you set the prop,
so `level2.scene.json` named `level2` needs no wiring at all. Set `scene`
explicitly when one file is entered more than one way (`"chapter-2-rescue"`).

**The swap clears the input map** (the new scene declares its own `input{}`), and
physics registers the new bodies before that scene's first frame — so a
character walks in level two exactly as it did in level one.

## State that outlives a SCENE, not just a session

A save and a scene swap are different mechanisms and the word "persist" covers
both, so this is the one thing a two-scene quest game has to be told: **the
three hooks above carry state across a SAVE and nothing across a SWAP.**

`goToScene` replaces the tree. The new scene's behaviors are new objects with
`onReady` starting values — a quest flag set in the village is gone when the
cellar loads, and nothing reports it, because nothing went wrong.

A quest that has to survive both is three parts, and each one alone fails
differently. Measured on a two-scene game built for this:

| | swap | save |
| --- | --- | --- |
| a module object in YOUR code | ✅ crosses | ❌ `captureState()` cannot see it — `restored: 0`, and the report is clean |
| `serialize`/`deserialize` alone | ❌ the new scene's behavior starts fresh | ✅ `restored: 1` |
| …plus `announce()` | — | the HUD, which otherwise reads the old value over restored state |

So: keep the truth in a module object, and give ONE behavior that exists in
every scene the job of writing it in and out.

```ts
/** Not engine state — YOUR game's, and a module is where a swap cannot reach. */
export const quest = { accepted: false, carrying: false, done: false };

export class Hold extends Behavior {
  static signals = ['questChanged'];
  override serialize() { return { ...quest }; }
  override deserialize(d: JsonValue) { Object.assign(quest, d as object); }
  override announce() { this.emit('questChanged', quest.stage); }  // the screen
}
```

**Give that node the same `uid` in every scene it appears in.** The save is keyed
by uid, so `village.scene.json` and `cellar.scene.json` sharing one uid on their
root is what lets a save taken in either one restore into either one. Generate it
once with `newUid()` and paste it into both files — this is the single case where
one uid legitimately appears in two scenes, because it is one thing.

`behaviorsWithoutSave()` finds the behavior you forgot: on the game above it
named `/Village (Hold)` first, out of 46% of the scripted nodes it lists.

## A title screen, in JSON

A `SavePoint` can ASK without loading. `probeOnReady` fires on the first frame
and emits `hasSave(label, playtime, scene)` or `noSave`, so the menu wires
itself:

```json
{ "name": "Save", "type": "Node", "uid": "n_…",
  "script": { "name": "SavePoint",
              "props": { "game": "chapters", "slot": "1", "probeOnReady": true } } }
```
```json
{ "signal": "noSave",  "from": "Save", "to": "HUD/Menu/Continue", "handler": "hide" },
{ "signal": "noSave",  "from": "Save", "to": "HUD/Menu/SlotInfo", "handler": "hide" },
{ "signal": "hasSave", "from": "Save", "to": "HUD/Menu/SlotInfo", "handler": "setText" }
```

With `"format": "Continue: {}"` on that `UiText`, a fresh install shows a menu
with no Continue button and a save shows `Continue: Chapter 2`. Every HUD widget
takes `show`/`hide` from a wire (`visible` is a prop, and a connection needs a
method — the same wall `setText` broke through).

The label leads because that is what a menu shows; an unlabelled slot falls back
to its scene key, so the line is never blank.

**The button's press is still yours**, and rightly: `pressed → your router`. See
the three lines above. `examples/lanternhold-2d` ships this exact menu — a fresh
install with no Continue, and a returning player whose slot line reads what the
game SAVED (`last saved: Village` — the SavePoint's own label, not something the
title screen guessed).

Two things that cost a run each to learn there:

- **A hand-written slot with `state: {}` restores nothing.** The engine says so
  — `restored 0 of 2 saveable behaviour(s)` — but a menu built on one looks
  perfectly green. Write the slot the way the game writes it.
- **Hold the engine before you route.** `goToScene` frees the tree the button
  lives in, so `this.engine` on the next line throws. See "Game flow" in
  `incanto-gameplay-behaviors.md`.

## Several slots

```ts
for (const slot of slots.all()) {         // newest first
  render(slot.id, slot.label, new Date(slot.savedAt), slot.playtime);
}
slots.remove('2');
slots.clear();                            // "delete all data"
```

One `SavePoint` per slot is the declarative version: three nodes with
`slot: "1" | "2" | "3"`, each probing into its own row of the menu.

### Restore into a scene that has been PLAYED

A save is restored INTO a freshly loaded scene. The structure comes from the
file; `#freed` then takes away the nodes that run had consumed — and nothing
puts any back. So a pause-menu Load, a slot menu, or death wired straight to
`SavePoint.restore()` leaves THIS run's collectibles deleted:

```
CONTROL restart the scene from source, then restore → gems [Gem2,Gem3], won
death wired straight to restore (no reload)         → gems [Gem3],       lost
```

…and the next autosave writes that hybrid back to the slot, so the unwinnable
run survives a page reload. Reload first — `GameFlow.restart` with
`restoreOnReady`, or `restartScene(engine)` and then restore. After a restart
the SavePoint node is a NEW one, so a held behavior reference is stale.

`report.stale` names the authored nodes this tree consumed that the save does
not account for, and the engine warns when it is non-empty. It is a warning
rather than an error because a node that freed ITSELF on a timer lands in the
same set, and only your game knows which of its nodes are transient.

### A save hook that throws

`serialize()`, `deserialize()` and `announce()` are your code, and your code
throws. All three are caught now, and each says something different:

- a **`serialize()`** that throws leaves a HOLE in the save — that behavior's
  whole run is missing — so `SavePoint.save()` **refuses to write** and emits
  `saveFailed` instead of `saved`. Overwriting the previous slot with a holed
  save destroys the progress the player actually had. `engine.lastCaptureFailures`
  names them if you call `captureState()` yourself.
- a **`deserialize()`** that throws is in `report.skipped` as before, and now
  also in **`report.refused`** with the reason — a save this build cannot read
  and a node with no `deserialize` at all are different problems.
- an **`announce()`** that throws means the state IS restored and the SCREEN was
  not told: **`report.unannounced`**. It reported `restored: 2, expected: 2`
  over a HUD showing zeros, and it does not take an override to reach — a
  `getNode('HUD/ScoreLabel')` on a renamed node lands there.

### A save that cannot be read

A truncated write (a power cut, a tab closed mid-save) or a slot from a build
that predates this one is not offered by `all()` — but it is not *gone* either,
and a load menu that quietly shows one fewer row than the player remembers is
the worst thing a save system can do:

```ts
for (const { id, why } of slots.problems()) {
  render(`slot ${id}: ${why === 'corrupt' ? 'damaged' : 'from an older version'}`);
}
```

The slot INDEX is rebuilt from storage when it cannot be read, so one corrupt
byte in a derived list no longer hides every save on the machine — nor lets the
next `write()` orphan them. It says so on the console when it does.

## Checking your coverage

```ts
import { behaviorsWithoutSave, savesWithoutUid } from 'incanto';
console.log(behaviorsWithoutSave(game.engine.scene.root)); // forgot serialize?
console.log(savesWithoutUid(game.engine.scene.root)); // forgot the uid?
```

`behaviorsWithoutSave` names every behavior with props and no `serialize`. Not
all of them are wrong — one that derives everything from time has nothing to
save — but it is the list to read before shipping.

`savesWithoutUid` is the other half, and none of it is debatable: a behavior
that DOES serialize, on a node with no uid, is state that goes nowhere.
`incanto-check` catches the built-ins it can recognise from the JSON
(`Health`, `ScoreKeeper`, `Collector`); a scene file cannot be asked whether
YOUR behavior serializes, so this walks the live tree and names those too.

## When the browser will not store anything

A private window, storage disabled, or an exhausted quota: `localStorage`
throws, and the store falls back to memory. **That fallback is right** —
refusing to save would be worse — but everything in the session still reads
healthy, so the player only finds out by reloading and losing the run. Measured
under Safari-private conditions: `set('highScore', 4200)` then `get(...)`
returned 4200, with zero warnings and no way to ask.

```ts
if (!slots.persistent) {
  banner.show('This browser will not keep your progress — private window?', { seconds: 6 });
}
```

`SaveSlots.persistent` (and `SaveStore.persistent`) is `false` whenever writes
live only as long as the tab. The engine also says it once per namespace: on the
console, and — from `SavePoint` — through `engine.log`, so `incanto-logs` and
the `says` rung of `incanto-verify` see it too.

`SavePoint` still emits `saved(slot)` in that state, deliberately: it did save,
for as long as the page is open, and a Continue button that never lights up
would be a second bug. Ask `persistent` before promising the player anything.

Headless — tests, verify scripts, SSR — `persistent` is `false` and nothing is
logged: in-memory is the design there, not a failure.

## Saving from the scene — `SavePoint`

*When* to save is a design decision (checkpoint, level end, on quit) and only
your game knows — so the scene still chooses, by picking which signal to wire.
What it does not need any more is a method of your own to wire it to:

```json
{ "name": "Save", "type": "Node", "uid": "n_…",
  "script": { "name": "SavePoint",
              "props": { "game": "vault", "slot": "1", "label": "Chapter 2" } } }
```
```json
{ "signal": "triggerEnter", "from": "Level/Exit", "to": "Save", "handler": "save" },
{ "signal": "collected",    "from": "Gems/Gem1",  "to": "Save", "handler": "save" },
{ "signal": "won",          "from": ".",          "to": "Save", "handler": "save" }
```

| prop | default | meaning |
| --- | --- | --- |
| `game` | `"game"` | slot namespace — keeps two games on one origin apart |
| `slot` | `"1"` | which slot this node reads and writes |
| `label` | `""` | shown in a load menu |
| `scene` | `""` | the key a loader routes back to (empty = this scene's `name`) |
| `restoreOnReady` | `false` | read the slot on the first frame — a "Continue" boot |

Methods: `save()` · `restore()` · `clear()` — and `playtime` / `slotScene()` to
read. Signals: `saved(slot)` · `restored(count)` · **`noSave`**, which is what
greys out a Continue button.

`restoreOnReady` lands on the **first frame**, not in `onReady`: `onReady` runs
children-first, so restoring there would hand the score keeper its state back
and then watch the root's own `onReady` set it to zero.

**A checkpoint wired to `collected` counts that pickup.** `Pickup` queues its
free before it announces, so a save taken from the handler records the world
without it — otherwise the gem came back on the next run with the score that
counted it already banked.

Still your code when the moment is not a signal — every 60 s, on `visibilitychange`,
on a quit button: call `engine.captureState()` and `slots.write()` yourself.
