Skip to content

Offline progress

Credit away-time on reload and on tab return, with a "welcome back" summary. Three pieces: pageLifecycle (detects and measures backgrounding), offline-progress (caps and advances), and the save's savedAt timestamp (measures reloads).

Wiring

ts
import { createEngine, SaveManager, formatDuration, formatNumber } from "@idlekitjs/core";
import { LocalStorageAdapter } from "@idlekitjs/storage/local-storage";
import { autosave } from "@idlekitjs/plugins/autosave";
import { offlineProgress } from "@idlekitjs/plugins/offline-progress";
import { pageLifecycle } from "@idlekitjs/browser/page-lifecycle";

const MAX_OFFLINE_MS = 8 * 60 * 60 * 1000; // 8 h cap — game design, yours to pick

const engine = createEngine<State>({ initialState /* , renderer, scheduler */ });
engine.addSystem((state, dt) => {
  state.coins += state.rate * dt;
});

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

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

That's functionally complete: hiding the tab pauses the loop; returning (or reloading) advances the simulation by the capped elapsed time, in the same fixed steps as real time — so every mechanic behaves exactly as if the player had stayed.

The "welcome back" summary

The plugin advances the state but doesn't narrate. Snapshot around the catch-up points yourself.

Reload catch-up happens inside engine.load:

ts
const before = engine.state.coins;
const hadSave = await engine.load(save);
const gained = engine.state.coins - before;

if (hadSave && gained > 0) {
  showToast(`While you were away: +${formatNumber(gained)} coins`);
}

engine.start();

Same-session catch-up is announced by the resume event — note that extensions registered before offlineProgress see the event first, so snapshot in a handler registered before it, or simply report the elapsed time:

ts
engine.events.on("resume", (elapsedMs) => {
  if (elapsedMs > 60_000) {
    showToast(`Welcome back! You were away ${formatDuration(elapsedMs)}`);
  }
});

Testing it quickly

Simulate a stale save instead of waiting an hour:

ts
import { MemoryAdapter } from "@idlekitjs/storage/memory";

const adapter = new MemoryAdapter();
adapter.write(
  "my-game",
  JSON.stringify({
    version: 1,
    savedAt: Date.now() - 3_600_000, // "saved" an hour ago
    state: { coins: 0, rate: 2 },
  }),
);
// load through a SaveManager on this adapter -> 2 coins/s × capped 1 h

(That's exactly how the toolkit's own tests exercise it.)

Gotchas

  • Both pieces, always. Without pageLifecycle, backgrounding gives you a throttled half-simulation instead of a clean pause + credit. Without offlineProgress, elapsed time is measured and thrown away.
  • The cap is per catch-up, not per day — pick it as a design decision and say it in-game.
  • Boosts and crafting jobs run down during catch-up (consistent with the production they affect) — see the boosts note if you want wall-clock exceptions.