Skip to content

Save & Load

YAGE has two persistence paths:

  1. Stores + a Save instance (this guide). Typed reactive singletons for settings, save slots, world facts, and progression. The default for almost everything.
  2. Snapshot quicksave. Full-scene serialization via @serializable, used when you specifically want to “pause and resume the simulator.” See the Snapshot Quicksave guide for that path.

Most games only need stores. Reach for snapshots when stores aren’t enough.

Most save data isn’t really “the simulator’s runtime state” — it’s the intentional facts about the game: which chests are opened, what chapter the player is on, the current settings, the inventory. Trying to serialize a live component graph to capture that data is fragile (closures, runtime objects, destroyed entities). A typed store says directly what the data is.

Stores also avoid the trap of accidentally persisting things you don’t want — particle positions, animation phase, transient UI state — because you choose what each store contains.

YAGE’s state factories live in @yagejs/core and produce pure-data values implementing three contracts: Reactive (subscribe), Serializable<T> (serialize/hydrate), and Resettable (reset). The save layer consumes any Serializable<T>; ids and versioning live at the save call site.

game/persistence/stores.ts
import { createStore } from "@yagejs/core";
export interface Potion { name: string; quality: number }
export const game = createStore((s) => ({
inventory: s.map<string, number>(),
recipes: s.set<string>(),
gold: s.counter({ default: 0 }),
reputation: s.counter({ default: 50 }),
shelf: s.list<Potion>(),
day: s.value<number>({ default: 1 }),
settings: s.record<{ volume: number; lang: string }>({
default: () => ({ volume: 0.8, lang: "en" }),
}),
}));
// Idiomatic per-shape ops on each leaf:
game.gold.increment(10);
game.inventory.set("moonleaf", 3);
game.recipes.add("brew-1");
const potionId = game.shelf.add({ name: "Cure", quality: 7 });
// Save the whole tree as one document — id at the save call site:
save.autoPersist("game", game);

Leaf builder methods on s:

  • s.value<T>({ default, codec? }) — single typed cell.
  • s.counter({ default? }) — number with value, set, increment, decrement, clamp(value, min, max).
  • s.record<T>({ default, codec? }) — object with shallow merge.
  • s.map<K, V>({ default? }) — key→value.
  • s.set<K>({ default? }) — keys.
  • s.list<T>({ default? }) — ordered list with stable monotonic ids.

The compound bundles every leaf so they serialise/restore atomically as one document. Reserved leaf keys — subscribe, serialize, hydrate, reset — collide at construction time.

useStore(compound) returns the encoded snapshot of the whole tree; for fine-grained subscriptions read individual leaves.

When a single primitive doesn’t belong inside a larger tree, use the leaf factories directly:

import {
createRecord, createValue, createCounter,
createMap, createSet, createList,
} from "@yagejs/core";
export const settings = createRecord<SettingsData>({
default: () => ({ audio: { music: 0.8, sfx: 1.0 }, vsync: true }),
});
export const opened = createSet<string>();
export const defeated = createMap<string, number>();
export const restEpoch = createCounter();
export const day = createValue<number>({ default: 1 });
export const journal = createList<{ at: number; text: string }>();

Shape APIs (every value also exposes subscribe(fn), serialize(), hydrate(raw), reset()):

  • createRecord<T> / s.record: get(): Readonly<T>, set(partial: Partial<T>).
  • createValue<T> / s.value: get(): T, set(v: T).
  • createCounter / s.counter: value(), set(n), increment(by?), decrement(by?), clamp(value, min, max).
  • createMap<K, V> / s.map: get(k), set(k, v), delete(k), has(k), entries(), size(), clear().
  • createSet<K> / s.set: add(k), delete(k), has(k), values(), size(), clear().
  • createList<T> / s.list: add(item): number (id), remove(id): boolean, get(id), update(id, partial), list(), size(), clear().

Codecs are bundled for Set/Map/Counter/List — you only specify a custom codec on createRecord<T> / createValue<T> (or the matching leaves) when T contains exotic types.

Migrating from many leaf factories to one compound

Section titled “Migrating from many leaf factories to one compound”

If your game grew a createRecord here, a createCounter there, and several createSets — each with its own autoPersist call — the compound collapses them into one save target with atomic writes:

// Before — three save documents, three autoPersist calls.
const progression = createRecord<RunData>({ default: () => ({ chapter: 1, coins: 0 }) });
const deaths = createCounter();
const flags = createSet<string>();
save.autoPersist("run.progression", progression);
save.autoPersist("run.deaths", deaths);
save.autoPersist("run.flags", flags);
// After — one document, one autoPersist call, atomic serialize/hydrate.
const game = createStore((s) => ({
progression: s.record<RunData>({ default: () => ({ chapter: 1, coins: 0 }) }),
deaths: s.counter({ default: 0 }),
flags: s.set<string>(),
}));
save.autoPersist("run", game);

Construct Save once in your entry point. The plugin only registers it under SaveServiceKey for in-game DI access — boot-time work uses save directly.

game/persistence/save.ts
import { createSave, localStorageAdapter } from "@yagejs/save";
export const save = createSave({
adapter: localStorageAdapter({ namespace: "my-game" }),
});
game/main.ts
import { Engine } from "@yagejs/core";
import { SavePlugin } from "@yagejs/save";
import { save } from "./persistence/save.js";
import { settings, saves } from "./persistence/stores.js";
await Promise.all([
save.restore("settings", settings),
save.restore("saves", saves),
]);
save.autoPersist("settings", settings); // stream changes back to disk
const engine = new Engine();
engine.use(new SavePlugin({ save }));
await engine.start();

Settings — single document + auto-persist

