Save & Load Systems
The @web-engine-dev/save package provides robust game state persistence with support for multiple save slots, auto-save, cloud saves, schema migration, and optional encryption.
Quick Start
typescript
import { SaveManager, LocalStorageProvider } from '@web-engine-dev/save';
import { WorldSaveAdapter } from '@web-engine-dev/save';
import { WorldSerializer } from '@web-engine-dev/ecs';
// Create and initialise SaveManager
const saveManager = new SaveManager<SerializedWorld>({
backend: 'indexedDB', // 'localStorage' | 'indexedDB'
version: { major: 1, minor: 0, patch: 0 },
maxSlots: 3,
autoSave: {
enabled: true,
intervalMs: 60_000, // auto-save every 60 s
slotIndex: 0,
},
});
await saveManager.initialize();
// WorldSaveAdapter bridges SaveManager with ECS serialization
const worldSerializer = new WorldSerializer(serializerConfig);
const adapter = new WorldSaveAdapter({
saveManager,
serialize: (world) => worldSerializer.saveWorld(world),
deserialize: (world, data) => worldSerializer.loadWorld(world, data),
});Saving Game State
typescript
// Save to slot 0
async function saveGame(world: World, slotIndex: number, slotName: string): Promise<void> {
const result = await adapter.saveWorld(world, slotIndex, {
name: slotName,
chapter: world.getResource(CurrentScene).id,
});
if (result.success) {
showSavedIndicator(world);
}
}Loading Game State
typescript
async function loadGame(world: World, slotIndex: number): Promise<boolean> {
const result = await adapter.loadWorld(world, slotIndex);
if (!result.success) return false;
// Entity mapping is returned (old entity IDs → new entity IDs)
const entityMap = result.data;
// Restore any cross-scene references using entityMap if needed
return true;
}Save Slots UI
typescript
async function buildSaveSlotUI(): Promise<void> {
const slots = await saveManager.listSlots();
for (const slot of slots) {
renderSlotCard({
index: slot.index,
name: slot.metadata?.name ?? `Slot ${slot.index + 1}`,
timestamp: new Date(slot.metadata?.timestamp ?? 0).toLocaleString(),
onLoad: () => loadGame(world, slot.index),
onDelete: async () => {
await saveManager.delete(slot.index);
rebuildSlotUI();
},
});
}
}Schema Migration
As your game evolves, save file schemas change. Handle migrations gracefully:
typescript
const CURRENT_SAVE_VERSION = 5;
function migrateSaveData(data: unknown): CurrentSaveData {
let d = data as AnySaveData;
// Apply migrations in order
if (d.version < 2) d = migrateV1toV2(d);
if (d.version < 3) d = migrateV2toV3(d);
if (d.version < 4) d = migrateV3toV4(d);
if (d.version < 5) d = migrateV4toV5(d);
return d as CurrentSaveData;
}
function migrateV3toV4(d: SaveV3): SaveV4 {
// v4 added the new skill system
return {
...d,
version: 4,
player: {
...d.player,
skills: { points: 0, unlocked: [] }, // default for old saves
},
};
}Checkpoints
Automatic checkpoints at key moments:
typescript
import { CheckpointSystem, Checkpoint } from '@web-engine-dev/save';
world.addSystem(CheckpointSystem);
// Define checkpoint triggers in the world
world.spawn(
[Position, { x: 500, y: 300 }],
[
Checkpoint,
{
id: 'level-1-mid',
respawnPosition: { x: 500, y: 280 },
autoSave: true, // immediately write to autosave slot
showIndicator: true, // brief "checkpoint" UI notification
},
],
[Collider, { shape: 'box', width: 32, height: 64, isTrigger: true }]
);
// When the player hits the trigger, CheckpointSystem fires:
// - CheckpointReachedEvent
// - Auto-save if configured
// - Update last respawn pointCloud Saves
The @web-engine-dev/save package does not include a built-in cloud provider, but you can implement the StorageProvider interface for any backend (Firebase, Supabase, custom API):
typescript
import type { StorageProvider } from '@web-engine-dev/save';
class MyCloudProvider implements StorageProvider {
async read(key: string): Promise<Uint8Array | null> {
// fetch from your API
const resp = await fetch(`/api/saves/${key}`);
if (!resp.ok) return null;
return new Uint8Array(await resp.arrayBuffer());
}
async write(key: string, data: Uint8Array): Promise<void> {
await fetch(`/api/saves/${key}`, { method: 'PUT', body: data });
}
async delete(key: string): Promise<void> {
await fetch(`/api/saves/${key}`, { method: 'DELETE' });
}
async list(): Promise<string[]> {
// return available slot keys
return [];
}
}
const cloudManager = new SaveManager<GameState>({
storageProvider: new MyCloudProvider(),
version: { major: 1, minor: 0, patch: 0 },
maxSlots: 10,
});
await cloudManager.initialize();Game Flags & Quest Tracking
Flags and quest state are stored as part of your typed game state; define them in your GameState interface and they are serialized automatically:
typescript
interface GameState {
flags: Record<string, boolean>;
quests: Record<string, 'active' | 'complete' | 'failed'>;
player: PlayerData;
// ...
}
// Save includes flags automatically
await saveManager.save(0, {
flags: { tutorialComplete: true, bossDefeated_goblinKing: false },
quests: { mainQuest: 'active' },
player: capturePlayerData(world),
});
// Read flags after load
const result = await saveManager.load(0);
if (result.success) {
const { flags } = result.data;
if (flags.bossDefeated_goblinKing) {
openSecretPassage(world);
}
}Next Steps
- Networking, cloud save through a game server
- Scenes & Prefabs, scene loading as part of save/load
- UI & HUD, building a save/load screen