Skip to content

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.

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

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

typescript
import type { BeforeSaveHook, LoadResult } from '@skewedaspect/sage';

export const saveScore : BeforeSaveHook = () => ({ score: 1200 });

export function levelOf(result : LoadResult) : string
{
    return result.envelope.level;
}

SaveEnvelope ​

typescript
interface SaveEnvelope
{
    saveFormatVersion : number;
    engineVersion : string;
    level : string;
    timestamp : number;
    custom : Record<string, unknown>;
    snapshot : GameSimulationSnapshot;
}
FieldMeaning
saveFormatVersionThe save format, currently 2. A load rejects a version it does not know.
engineVersionThe SAGE version that wrote the save
levelThe name of the level the game was in
timestampWhen the save was written, in milliseconds
customMerged data from every onBeforeSave hook
snapshotExactly 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 ​

typescript
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 ​

typescript
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:

  1. The level manager transitions to envelope.level with forRestore: 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.
  2. buildFromSnapshot runs on the simulation that transition just built for the new scene, and builds the replacement from envelope.snapshot, restoring every entity without running onCreate. Each restored NodeEntity builds its node, claims a pooled set, or adopts the authored node its nodeName names, and loads its mesh the same way a fresh spawn does. Every NodeEntity's position and rotation are 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.
  3. The replacement is handed to the current level and assigned to engine.simulation.
  4. load answers with the replacement simulation and the envelope it read, custom included.

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:

typescript
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:

typescript
function saveToSlot(slot : number) : void
{
    localStorage.setItem(`save-slot-${ slot }`, JSON.stringify(saveManager.save()));
}

Released under the MIT License.