Section titled “Settings — single document + auto-persist”

Settings change rarely and should write to disk on every change. autoPersist subscribes to the value and writes on every change, coalescing multiple synchronous set calls into one write per microtask.

await save.restore("settings", settings); // load on boot
const stop = save.autoPersist("settings", settings);
// Anywhere in the game…
settings.set({ audio: { ...settings.get().audio, music: 0.5 } });
// → one write fires after the current microtask

stop() removes the subscriber and cancels any pending write. For real time-based debouncing across user interactions (e.g. throttling a slider drag), wrap the value yourself.

Slots are named instances of a value. Use them for “save game” semantics. Each slot has optional metadata persisted alongside the value (timestamp, location, thumbnail, anything you want).

interface RunMeta {
location: string;
playtime: number;
}
await save.saveSlot<unknown, RunMeta>("saves", "manual-1", saves, {
metadata: { location: "Forest Temple", playtime: 60 },
});
await save.loadSlot("saves", "manual-1", saves);
const slots = await save.listSlots<RunMeta>("saves");
// [{ name: "manual-1", savedAt: 1714…, metadata: { location, playtime } }, …]
await save.deleteSlot("saves", "manual-1");

loadSlot throws SlotNotFoundError for missing slots.

const slots = await save.listSlots("saves");
if (slots.length > 0) {
const latest = slots.sort((a, b) => b.savedAt - a.savedAt)[0];
await save.loadSlot("saves", latest.name, saves);
}

If you have profiles (per-user save banks), use hierarchical slot names with the prefix filter:

await save.saveSlot("saves", `${profile}/manual-1`, saves);
const profileSlots = await save.listSlots("saves", { prefix: `${profile}/` });

The engine doesn’t formalize profile scopes — the convention lives in your game code, which keeps the primitive simple.

Components and scenes resolve the registered Save instance through SaveServiceKey:

import { SaveServiceKey } from "@yagejs/save";
class CheckpointOnRest extends Component {
setup() {
this.entity.on(Rested, async () => {
const save = this.use(SaveServiceKey);
await save.saveSlot("saves", "auto", saves);
});
}
}

version + migrate live on the read call (restore / loadSlot / autoPersist), not on the primitive. Bump version when the encoded shape changes and pass migrate to transform older payloads. Per-leaf migration inside a compound is not supported — migrate the tree as a whole.

// Single record — migrate the record value.
const saves = createRecord<RunData>({ default: () => initialRun() });
await save.restore("saves", saves, {
version: 3,
migrate: (old, fromVersion) => {
let v = old as Record<string, unknown>;
if (fromVersion < 2) v = { ...v, inventory: [] };
if (fromVersion < 3) v = { ...v, position: v.startPos ?? { x: 0, y: 0 } };
return v as RunData;
},
});
// Compound — one migrate on the tree. Returns the new encoded form each
// leaf consumes: `{ gold: number, day: { value: number } }` here.
const game = createStore((s) => ({
gold: s.counter({ default: 0 }),
day: s.value<number>({ default: 1 }),
}));
await save.restore("run", game, {
version: 2,
migrate: (old) => {
const v1 = old as { gold: number };
return { gold: v1.gold, day: { value: 7 } };
},
});

Newer-than-current payloads throw StoreVersionTooNewError; older payloads without a migrate throw StoreMigrationMissingError.

Stores serialize via a Codec<T, TEncoded>. TEncoded defaults to T for identity codecs, and surfaces in serialize()/hydrate() and RestoreOptions.migrate so migrations get the right type. Built-ins:

import { jsonCodec, setCodec, mapCodec, dateCodec } from "@yagejs/core";
jsonCodec<T>() // Codec<T, T> — identity (default)
setCodec<K>() // Codec<Set<K>, K[]>
mapCodec<K, V>() // Codec<Map<K,V>, [K,V][]>
dateCodec() // Codec<Date, string> — ISO string

createSet/createMap/createCounter/createList bundle the right codec — you only specify a codec for createRecord<T> / createValue<T> (or the matching s.record/s.value leaves) when the type contains exotic values. When a custom codec changes the encoded shape (e.g. Date → string), pass both generics so the change shows up in serialize() and migrate return types: createValue<Date, string>({ default: () => new Date(), codec: dateCodec() }).

import { localStorageAdapter, memoryAdapter } from "@yagejs/save";
localStorageAdapter({ namespace?: string }) // browser
memoryAdapter() // tests + Node

The SaveAdapter interface is intentionally minimal — read, write, delete, list over async string IO. Custom adapters (file system, IndexedDB, cloud sync) plug in here.

Construct fresh state per test, swap in memoryAdapter:

import { createRecord } from "@yagejs/core";
import { createSave, memoryAdapter } from "@yagejs/save";
const save = createSave({ adapter: memoryAdapter() });
it("round-trips a record", async () => {
const settings = createRecord({ default: () => ({ music: 0.8, sfx: 1.0 }) });
settings.set({ music: 0.3 });
await save.persist("settings", settings);
const next = createRecord({ default: () => ({ music: 0.8, sfx: 1.0 }) });
await save.restore("settings", next);
expect(next.get().music).toBe(0.3);
});

Stores are for intentional state — settings, slots, world facts. They notify all subscribers synchronously on every change, and any UI bindings re-render. Don’t update stores from update(dt) or other per-frame paths; that’s what ECS state and useQuery / useSceneSelector are for. If you find yourself debouncing every set, you’re using the wrong primitive.

If your game genuinely needs to capture and resume the entire simulator state (every entity, component, active process, scene stack) — for example a true quicksave during physics simulation — see the Snapshot Quicksave guide for the @serializable path.