Skip to content

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 point

Cloud 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

Proprietary software. All rights reserved.