Appearance
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 closeBecause 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 saveEach 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
| Adapter | Use |
|---|---|
LocalStorageAdapter | Browsers — synchronous, survives pagehide |
MemoryAdapter | Tests, SSR, ephemeral sessions |
| Custom | IndexedDB, 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)beforeengine.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.
Related
- Save & load recipe — complete setup.
- Storage overview — the adapter contract.
- Autosave plugin.