Saving

SkillDev tools

Keeping a player's progress across a reload, a crash and a version bump. Use for saving a game, autosave, load, "my save is gone", "it forgot my progress", changing the shape of saved data, migrations, a reset or start-over button, or when a player on an old build might open a newer save.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Saving skill

What this skill tells your AI

The instructions your AI receives, as published by plausibleventures/lattice in skills/saving/SKILL.md and read by ahel’s review.

Three ideas, and every failure mode in this skill follows from one of them.

  • The chain is the version. createStore reads the current version off the migration chain's head, so declaring version 7 while shipping a chain that ends at 6 is not a bug you can write down.
  • Every failure is a value. open() never throws, for any content whatsoever. Seven ways a save fails to become a state, and every one degrades to a fresh game with a report.
  • A save stores causes, not consequences. Persist the player's brand hue, never the #rrggbb it derives to.

The alternative to a chain — parsed.version === SAVE_VERSION with a fallback to newGame() — is not a migration policy. It is a delete. The game this kit was extracted from said so in its own source: "a bump is not a migration — it is a deletion of every player's campus."


A save, its chain, and its autosave

import { asEpochMillis } from '@latticekit/core';
import { browserStorage, createStore, migrations, scheduleFrom } from '@latticekit/persist';
import type { Recognize } from '@latticekit/persist';
import type { Loop } from '@latticekit/loop';

interface V1 { readonly version: 1; readonly coins: number }
interface V2 { readonly version: 2; readonly wallet: { readonly coin: number }; readonly hue: number }

// A recognizer returns the value TYPED, or throws naming the field. Never a boolean —
// a boolean has already discarded the value that was wrong, so it cannot name it.
const isV1: Recognize<V1> = (value) => {
  const coins = (value as { coins?: unknown }).coins;
  if (typeof coins !== 'number' || !Number.isFinite(coins)) {
    throw new RangeError(`save.v1.coins: expected a finite number, got ${String(coins)}`);
  }
  return { version: 1, coins };
};

const isV2: Recognize<V2> = (value) => {
  const v = value as { wallet?: { coin?: unknown }; hue?: unknown };
  const coin = v.wallet?.coin;
  // Number.isFinite is the load-side guard that matters: JSON turns Infinity and NaN into
  // `null`, under a valid checksum, and they come back as NaN on the next tick.
  if (typeof coin !== 'number' || !Number.isFinite(coin)) {
    throw new RangeError(`save.v2.wallet.coin: expected a finite number, got ${String(coin)}`);
  }
  const hue = typeof v.hue === 'number' && Number.isFinite(v.hue) ? v.hue : 28;
  return { version: 2, wallet: { coin }, hue };
};

const chain = migrations(1, isV1)
  .step(2, 'one coin counter became a wallet', (v1) => ({
    version: 2 as const, wallet: { coin: v1.coins }, hue: 28,
  }), isV2)
  .seal();

export function openSave(loop: Loop) {
  const store = createStore({
    key: 'lighthouse:save',
    chain,
    adapter: browserStorage(),
    fresh: (): V2 => ({ version: 2, wallet: { coin: 0 }, hue: 28 }),
    // REQUIRED, and it has no default on purpose. See below.
    now: () => asEpochMillis(Date.now()),
  });

  const opened = store.open();     // never throws, whatever is in storage
  let live: V2 = opened.state;

  const auto = store.autosave(() => live, { schedule: scheduleFrom(loop.real) });
  return { store, opened, auto, set: (next: V2) => { live = next; } };
}

scheduleFrom(loop.real), never loop.real.after. The loop schedules in seconds and returns a timer id; this package schedules in milliseconds and wants a cancel function. Passing the method directly does not compile — and cast through, it asks for a write every 4,000 seconds, so the game autosaves once every 67 minutes while the status reports ok the whole time.

Whatever you pass as a schedule must keep firing in a hidden tab. requestAnimationFrame is 0 Hz when the tab is backgrounded, which is precisely the moment before a tab is closed. That is why it is loop.real and not loop.sim.

And in a browser, one more line:

import { installFlushTriggers } from '@latticekit/persist';
import type { Autosave } from '@latticekit/persist';

export function flushOnLeave(auto: Autosave): () => void {
  return installFlushTriggers(auto, { visibility: document, page: window });
}

beforeunload does not fire reliably on mobile Safari. installFlushTriggers binds visibilitychange (guarded on hidden) and pagehide instead. The disposer it returns deliberately does not flush — a disposer that writes is the same bug wearing a different hat.


Every failure is a value, and one of them is not a failure at all

reasonwhat happenedwritable after?
unreadablestorage itself refused to be readyes
malformednot JSON, or not an envelope — something else wrote to this keyyes
corruptchecksum mismatch, or a payload that did not parseyes
futurethe save is newer than this buildno
orphanedthe save is older than the chain flooryes
migration-faileda rung threw, or a step's recognizer rejected its own outputyes
invalidreached the head and the head recognizer still refusedyes

source: 'fresh' with firstRun: false is a save that was lost. They are separate fields for exactly that reason, and a game that cannot tell the two apart will report a healthy funnel while quietly losing people.

A save from the future makes the store read-only, and that is the most important row. The player opened a stale deploy — a cached index.html, a service worker, a second device on an old build. Their v9 save is fine; the v7 build cannot read it. Degrading to fresh is correct in memory and catastrophic on disk, because the old build would autosave four seconds later and overwrite a good save with an empty game. So future is the one reason that also sets writable: false, every write then skips, and storage comes out of the session byte-identical.

