@dloizides/save-state
v1.4.1
Published
Framework-agnostic local save/progress primitives for games: a KeyValueStore port (localStorage/memory), corruption-safe JSON I/O, and a generic versioned, slotted SaveStore. Zero deps, isomorphic. Extracted from Morphe + Aurora.
Maintainers
Readme
@dloizides/save-state
Framework-agnostic local save/progress primitives for games. Zero runtime dependencies, isomorphic (works in browser games, React Native shells, and tests — the store is structural and injectable).
Three layers, adopt what fits your model:
- Storage port —
KeyValueStoreinterface +MemoryKeyValueStore+defaultKeyValueStore()(browserlocalStoragewhen present, in-memory otherwise). - Corruption-safe JSON I/O —
readJson/writeJson/removeKeys. None ever throw into the game loop. SlotStore<T>— a generic, versioned, slotted save store (one key per slot), modelled on the common "N save slots, version-guarded, corruption-safe" pattern.
Install
npm install @dloizides/save-stateSlotStore (one key per slot)
import { SlotStore } from '@dloizides/save-state';
interface MySave { version: number; timestamp?: number; level: number }
const saves = new SlotStore<MySave>({ prefix: 'mygame:save:', version: 3 });
saves.save(1, { version: 3, timestamp: Date.now(), level: 7 });
saves.load(1); // MySave | null (null if absent, corrupt, or a different version)
saves.exists(1); // boolean
saves.meta(1); // { slot, exists, timestamp? } — for a save-picker, no full load
saves.delete(1);load() returns null for any stored save whose version differs from the configured schema version,
unless you pass a migrate hook (1.4):
const saves = new SlotStore<MySave>({
prefix: 'mygame:save:',
version: 3,
// older save -> upgraded save on version 3, or null. The result is written back, so it runs once.
migrate: (stored, fromVersion) => (fromVersion === 2 ? { ...stored, version: 3, level: 1 } as MySave : null),
// empty slot -> a first save built from old raw keys, written back once. null = nothing to import.
importLegacy: () => null,
});exists() and meta() also seed an empty slot through importLegacy (1.4.1), so a save picker sees an
imported save before the first load(). A newer save is never passed to migrate; a failed, throwing or wrong-version migration returns null
and leaves the stored bytes untouched. fromJson imports an older payload through migrate too; without
a migrate hook it still returns MigrationRequired (1.3 behaviour).
Settings store (1.4)
import { createSettingsStore } from '@dloizides/save-state';
const settings = createSettingsStore({ key: 'mygame:settings', defaults: { music: 3, sfx: 3, muted: false } });
settings.get(); // frozen snapshot; unknown keys dropped, wrong-typed/missing keys take the default
settings.set('music', 1); // false = not persisted (quota/private mode); memory + listeners still update
settings.patch({ muted: true });
settings.reset();
const off = settings.subscribe((next) => applyVolume(next));Record book (1.4)
import { createRecordBook, keyRecordSource, RecordOrder } from '@dloizides/save-state';
const bestTimes = createRecordBook<'neon' | 'dunes'>({
order: RecordOrder.Lower, // Higher for scores
source: keyRecordSource('mygame:records'), // corrupt JSON reads as {}
});
bestTimes.submit('neon', 55.2); // { isNew, prev, delta } delta = value - prevA non-finite or negative value is rejected (isNew: false, delta: null) and nothing is written. A tie is
not a new record.
Storage port + JSON helpers (custom model)
For a bespoke layout (e.g. all slots in one blob, or multi-version migration), build on the lower layers:
import { defaultKeyValueStore, readJson, writeJson, removeKeys } from '@dloizides/save-state';
const store = defaultKeyValueStore();
const save = readJson<MySave>(store, 'mygame:save', { validate: isMySave }); // null-safe
writeJson(store, 'mygame:save', save); // false if blocked (private mode/quota)
removeKeys(store, ['mygame:save', 'mygame:v1']); // best-effort, never throwsAPI
KeyValueStore,MemoryKeyValueStore,defaultKeyValueStore()readJson<T>(store, key, { validate? }),writeJson(store, key, value),removeKeys(store, keys)SlotStore<T extends VersionedSave>({ prefix, version, store?, migrate?, importLegacy? })—save / load / delete / exists / meta / toJson / fromJsoncreateSettingsStore<T>({ key, defaults, store? })—get / set / patch / reset / subscribecreateRecordBook<Id>({ order, source })—get / submit / all;keyRecordSource(key, store?);RecordOrder- types:
VersionedSave,SlotMeta,SlotStoreOptions,ReadJsonOptions,ImportError,ImportResult,SettingsStore,SettingsStoreOptions,RecordBook,RecordBookOptions,RecordResult,RecordSource
License
MIT
