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
const engine = await createGameEngine({ canvas });
const saveManager = engine.managers.saveManager;SaveManager API
| Method | Signature | Description |
|---|---|---|
serialize | () => SaveData | Capture current game state |
deserialize | (data : SaveData) => Promise<void> | Restore game state from a save |
onBeforeSerialize | (hook : () => Record<string, unknown>) => void | Register a pre-save hook |
onAfterDeserialize | (hook : (custom : Record<string, unknown>) => void) => void | Register a post-load hook |
$teardown | () => Promise<void> | Clean up hooks (called automatically) |
SaveData
The serialize() method returns a SaveData object:
interface SaveData
{
version : number;
levelName : string;
entities : SerializedEntity[];
custom : Record<string, unknown>;
}| Field | Type | Description |
|---|---|---|
version | number | Schema version (currently 1) |
levelName | string | Name of the current level |
entities | SerializedEntity[] | All entity snapshots |
custom | Record<string, unknown> | Merged data from onBeforeSerialize hooks |
SerializedEntity
Each entity is captured as:
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 };
};
}| Field | Type | Description |
|---|---|---|
id | string | Entity ID at save time (remapped on load) |
type | string | Entity definition type name |
name | string? | Optional human-readable name |
tags | string[] | Entity tags |
state | object | Deep clone of entity.state |
parentId | string? | Parent entity ID for hierarchy |
transform | object? | Position, rotation, scaling from the scene node |
Saving Game State
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
onBeforeSerializehooks
What Does NOT Get Serialized
- Entity definitions -- Referenced by
typename; must be registered when deserializing - Behavior internal state -- Only
entity.stateis 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
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:
- 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).
- Clear level-spawned entities -- Destroys all entities created by the level's setup, since they are about to be recreated from the save.
- Recreate entities -- Creates each entity by its
typename with the saved name, tags, and state. Entities receive new IDs. - Restore transforms -- Applies saved position, rotation, and scaling to entities that have scene nodes.
- Restore hierarchy -- Re-establishes parent-child relationships using the old-to-new ID mapping.
- Run afterDeserialize hooks -- Calls all registered hooks with the
customdata 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:
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:
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:
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
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:
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
localStorageorsessionStorage - 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:
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.
