Skip to content

Save & Load

The @yagejs/save package provides automatic save/load with component-level serialization, entity and scene restoration hooks, and slot-based storage.

import { SavePlugin } from "@yagejs/save";
engine.use(new SavePlugin({
namespace: "my-game", // storage key prefix (default: "yage")
}));

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";
@serializable
class Player extends Entity {
// ...
}
@serializable
class GameScene extends Scene {
// ...
}

Only @serializable classes are included in snapshots. Built-in components like Transform, RigidBodyComponent, and ColliderComponent are already serializable.

Components with simple data fields are serialized automatically:

@serializable
class ScoreComponent extends Component {
score = 0;
combo = 1;
// These fields will be saved and restored automatically
// via serialize() / fromSnapshot() defaults
}

For components with non-trivial state, implement serialize() and static fromSnapshot():

@serializable
class 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;
}
}

Some state cannot be serialized — draw callbacks, event listeners, and runtime references. Use afterRestore() to re-create them:

@serializable
class 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();
}
});
}
}

Scenes also have an afterRestore() hook for re-creating scene-level state:

@serializable
class 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.

Access the save service via DI:

import { SaveServiceKey } from "@yagejs/save";
const saveService = this.use(SaveServiceKey);
// Save current state to a slot
saveService.saveSnapshot("slot1");
// Load from a slot (async — rebuilds scenes and entities)
await saveService.loadSnapshot("slot1");
// Check if a slot exists
saveService.hasSnapshot("slot1"); // boolean
// Delete a slot
saveService.deleteSnapshot("slot1");

Export snapshots as plain objects for cloud saves or file download:

const data = saveService.exportSnapshot("slot1"); // GameSnapshot | null
await saveService.importSnapshot("slot1", data);

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:

@serializable
class 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 });
}

Store data that persists across saves — settings, unlocks, best scores:

// Save arbitrary data
saveService.saveData("bestScore", { value: 9999 });
// Load it back
const best = saveService.loadData("bestScore"); // { value: 9999 } | null
// Check / delete
saveService.hasData("bestScore");
saveService.deleteData("bestScore");

User data is independent of game snapshots. It survives even if snapshots are deleted.

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 }); // ✓ typed
save.saveData("playerName", "Ada"); // ✓ typed
save.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 error

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

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");
}
}
}
  • Included: @serializable entities, scenes, and components. The full scene stack, entity hierarchy (parent/child), and component data.
  • Not included: draw callbacks, event listeners, runtime service references, non-@serializable classes.

If something is missing after a load, you need an afterRestore() hook to re-create it.