Skip to content

Saving your game

IdleKit's persistence stack has three small parts that compose cleanly:

txt
SaveManager (core)      versioning, migrations, timestamps, JSON


SaveAdapter (storage)   where bytes go: memory, localStorage, your backend


autosave (plugins)      when to save: interval, tab hidden, page close

Because your state is a plain object (State), there is no serialization step to write — the whole state is the save.

SaveManager

ts
import { SaveManager } from "@idlekitjs/core";
import { LocalStorageAdapter } from "@idlekitjs/storage/local-storage";

const save = new SaveManager<State>({
  key: "my-game",
  version: 1,
  adapter: new LocalStorageAdapter(),
});

await save.save(engine.state); // persist now
await save.clear(); // delete the save

Each save is stored as { version, savedAt, state }. savedAt powers offline progress; version powers migrations.

load() never throws: a missing or corrupted save returns null, and the JSON parsing neutralizes prototype-pollution attempts in hand-edited saves.

Loading

Prefer engine.load(save) over calling save.load() yourself:

ts
const loaded = await engine.load(save); // true if a save existed
engine.start();

It assigns the saved state and emits the loaded event — the signal every extension uses to restore itself: projects re-mark completions, collections and boosts republish their modifiers, crafting heals its jobs, offline progress credits the away time. If you bypass it, restore logic silently doesn't run.

Migrations

When your state shape changes, bump version and describe how to upgrade each step. migrations[n] takes a version n - 1 save and returns a version n one; the manager chains them as needed:

ts
const save = new SaveManager<State>({
  key: "my-game",
  version: 3,
  adapter: new LocalStorageAdapter(),
  migrations: {
    // v1 -> v2: coins were renamed
    2: (data) => {
      const old = data as { gold?: number };
      return { ...(data as object), coins: old.gold ?? 0 };
    },
    // v2 -> v3: new field with a default
    3: (data) => ({ ...(data as object), generators: 0 }),
  },
});

A player who skipped versions gets every migration applied in order. Saves with no version field are treated as version 0.

Defensive loading, twice over

Migrations are your schema safety net; on top of that, mechanics heal themselves on loaded (dropping jobs for deleted recipes, clamping boost timers to current definitions). Between the two, a stale save degrades gracefully instead of crashing.

Autosaving

Don't sprinkle save.save() calls — install the autosave plugin once:

ts
import { autosave } from "@idlekitjs/plugins/autosave";

engine.use(autosave({ manager: save, getState: () => engine.state, intervalMs: 15_000 }));

It saves on an interval and at the moments that actually matter on the web: when the tab goes hidden (visibilitychange) and on page close (pagehide) — the reliable signals on mobile, where the OS may kill a background tab without warning.

For save-worthy moments the interval might miss, the mechanics expose callbacks — save on meaningful progress:

ts
const projectsExt = projects<State>({
  // ...
  onComplete: (_id, state) => void save.save(state),
});

Choosing an adapter

AdapterUse
LocalStorageAdapterBrowsers — synchronous, survives pagehide
MemoryAdapterTests, SSR, ephemeral sessions
CustomIndexedDB, native storage, a server

The SaveAdapter contract is three methods (read, write, remove), sync or async — writing your own takes minutes.

Common pitfalls

  • Loading after rendering starts — the player sees the initial state flash. await engine.load(save) before engine.start().
  • Forgetting getState: () => engine.state — autosave needs the live state; passing a snapshot saves stale data forever.
  • Non-JSON values in the state — they vanish on the round-trip. See State.