The unreadable text is copied to ${key}:rejected and read back through store.rejected(). Degrading to fresh without keeping the bytes destroys the only copy of the bug that just ate a player's progress.


Telling the player, without saying anything technical

store.status is one of four bare string literals, most severe first. It never carries a timestamp, an attempt count, a byte size or a version number — interpolating a detail defeats a latch in exactly the case the latch exists for, because the autosave rediscovers a full quota every four seconds and a status that differs each time is shown each time.

statuswhat the player loses by missing itso it is
refusing-newerthe whole session, silently, and they cannot tella modal they must acknowledge
write-failingeverything since the last good write, growinga modal if it persists
not-persistentthis session, once they close the taba toast, latched once
oknothingnothing

The choice is not how alarming the message sounds. It is what the player loses by missing it. A modal about a hypothetical blocks a first-time player at the door; a toast about a session that will not survive the tab closing expires unread.

import type { Store } from '@latticekit/persist';

export function tell(store: Store<unknown>, toastOnce: (k: string, m: string) => void): void {
  if (store.status === 'not-persistent') {
    toastOnce('storage-not-persistent', 'This browser may not keep your progress.');
  }
}

Latch on the condition, never on the rendered text: a message carrying a byte count changes on every rediscovery and defeats a deduplication written for precisely that case.

status is readable the moment open() returns, before a single tick, because the newer-save case has to reach a player whose session has not started.


Reset closes before it removes

close the store to writes  →  stop every autosave handle  →  remove the key and its quarantine

localStorage.clear() followed by a reload does not reset a game: the live autosave flushes on pagehide and writes the state back over the clear. store.reset() does it in the order that works, and after it returns no code path in the package writes to the adapter until open() is called again.

reset() is scoped to one store. There is no resetEverything(): it would be four lines and it would put the player's volume back at full at one in the morning.


The trap that will find you: hashString walks UTF-16 code units

Two consequences pull in opposite directions, and which applies depends on whether you are hashing text a human means or bytes a machine wrote.

import { hashString } from '@latticekit/core';
import { defaultChecksum } from '@latticekit/persist';

// A key derived from something a player typed. NORMALIZE FIRST, ALWAYS.
// macOS hands you NFD; Windows and most browsers hand you NFC. Without this, the same
// visible name typed on two machines produces two save keys and two different worlds —
// and the bug reproduces on nobody's machine.
export function keyFor(playerName: string): string {
  return `game:save:${hashString(playerName.normalize('NFC')).toString(36)}`;
}

// A checksum over a payload. NEVER normalize — the bytes are the subject, and a save
// truncated mid-combining-sequence must fail.
export function checksum(payloadText: string): string {
  return defaultChecksum(payloadText);
}

Ask which of the two you have before you reach for normalize.


Five more, mined from a game that shipped

  • Private-mode Safari throws on the property access, not just on the write. browserStorage() wraps the read of localStorage itself, then probes with a real write, then degrades to memory. Do not hand-roll this.
  • JSON quietly destroys NaN and Infinity — both serialize to null, with a valid checksum, and come back as NaN on the next tick. A Number.isFinite check on every currency inside the head recognizer is worth more than a recognizer that checks thirty field names and no ranges.
  • Two live tabs share one key and the loser's flush wins. The default is last-write-wins; conflict: 'refuse' costs one extra read per write. It is detection, not a lock, deliberately — a half-lock that leaks on a crashed tab locks a player out of their own game permanently.
  • Autosave has no dirty check. A player standing still still writes the same bytes on every tick, and minWriteIntervalMs is the only lever. Three seconds is a reasonable floor for a short session: at 1,000 ms a "saved 0s ago" readout says 0s forever, which proves a save is happening and proves nothing about when.
  • The economy's node list is the save's field order. If you are saving a sim ledger, adding a node in v4 must go on the end, and a node added purely as a modelling workaround is a field in every save file forever.

now has no default, and the reason is worth reading once

now is a required field. Defaulting it to () => 0 would be the worst bug in this package's reach: every save would load with an elapsed of zero, offline progress would pay out nothing, and nothing would look broken.

Keep the one Date.now() on a single greppable line in your boot. It never reaches a tile, a hash or a pixel, so the world stays deterministic.


A replay is evidence, and evidence is never migrated

savereplay
old formatmigrated, rung by rungrefusedorphaned
mechanisma chain from floor to heada chain with no rungs: migrations(N, isLog).seal()
near-misstolerated; a recognizer may normalize as it validatesrefused, by name
failure costsa player's progressa test result nobody should have trusted

A session recorded at a 16.667 ms step and replayed at 20 ms produces a confident wrong answer, which is worse than a refusal. So a replay store is an ordinary createStore whose chain's floor equals its head — no second code path, and an old replay reads as orphaned through machinery that already exists.


Testing a chain

One fixture per historical version, from the floor up, fed through store.decode. Compile-time and construction-time checks catch a hole in the chain three ways; only a fixture catches a rung that exists and is wrong, which is the failure that actually ships.


What this skill does not cover

you wantread
the loop that drives the autosave schedulestarting
offline progress arithmetic, and what a stored price costs youeconomy
the modal or toast that reports a statushud
replays, divergence and cross-engine agreementdeterminism

Long form, on disk: node_modules/@latticekit/persist/README.md.

Signals

GitHub stars
38
Forks
5
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
saving
Source
github.com/plausibleventures/lattice