Save & Load
YAGE has two persistence paths:
- Stores + a Save instance (this guide). Typed reactive singletons for settings, save slots, world facts, and progression. The default for almost everything.
- 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.
Why stores
Section titled “Why stores”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.
Creating reactive state
Section titled “Creating reactive state”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.
Compound createStore (primary)
Section titled “Compound createStore (primary)”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 withvalue,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.
Leaf factories (one-offs)
Section titled “Leaf factories (one-offs)”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);Creating the Save instance
Section titled “Creating the Save instance”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.
import { createSave, localStorageAdapter } from "@yagejs/save";
export const save = createSave({ adapter: localStorageAdapter({ namespace: "my-game" }),});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 bootconst stop = save.autoPersist("settings", settings);
// Anywhere in the game…settings.set({ audio: { ...settings.get().audio, music: 0.5 } });// → one write fires after the current microtaskstop() 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.
Save slots
Section titled “Save slots”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.
Continue (load latest)
Section titled “Continue (load latest)”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);}Multi-profile
Section titled “Multi-profile”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.
In-game access via DI
Section titled “In-game access via DI”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); }); }}Migration & versioning
Section titled “Migration & versioning”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.
Codecs
Section titled “Codecs”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 stringcreateSet/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() }).
Adapters
Section titled “Adapters”import { localStorageAdapter, memoryAdapter } from "@yagejs/save";
localStorageAdapter({ namespace?: string }) // browsermemoryAdapter() // tests + NodeThe 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);});Per-frame updates: don’t
Section titled “Per-frame updates: don’t”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.
When stores aren’t enough
Section titled “When stores aren’t enough”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.