Skip to content

Serialization & Save/Load

The SaveManager provides a storage-agnostic serialization system for capturing and restoring game state. It produces plain JSON-serializable objects that you can persist however you like -- localStorage, IndexedDB, a server API, or clipboard copy/paste during development.

Accessing the SaveManager

typescript
const engine = await createGameEngine({ canvas });
const saveManager = engine.managers.saveManager;

SaveManager API

MethodSignatureDescription
serialize() => SaveDataCapture current game state
deserialize(data : SaveData) => Promise<void>Restore game state from a save
onBeforeSerialize(hook : () => Record<string, unknown>) => voidRegister a pre-save hook
onAfterDeserialize(hook : (custom : Record<string, unknown>) => void) => voidRegister a post-load hook
$teardown() => Promise<void>Clean up hooks (called automatically)

SaveData

The serialize() method returns a SaveData object:

typescript
interface SaveData
{
    version : number;
    levelName : string;
    entities : SerializedEntity[];
    custom : Record<string, unknown>;
}
FieldTypeDescription
versionnumberSchema version (currently 1)
levelNamestringName of the current level
entitiesSerializedEntity[]All entity snapshots
customRecord<string, unknown>Merged data from onBeforeSerialize hooks

SerializedEntity

Each entity is captured as:

typescript
interface SerializedEntity
{
    id : string;
    type : string;
    name ?: string;
    tags : string[];
    state : object;
    parentId ?: string;
    transform ?: {
        position : { x : number; y : number; z : number };
        rotation : { x : number; y : number; z : number };
        scaling : { x : number; y : number; z : number };
    };
}
FieldTypeDescription
idstringEntity ID at save time (remapped on load)
typestringEntity definition type name
namestring?Optional human-readable name
tagsstring[]Entity tags
stateobjectDeep clone of entity.state
parentIdstring?Parent entity ID for hierarchy
transformobject?Position, rotation, scaling from the scene node

Saving Game State

typescript
const saveData = saveManager.serialize();

// Store however you like
localStorage.setItem('save-slot-1', JSON.stringify(saveData));

What Gets Serialized

  • All entities with their state, tags, names, and hierarchy
  • Transform data (position, rotation, scaling) for entities attached to scene nodes
  • Custom data from onBeforeSerialize hooks

What Does NOT Get Serialized

  • Entity definitions -- Referenced by type name; must be registered when deserializing
  • Behavior internal state -- Only entity.state is captured. Expose behavior-internal data through the entity state if it needs to persist.
  • Scene configuration -- Camera position, lighting, post-processing. The level's own setup handles restoration upon transition.

Loading Game State

typescript
const json = localStorage.getItem('save-slot-1');
if(json)
{
    const saveData = JSON.parse(json) as SaveData;
    await saveManager.deserialize(saveData);
}

Deserialization Flow

The restore process follows this sequence:

  1. Level transition -- Transitions to the level named in the save data. This runs the full level lifecycle (teardown of current level, setup of the new one).
  2. Clear level-spawned entities -- Destroys all entities created by the level's setup, since they are about to be recreated from the save.
  3. Recreate entities -- Creates each entity by its type name with the saved name, tags, and state. Entities receive new IDs.
  4. Restore transforms -- Applies saved position, rotation, and scaling to entities that have scene nodes.
  5. Restore hierarchy -- Re-establishes parent-child relationships using the old-to-new ID mapping.
  6. Run afterDeserialize hooks -- Calls all registered hooks with the custom data from the save.

ID Remapping

Entities always receive fresh IDs when recreated. The deserializer maintains an internal old-to-new ID mapping to restore parent-child relationships correctly.

If your game logic stores entity IDs externally (e.g., "the player entity is X"), use the onAfterDeserialize hook to update those references:

typescript
let playerEntityId : string;

saveManager.onAfterDeserialize((custom) =>
{
    // Your game tracks the player entity ID in custom data
    playerEntityId = custom.playerEntityId as string;
});

WARNING

Old entity IDs are not valid after deserialization. Any external references to entity IDs must be updated through onAfterDeserialize hooks.

Extension Hooks

Hooks let you save and restore custom data that lives outside the entity system -- scores, quest progress, UI state, or anything else your game tracks independently.

onBeforeSerialize

Register a callback that returns custom data to include in the save:

typescript
saveManager.onBeforeSerialize(() =>
{
    return {
        score: gameState.score,
        questLog: gameState.quests.getCompletedIds(),
        playTime: gameState.totalPlayTime,
    };
});

Multiple hooks can be registered. Their returned objects are merged (shallow) into the custom field. If two hooks return the same key, the later one wins.

onAfterDeserialize

Register a callback to process custom data when loading:

typescript
saveManager.onAfterDeserialize((custom) =>
{
    gameState.score = custom.score as number;
    gameState.quests.restoreCompleted(custom.questLog as string[]);
    gameState.totalPlayTime = custom.playTime as number;
});

Each registered hook receives the full custom object from the save data.

Full Example

typescript
const engine = await createGameEngine({ canvas });
const saveManager = engine.managers.saveManager;

// --- Register hooks ---

saveManager.onBeforeSerialize(() =>
{
    return {
        score: currentScore,
        difficulty: currentDifficulty,
    };
});

saveManager.onAfterDeserialize((custom) =>
{
    currentScore = custom.score as number;
    currentDifficulty = custom.difficulty as string;
    updateScoreDisplay(currentScore);
});

// --- Save ---

function saveGame() : void
{
    const data = saveManager.serialize();
    localStorage.setItem('save-slot-1', JSON.stringify(data));
}

// --- Load ---

async function loadGame() : Promise<void>
{
    const json = localStorage.getItem('save-slot-1');
    if(!json)
    {
        console.warn('No save data found');
        return;
    }

    await saveManager.deserialize(JSON.parse(json) as SaveData);
}

Multiple Save Slots

The SaveManager is slot-agnostic. Implement save slots in your storage layer:

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

async function loadFromSlot(slot : number) : Promise<void>
{
    const json = localStorage.getItem(`save-slot-${ slot }`);
    if(json)
    {
        await saveManager.deserialize(JSON.parse(json) as SaveData);
    }
}

function listSaveSlots() : number[]
{
    const slots : number[] = [];
    for(let i = 0; i < localStorage.length; i++)
    {
        const key = localStorage.key(i);
        if(key?.startsWith('save-slot-'))
        {
            slots.push(parseInt(key.replace('save-slot-', ''), 10));
        }
    }
    return slots.sort();
}

Storage Considerations

The save system is deliberately storage-agnostic. It produces and consumes plain JavaScript objects. You can:

  • Save to localStorage or sessionStorage
  • Save to IndexedDB for larger data
  • Send to a server API for cloud saves
  • Write to files in Electron/Tauri apps
  • Copy to clipboard during development

Versioning

The version field in SaveData is currently 1. Future versions of SAGE may increment this to support schema migrations. When loading saves, check the version if your game needs to handle saves from older versions:

typescript
async function loadGame() : Promise<void>
{
    const json = localStorage.getItem('save');
    if(!json) { return; }

    const data = JSON.parse(json) as SaveData;

    if(data.version !== 1)
    {
        console.error(`Unsupported save version: ${ data.version }`);
        return;
    }

    await saveManager.deserialize(data);
}

Teardown

The SaveManager implements Disposable. Calling gameEngine.$teardown() automatically clears all registered hooks.

Released under the MIT License.