Skip to content

Writing a custom adapter

Need IndexedDB, native storage, a sync server, or compression? Implement the three-method SaveAdapter contract and hand it to the SaveManager — no other line of your game changes.

The contract

ts
import type { SaveAdapter } from "@idlekitjs/core";

interface SaveAdapter {
  read(key: string): string | null | Promise<string | null>;
  write(key: string, value: string): void | Promise<void>;
  remove(key: string): void | Promise<void>;
}

Rules of the road:

  • Values are opaque strings — the SaveManager owns the JSON envelope (version, timestamp); your adapter just stores bytes.
  • Sync or async both work — the manager awaits everything.
  • read may throw or reject — the manager catches it and treats the save as absent. Prefer returning null for "no save"; reserve errors for real failures.

Example: sessionStorage

The smallest real adapter — persists for the tab session only:

ts
import type { SaveAdapter } from "@idlekitjs/core";

export class SessionStorageAdapter implements SaveAdapter {
  read(key: string): string | null {
    return sessionStorage.getItem(key);
  }

  write(key: string, value: string): void {
    sessionStorage.setItem(key, value);
  }

  remove(key: string): void {
    sessionStorage.removeItem(key);
  }
}

Example: REST backend

Async adapters are the same shape with promises:

ts
import type { SaveAdapter } from "@idlekitjs/core";

export class HttpAdapter implements SaveAdapter {
  constructor(private readonly baseUrl: string) {}

  async read(key: string): Promise<string | null> {
    const response = await fetch(`${this.baseUrl}/saves/${key}`);
    if (response.status === 404) {
      return null;
    }
    if (!response.ok) {
      throw new Error(`save read failed: ${response.status}`);
    }
    return response.text();
  }

  async write(key: string, value: string): Promise<void> {
    await fetch(`${this.baseUrl}/saves/${key}`, { method: "PUT", body: value });
  }

  async remove(key: string): Promise<void> {
    await fetch(`${this.baseUrl}/saves/${key}`, { method: "DELETE" });
  }
}

Async adapters and page close

The autosave plugin's close-time save relies on the write finishing before the page unloads — which async network writes can't guarantee. Pair a remote adapter with a local one (write locally always, sync remotely opportunistically), or accept that the last seconds may be lost.

Wiring it up

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

Everything downstream — autosave, migrations, offline progress — works unchanged, because they only ever see the SaveManager.

Testing your adapter

Round-trip it through a real SaveManager (see the MemoryAdapter tests pattern): save a state, load it back, assert equality; then corrupt the stored string and assert load() returns null instead of throwing.