Save & Load
The @yagejs/save package provides automatic save/load with component-level
serialization, entity and scene restoration hooks, and slot-based storage.
SavePlugin Setup
Section titled “SavePlugin Setup”import { SavePlugin } from "@yagejs/save";
engine.use(new SavePlugin({ namespace: "my-game", // storage key prefix (default: "yage")}));The @serializable Decorator
Section titled “The @serializable Decorator”Mark classes with @serializable so the save system can snapshot and restore
them. This works on entities, scenes, and components.
import { serializable } from "@yagejs/core";
@serializableclass Player extends Entity { // ...}
@serializableclass GameScene extends Scene { // ...}Only @serializable classes are included in snapshots. Built-in components
like Transform, RigidBodyComponent, and ColliderComponent are already
serializable.
Auto-Serialization
Section titled “Auto-Serialization”Components with simple data fields are serialized automatically:
@serializableclass ScoreComponent extends Component { score = 0; combo = 1;
// These fields will be saved and restored automatically // via serialize() / fromSnapshot() defaults}Custom Serialization
Section titled “Custom Serialization”For components with non-trivial state, implement serialize() and
static fromSnapshot():
@serializableclass MovingSpike extends Component { private startY: number; private amplitude: number; private speed: number; private elapsed = 0;
constructor(opts: { startY: number; amplitude: number; speed: number }) { super(); this.startY = opts.startY; this.amplitude = opts.amplitude; this.speed = opts.speed; }
serialize() { return { startY: this.startY, amplitude: this.amplitude, speed: this.speed, elapsed: this.elapsed, }; }
static fromSnapshot(data: { startY: number; amplitude: number; speed: number; elapsed: number; }) { const spike = new MovingSpike({ startY: data.startY, amplitude: data.amplitude, speed: data.speed, }); spike.elapsed = data.elapsed; return spike; }}Entity afterRestore
Section titled “Entity afterRestore”Some state cannot be serialized — draw callbacks, event listeners, and runtime
references. Use afterRestore() to re-create them:
@serializableclass Coin extends Entity { setup(): void { this.add(new Transform()); this.add(new GraphicsComponent().draw(drawCoin)); this.add(new RigidBodyComponent({ type: "static" }));
const collider = new ColliderComponent({ shape: { type: "circle", radius: 10 }, sensor: true, }); this.add(collider); this.setupTrigger(collider); }
afterRestore(): void { // Re-create the draw callback (not serializable) this.get(GraphicsComponent).draw(drawCoin);
// Re-attach event handlers this.setupTrigger(this.get(ColliderComponent)); }
private setupTrigger(collider: ColliderComponent): void { collider.onTrigger((ev) => { if (ev.entered) { this.emit(CoinCollected); this.destroy(); } }); }}Scene afterRestore
Section titled “Scene afterRestore”Scenes also have an afterRestore() hook for re-creating scene-level state:
@serializableclass GameScene extends Scene { readonly name = "game";
onEnter(): void { this.buildShared(); this.spawnInitialEntities(); }
afterRestore(): void { this.buildShared(); // Entities are restored automatically — don't re-spawn them }
private buildShared(): void { // Set up event listeners, services, static geometry — // anything needed by both fresh start and load this.on(CoinCollected, () => { /* ... */ }); }}The pattern is:
onEnter()— called for a fresh start. Spawn entities, set up state.afterRestore()— called after loading. Entities are already restored. Re-create non-serializable scene state only.- Extract shared setup into a helper method called by both.
Snapshot API
Section titled “Snapshot API”Access the save service via DI:
import { SaveServiceKey } from "@yagejs/save";
const saveService = this.use(SaveServiceKey);Save and Load
Section titled “Save and Load”// Save current state to a slotsaveService.saveSnapshot("slot1");
// Load from a slot (async — rebuilds scenes and entities)await saveService.loadSnapshot("slot1");
// Check if a slot existssaveService.hasSnapshot("slot1"); // boolean
// Delete a slotsaveService.deleteSnapshot("slot1");Import / Export
Section titled “Import / Export”Export snapshots as plain objects for cloud saves or file download:
const data = saveService.exportSnapshot("slot1"); // GameSnapshot | nullawait saveService.importSnapshot("slot1", data);Snapshot Schema
Section titled “Snapshot Schema”exportSnapshot() returns a GameSnapshot — a plain object with a stable,
documented shape. You normally never look inside it; importSnapshot() does
the restoration for you. But understanding the structure is useful when
writing custom migrations, building a save-file preview UI, or debugging
stale save data.
interface GameSnapshot { version: number; // schema version — bump when formats change timestamp: number; // Date.now() at save time scenes: SceneSnapshotEntry[]; // one entry per scene in the stack}
interface SceneSnapshotEntry { type: string; // class name from @serializable paused: boolean; // was the scene paused at save time entities: EntitySnapshotEntry[]; userData?: unknown; // result of scene.serialize()}
interface EntitySnapshotEntry { id: number; // save-time ID, used to rewire cross-entity refs type: string; // class name from @serializable components: ComponentSnapshot[]; userData?: unknown; // result of entity.serialize() parentId?: number; // if this is a child of another entity childName?: string; // name under which parent registered this child}
interface ComponentSnapshot { type: string; // component class name data: unknown; // result of component.serialize()}The id on EntitySnapshotEntry is what makes cross-entity references work.
When you save an entity that holds a pointer to another entity (say, an AI
that “targets” a player), the pointer is stored as the target’s save-time
id. On load, afterRestore() receives a SnapshotResolver whose
resolver.entity(oldId) returns the freshly-restored instance, so you can
rewire the pointer without having to care about ID collisions:
@serializableclass Enemy extends Entity { targetId!: number;
serialize() { return { targetId: this.target?.id }; }
afterRestore(data: { targetId: number }, resolver: SnapshotResolver) { this.target = resolver.entity(data.targetId); }}One common read-only use of the schema is building a save-slot preview UI —
read exportSnapshot(), count scenes[0].entities, inspect timestamp,
and render a summary without having to actually restore the save:
const preview = saveService.exportSnapshot("slot1");if (preview) { const age = new Date(preview.timestamp).toLocaleString(); const count = preview.scenes[0]?.entities.length ?? 0; renderSlot({ age, entityCount: count });}Persistent User Data
Section titled “Persistent User Data”Store data that persists across saves — settings, unlocks, best scores:
// Save arbitrary datasaveService.saveData("bestScore", { value: 9999 });
// Load it backconst best = saveService.loadData("bestScore"); // { value: 9999 } | null
// Check / deletesaveService.hasData("bestScore");saveService.deleteData("bestScore");User data is independent of game snapshots. It survives even if snapshots are deleted.
Typed Slots
Section titled “Typed Slots”By default, saveService.saveData("bestScore", ...) accepts any string key
and any value — the service is typed as SaveService<UntypedSlots>, which is
effectively Record<string, any>. That’s fine for prototyping, but it gives
you no autocomplete on slot names and no compile-time check that you’re
storing the right shape under the right key.
SaveService is generic over a slot-key map. Declare an interface describing
the shape of every persistent slot your game uses, then cast the resolved
service once:
import { SaveServiceKey, type SaveService } from "@yagejs/save";
interface MySlots { bestScore: { value: number }; playerName: string; settings: { volume: number; muted: boolean };}
const save = this.use(SaveServiceKey) as SaveService<MySlots>;
save.saveData("bestScore", { value: 9999 }); // ✓ typedsave.saveData("playerName", "Ada"); // ✓ typedsave.saveData("settings", { volume: 0.5, muted: false });
const name = save.loadData("playerName"); // string | null// save.saveData("bstScore", ...) // ✗ typo → compile error// save.saveData("bestScore", 9999) // ✗ wrong shape → compile errorYou get autocomplete on slot names and compile-time errors when you pass a value of the wrong shape, with no runtime cost — the cast is purely a type hint.
This only types the persistent-user-data slots (saveData/loadData). The
snapshot API (saveSnapshot/loadSnapshot) is keyed by slot name as a
free-form string and serializes whatever scenes are currently on the stack,
so it doesn’t need to know your slot shape.
Quicksave Pattern
Section titled “Quicksave Pattern”A common pattern is F5/F9 quicksave/quickload:
class GameScene extends Scene { private readonly input = this.service(InputManagerKey); private readonly save = this.service(SaveServiceKey);
update(): void { if (this.input.isJustPressed("quicksave")) { this.save.saveSnapshot("quicksave"); } if (this.input.isJustPressed("quickload")) { this.save.loadSnapshot("quicksave"); } }}What Gets Serialized
Section titled “What Gets Serialized”- Included:
@serializableentities, scenes, and components. The full scene stack, entity hierarchy (parent/child), and component data. - Not included: draw callbacks, event listeners, runtime service
references, non-
@serializableclasses.
If something is missing after a load, you need an afterRestore() hook to
re-create it.