Saves
A saved game is a versioned envelope around a core snapshot, built by sage's SimulationSaveManager. The manager produces and consumes plain JSON-compatible objects; where they are stored is the game's business.
const saveManager = engine.managers.saveManager;SimulationSaveManager
SimulationSaveManager<Registry> carries the same entity registry the engine was booted with, the way every other manager createGameEngine builds does. See Engine.
| Method | Meaning |
|---|---|
save() | A SaveEnvelope around a snapshot of the simulation the engine holds |
load(envelope) | Transitions to the saved level, restores the envelope's snapshot into the simulation that transition built, binds the restored simulation to the level and the engine, and answers with it and the envelope |
onBeforeSave(hook) | Registers a hook whose returned object is merged into the envelope's custom field. Answers with the teardown. Two hooks answering with the same field throw, naming it. |
onBeforeSave is one of the engine's four lifecycle hook points, sharing the one registration policy the other three follow. See Engine.
Two exported types go with those two methods. BeforeSaveHook is what onBeforeSave takes, a function of no arguments answering with a record; LoadResult<Registry> is what load answers with, the restored simulation and the envelope it read.
import type { BeforeSaveHook, LoadResult } from '@skewedaspect/sage';
export const saveScore : BeforeSaveHook = () => ({ score: 1200 });
export function levelOf(result : LoadResult) : string
{
return result.envelope.level;
}SaveEnvelope
interface SaveEnvelope
{
saveFormatVersion : number;
engineVersion : string;
level : string;
timestamp : number;
custom : Record<string, unknown>;
snapshot : GameSimulationSnapshot;
}| Field | Meaning |
|---|---|
saveFormatVersion | The save format, currently 2. A load rejects a version it does not know. |
engineVersion | The SAGE version that wrote the save |
level | The name of the level the game was in |
timestamp | When the save was written, in milliseconds |
custom | Merged data from every onBeforeSave hook |
snapshot | Exactly what snapshot() returned, unmodified |
What a snapshot holds
The snapshot is the simulation's name, the tick counter, the ID counter, the generator's state as randomState, and every entity's ID, name, and state bag, deep-copied at a fixed-step boundary. See Simulation.
Transforms and parent IDs are state fields on NodeEntity, so the snapshot already carries them. Behavior instance fields are not in it: anything worth saving lives in the state bag. Entity classes are referenced by name and must be in the entity registry when the save loads.
Saving
saveManager.onBeforeSave(() =>
{
return { score, questLog: quests.completedIDs() };
});
function saveGame() : void
{
const envelope = saveManager.save();
localStorage.setItem('save-slot-1', JSON.stringify(envelope));
}Several hooks may be registered. Their returned objects are shallow-merged into custom, later hooks winning on a shared key -- unless two hooks answer with the same field, which onBeforeSave refuses at registration instead of silently letting one win.
Loading
async function loadGame() : Promise<NodeSimulation | undefined>
{
const json = localStorage.getItem('save-slot-1');
if(json === null)
{
return undefined;
}
const envelope = JSON.parse(json) as SaveEnvelope;
const result = await saveManager.load(envelope);
score = result.envelope.custom.score as number;
return result.simulation;
}Loading runs in order:
- The level manager transitions to
envelope.levelwithforRestore: true. A level loaded this way spawns nothing of its own: its spawn markers and entity markers are skipped, and the snapshot is the sole source of entities. buildFromSnapshotruns on the simulation that transition just built for the new scene, and builds the replacement fromenvelope.snapshot, restoring every entity without runningonCreate. Each restoredNodeEntitybuilds its node, claims a pooled set, or adopts the authored node itsnodeNamenames, and loads its mesh the same way a fresh spawn does. EveryNodeEntity'spositionandrotationare computed from its node (Entities), so a snapshot records where the node stands and the restore assigns the fields, which moves the node back there.- The replacement is handed to the current level and assigned to
engine.simulation. loadanswers with the replacement simulation and the envelope it read,customincluded.
A load rejects a save-format version it does not know, and nothing converts a save from one version to another. A save written under 0.9 or 0.10.x does not load; 0.10.x wrote save-format version 1, whose snapshot carries no randomState. A loaded save restores the generator from randomState, so the game rolls on from where the saved game stood.
Entity IDs are preserved
The core snapshot keys on entity IDs and nothing remaps them. An ID a game stored somewhere else, in custom or in another entity's state, still names the same entity after a load. The ID counter comes back with the snapshot, so IDs minted afterwards continue past every ID the save held.
The replacement and the engine
load takes no simulation. The one it restores into is engine.simulation, the simulation the level transition inside load just rebuilt for the new scene; restoring into any other leaves the entities holding a scene that was disposed with the old level.
The replacement buildFromSnapshot returns then becomes the current level's simulation and engine.simulation, so the running engine steps the restored entities and the level's own later spawns and despawns reach them. See Simulation for what a NodeSimulation carries over when it builds its replacement.
Calling buildFromSnapshot yourself
A game that restores a simulation without the save manager owns three things load handles for it.
The replacement is built detached. Its entities register and emit on its own bus until attach(), so nothing outside it hears an event before then. Under sage, engine.bindSimulation(replacement) detaches the simulation the engine held and attaches the new one in one call.
The replacement carries the engine bus the original was constructed with, and nothing else. A simulation built with no bus option rebuilds with no bus: attach() throws, naming the simulation, and every subscriber registered on the original's own bus keeps hearing the original and hears nothing from the replacement.
NodeSimulation's buildFromSnapshot is typed as answering with a NodeSimulation, so the replacement binds to the engine without narrowing:
const replacement = simulation.buildFromSnapshot(envelope.snapshot);
engine.bindSimulation(replacement);load does all three already and answers with a NodeSimulation.
Storage
The manager produces and consumes plain objects. localStorage, IndexedDB, a server API, or a file all work. Save slots are a key the game chooses:
function saveToSlot(slot : number) : void
{
localStorage.setItem(`save-slot-${ slot }`, JSON.stringify(saveManager.save()));
}