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.