@latticekit/persist — saves that survive a version bump, a crashed tab and a browser that
lies about its storage.
It does that by making the save an explicitly versioned envelope, the upgrade an
explicit chain of one-step migrations, and every failure a reported value instead of
a thrown exception on boot.
Two consequences shape the whole surface:
- The storage adapter is injected.
localStorage is named in exactly one function in this package (browserStorage, in the one module marked @browser-only), and everything else — chain, envelope, checksum, coalescing, quarantine, replay — runs and tests in Node with no shims. - The read path never throws. Boot is the one moment a game cannot recover from an exception, because there is no UI yet to show it in. All seven ways a save can be unusable are fields on a returned object.
const chain = migrations(1, isV1)
.step(2, 'one coin counter became a wallet of currencies', v1 => ({ version: 2, wallet: { coin: v1.coins } }), isV2)
.seal();
const store = createStore({ key: 'campus', chain, adapter: browserStorage(), fresh: newGame, now });
const opened = store.open(); // never throws. `opened.failure` says why if it degraded
const auto = store.autosave(() => game.state, { schedule: scheduleFrom(loop.real) });
installFlushTriggers(auto, { visibility: document, page: window });
scheduleFrom and not loop.real.after: loop schedules in seconds and this package in
milliseconds, so passing the method directly is a compile error — and, cast away, an
autosave every 67 minutes that nothing reports. The conversion lives in one place.
There is no version option, because the chain is the version; no validate option,
because validation is per-version inside the chain; no timer, because schedule is
injected; and no clock, because reading one would break non-negotiable #1 and defaulting one
would silently zero every offline gap.
A note on the examples in this package
Anything in a doc comment that looks like a call is reachable from a test, and that is a
rule rather than an aspiration. Two examples in this package were once wrong — one wired
loop.real.after straight into schedule, which is a compile error on the return type and
a 67-minute autosave interval once someone casts it away; the other composed a core guard
that cannot accept an unknown. Both survived a review, a full suite and 100% coverage,
because prose is not compiled and nothing was checking it.
The lesson is narrower and more useful than "keep docs current": a run-tested example and
a hand-written one are indistinguishable to a reader, and are read with equal trust. A
reader copies the snippet that is nearest to the symbol they are looking at, not the one
that happens to be under test. So an example either compiles and runs somewhere, or it is
marked as a sketch — and the cheapest way to keep that honest is to paste the doc's example
into the test file verbatim and let tsc and vitest own it from then on.
The migration chain: the only version number in the system.
A save format's version is not a constant a build declares and a chain then tries to keep
up with — it is the head of the chain. createStore reads it off chain.head, so
"declaring 7 while shipping a chain that ends at 6" is not a bug you can write down.
The chain is proven to have no holes three times over: at compile time, because step's
to is typed Increment<Head>; at construction, because seal() re-walks the rungs for
callers who arrived from JavaScript; and in a test, because only a test can catch a rung
that exists and is wrong.
The trap this replaces, from the game this kit was extracted from: parsed.version ===
SAVE_VERSION, with a fallback to createGame() for everything else. That is not a
migration policy. It is a delete, and its own source said so — *"a bump is not a migration
— it is a deletion of every player's campus."*
type Increment<N extends number, Counter extends readonly unknown[] = []> = Counter['length'] extends N ? [...Counter, unknown]['length'] & number : Increment<N, [...Counter, unknown]>
N + 1, at the type level, so a chain that skips a version does not compile.
Counting with a tuple caps out around 999, which is roughly 990 more save formats than any
game has ever shipped. Past that the compiler reports an excessively deep instantiation and
you have a different problem.
type Recognize<T> = (value: unknown) => T
How a version recognizes itself: returns the value typed, or throws.
Not (value: unknown) => value is T. A boolean predicate has already discarded the thing
that was wrong by the time it returns, so it cannot produce the message house rule 9
demands — it can only ever say "no". This is the same shape @latticekit/core's guard
module took for the same reason, and it composes directly with it:
import { expectObject, expectRecordOfFinite } from '@latticekit/core';
const isV2: Recognize<V2> = value => {
const o = expectObject(value, 'save.v2');
return { version: 2, wallet: expectRecordOfFinite(o['wallet'], 'save.v2.wallet') };
};
That example compiles, and test/migrate.test.ts runs it verbatim — see the note on
examples in index.ts about why that matters.
Note the bracket access. expectObject returns Record<string, unknown>, and the repo
builds with noPropertyAccessFromIndexSignature, so o.wallet does not compile and
o['wallet'] does. The guards that take a number (expectFinite, expectSerializable)
cannot be handed an unknown straight from JSON.parse without a cast; reach for the
unknown-accepting ones (expectObject, expectRecordOfFinite) on the save path, and
check a lone scalar with a typeof of your own until core widens the others.
Two things fall out of returning rather than asserting. The thrown message travels into
ReadFailure.message, so a rejected save says which field was wrong instead of "the
guard said no" — the difference between a fixable bug report and a shrug. And a recognizer
may normalize as it validates, returning a repaired value, which is the cheapest
possible migration for a field that only ever needed a default.
There is no optional variant, no default v => v as T, and no "skip validation in
production" flag. It is also not an assertion function: build tools strip those, which
would leave the check running only where it is least needed.
Make it as loose as you can defend. Checking the two or three fields your migration
actually reads beats a field-by-field validator nobody maintains — but do check that your
currencies are finite (expectSerializable), because Infinity serializes to null and
comes back as NaN with a perfectly valid checksum.
interface MigrationStep {
One rung, for reporting and for tests. why is prose a reviewer reads, not a label.
3 members
readonly from: number
readonly to: number
readonly why: string
interface MigrationChain<Head extends number, T> {
A sealed chain from floor to head with no gaps.
head is carried in the type, which is how createStore knows the current version without
being told it twice.
5 members
readonly floor: numberThe oldest version still readable. A save older than this reads as orphaned —
deliberate, announced data loss, which is why the floor is an argument and never
inferred. Raising it should be a commit of its own with the number in the message.
readonly head: Head
readonly steps: readonly MigrationStep[]
recognize(value: unknown): TThe head recognizer. store.decode runs it last; a throw becomes invalid, carrying the
thrown message.
run(value: unknown, from: number, onEnter?: (version: number) => void): TRun value from version from up to head, one rung at a time, recognising at every
version on the way.
Throws — the only throwing function in the package — because a migration is game code
and can do anything. store.decode is the caller that wraps it and turns the throw into
a migration-failed failure naming the rung. Exported so a test can drive a fixture
through the chain without a store.
interface ChainBuilder<Head extends number, Current> {
The chain under construction. migrations() starts one; seal() ends it.
2 members
step<Next extends Increment<Head>, Migrated>(to: Next, why: string, migrate: (prior: Current) => Migrated, recognize: Recognize<Migrated>): ChainBuilder<Next, Migrated>Add the rung Head → Head + 1. to is typed Increment<Head>, so
migrations(1, isV1).step(3, …) fails to compile with
Argument of type '3' is not assignable to parameter of type '2'.
migrate receives the previous version typed, because the previous version was
recognized by its own recognizer before it was handed over. That is the whole reason a
recognizer is mandatory rather than optional: without it a migration reads unknown and
every line in it is a cast.
There is no 3 → 7 shortcut and there will not be one. A shortcut means two paths from 3
to 7 and only one of them is ever exercised; the untested one is the path a player's
four-year-old save takes.
ThrowsTypeError if migrate or recognize is not a function, or why is empty. A
developer error at construction, which is a different moment from a player's save at
boot and is allowed to be loud.
seal(): MigrationChain<Head, Current>Freeze. Re-checks the chain at runtime for callers who arrived from JavaScript or through
an any, and throws RangeError naming the missing version.
A hole caught here is a developer error at construction. A hole caught at decode time
would present as a player losing a save, which is why this exists at all.
ThrowsRangeError if the rungs do not form floor → head in steps of exactly one.
function migrations<Floor extends number, T>(floor: Floor, recognize: Recognize<T>): ChainBuilder<Floor, T>
Start a chain at the oldest version you still support, with the recognizer for that version.
Every version has a recognizer, mandatory, including the floor. That is how migrate
receives a typed argument instead of unknown: a chain of migrations that each begin with
a cast is not a chain, it is a stack of hopes.
Parameters
ThrowsRangeError if floor is not an integer, TypeError if recognize is not a
function.
The envelope, the read pipeline, and the store.
Two rules govern everything in this file and they are worth stating before the code:
- The read path never throws. Boot is the one moment a game cannot recover from an exception, because there is no UI yet to show it in. Every read outcome — including all seven ways a save can be unusable — is a field on a returned object.
- The write path never throws either, for a different reason: the write that matters happens inside a
pagehide handler on a page that is being discarded, where an exception is both unhandleable and invisible. A failed write is a WriteResult.
Neither rule extends to construction. createStore and seal() throw loudly at
nonsense, because a developer error at startup is a different moment from a player's save
at boot and should be as loud as possible.
interface Envelope {
What is actually on disk. Five short keys, because this string is re-encoded every four
seconds for the life of a session.
d is the payload as a JSON string, not a nested object, for two reasons that are
worth the double encoding:
- the checksum then covers the exact bytes read, not a re-serialization of a parse. A checksum computed over
JSON.stringify(JSON.parse(text)) is a checksum of your serializer's key ordering, and it will pass over damage and fail over nothing; v stays readable when d is garbage. Detecting a save from the future must not require parsing a payload written by a build that no longer exists.
Use inspect() rather than eyeballing it in devtools.
5 members
readonly v: numberSave format version — the head of the chain that wrote it.
readonly t: EpochMillisWhen it was written, in epoch ms, read from the store's injected now — this package
has no clock of its own. The only timestamp in the format.
readonly n: numberWrite sequence, monotonic per key. Only used for cross-tab conflict detection.
readonly c: stringchecksum(d).
readonly d: stringThe game's state, JSON-encoded.
type FailureReason =
/** Storage itself refused to be read. Private mode, a disabled setting, an I/O error. */
'unreadable'
/** Not JSON, or JSON that is not an envelope. Something else wrote to this key. */
| 'malformed'
/** Checksum mismatch, or the payload did not parse though the envelope did. Damaged bytes. */
| 'corrupt'
/**
* `v` is above the chain head: the player has opened an older deploy. This one is not the
* player's fault and must not cost them their save — it is the only reason that also sets
* `writable: false`.
*/
| 'future'
/** `v` is below the chain floor: a save from before the versions this build still carries. */
| 'orphaned'
/**
* A migration threw, or a step's recognizer rejected its own output. `atVersion` names the
* rung and `message` carries what the recognizer said was wrong.
*/
| 'migration-failed'
/**
* Migrated to the head and the head recognizer still threw. The chain has a bug, or
* something else has been writing this key.
*/
| 'invalid'
Every way a save can fail to become a state. Closed union; a reviewer can count the
branches, and every one of them degrades to a fresh state rather than throwing.
interface ReadFailure {
The report. This is what "reported" means: a value, not a log line and not a thrown
error.
The package never renders text at a player, never calls console, and never phones home.
It hands the game a record the game can log, count, put behind a debug panel, or show as
"we could not read your save" in its own voice — and that a test can assert on exactly.
7 members
readonly reason: FailureReason
readonly message: stringNames the caller's mistake in prose, with the key and the versions in it — e.g.
persist: save "campus" is version 9 but this build reads up to 7 — the player has an
older deploy. Storage was left untouched and this store will not write.
For a game's own logs and bug reports. Do not show it to a player: it is written in
one voice and one language, and a game that puts it on screen has a sentence in its UI it
cannot change without patching a dependency. Switch on reason and say it yourself.
readonly savedVersion: number | nullThe version on disk, or null when the envelope was not readable at all.
readonly atVersion: number | nullFor migration-failed, the rung that threw. null otherwise.
readonly savedAt: number | nullThe instant on disk, when the envelope carried a readable one.
readonly quarantined: booleanWhether the offending text was kept under ${key}:rejected. false if quarantine is
off, if the quarantine write itself failed, if the text was never read, or for future —
which is never quarantined because nothing is being destroyed.
readonly cause: unknownWhatever was thrown, unchanged. unknown because a migration can throw a string.
interface OpenResult<T> {
What open() and decode() produce. Always playable, whatever happened.
8 members
readonly state: TAlways a playable state. fresh() was called if the save did not survive.
readonly source: 'save' | 'fresh'
readonly firstRun: booleanTrue only when storage held nothing. source: 'fresh' with firstRun: false is a save
that was lost, and a game that treats the two the same will report a healthy funnel
while quietly losing people.
readonly migratedFrom: number | nullThe version read from disk, when it was below the head and migrated up.
readonly savedAt: number | nullWhen the loaded save was written — non-null exactly when source === 'save'. What
offline accrual is measured from, and it can be in the future if the device clock
moved. Reported faithfully and never clamped; elapsedSince does the clamping.
readonly writable: booleanFalse when this store refuses to write over what it found. Today that is future only.
readonly durable: booleanFrom the adapter. False means this session will not be there tomorrow.
readonly failure: ReadFailure | nullNon-null exactly when source === 'fresh' && !firstRun.
type WriteSkip =
/** The store was never opened, or has been closed or reset. */
'closed'
/** A save from the future is on disk and this build must not overwrite it. */
| 'not-writable'
/** The coalescing interval has not elapsed. The overwhelmingly common skip. */
| 'too-soon'
/** Another tab has written since we last did, and `conflict: 'refuse'` is set. */
| 'conflict'
/** The envelope exceeds `maxBytes`, so the quota was not discovered by throwing. */
| 'too-large'
Why a write did not happen. Not errors — every one of these is the store working
correctly, which is why they are a separate field from error.
interface WriteFailure {
A write that was attempted and refused by the platform.
3 members
readonly reason: 'quota' | 'unavailable'
readonly message: string
readonly cause: unknown
interface WriteResult {
The outcome of one write attempt. written, skipped and error are mutually exclusive.
4 members
readonly written: boolean
readonly bytes: numberThe envelope's length in UTF-16 code units — what a browser storage quota actually
counts, and what maxBytes is compared against. 0 when nothing was serialized.
readonly skipped: WriteSkip | null
readonly error: WriteFailure | null
interface Rejected {
A save that could not be read, kept so a bug report can carry it.
Degrading to fresh without keeping the bytes destroys the only copy of the bug that just
ate a player's campus, and the support conversation is then two people guessing.
3 members
readonly failure: ReadFailure
readonly text: stringThe offending text, up to the quarantine cap.
readonly truncated: booleanTrue when text is a prefix rather than the whole of what was on disk.
type Cancel = () => void
Undo a scheduled callback. Calling it twice is not an error.
type Schedule = (afterMs: number, fn: () => void) => Cancel
Run fn after afterMs have passed, and hand back a way to cancel it.
Injected, never created. persist may not import @latticekit/loop — they are siblings on
layer 1 and the DAG forbids the edge — and it may not reach for a timer of its own, because
a package that creates a timer is a package that owns a leak. A browser game wraps
loop.real with scheduleFrom; a Node test passes a function that records its
callbacks and runs them by hand, and every coalescing test then finishes in microseconds
with no fake timers.
The unit is milliseconds, as the parameter name says and as minWriteIntervalMs,
EpochMillis and elapsedSince all say. @latticekit/loop schedules in seconds, so
loop.real.after is not a Schedule and must never be passed as one — use
scheduleFrom.
Whatever you pass must keep firing in a hidden tab. requestAnimationFrame is 0 Hz
when the tab is backgrounded, so an rAF-backed scheduler stops saving at precisely the
moment a player is most likely to close the tab.
interface SecondsTimeline {
A seconds-based timeline, structurally — loop.real and loop.sim both satisfy it.
Declared rather than imported, exactly as StorageLike declares localStorage and
ListenerTarget declares document: @latticekit/loop is a sibling on layer 1 and the DAG
forbids the edge, so the coupling is two method signatures and nothing else.
delay is in seconds and the returned handle is an opaque id, which is the whole reason
scheduleFrom exists.
2 members
after(delay: number, fn: () => void): number
cancel(id: number): boolean
function scheduleFrom(timeline: SecondsTimeline): Schedule
Turn a seconds-based timeline into the millisecond Schedule this package takes.
const auto = store.autosave(() => game.state, { schedule: scheduleFrom(loop.real) });
This function exists because the alternative is a silent hour-long outage. loop counts
in seconds throughout and this package counts in milliseconds throughout, and neither is
wrong: a save file's calendar is EpochMillis, and a frame budget is naturally sub-second.
But the two meet at exactly one call site, and a reader who writes
{ schedule: loop.real.after } and silences the return-type error with a cast has just
asked for a write every 4,000 seconds — 67 minutes — instead of every four. Nothing
looks broken. The game plays, the store reports ok, and the player loses an hour of
progress the first time the tab is closed at the wrong moment.
So the conversion lives here, in one audited place, rather than in the same three-line shim
in every game. It is also the only / 1000 in the package, and it should stay that way.
The returned Cancel is idempotent: it forgets its id after the first call, so a stale
disposer cannot cancel a timer that some later after was given the same id for. loop
never reuses ids, but this takes any conforming timeline and the guard costs one boolean.
ThrowsTypeError if timeline does not have after and cancel — a developer error at
wiring time, which is a much better moment than the first missed autosave.
type StoreStatus =
/** Reading and writing normally. The overwhelmingly common value. */
'ok'
/**
* A save exists that this build cannot read and must not overwrite. Nothing is being
* written and nothing this session will survive.
*
* Set by `open()`, cleared only by `reset()` or by an `open()` that no longer finds a newer
* save. **What the player loses by missing it: the entire session, silently.**
*/
| 'refusing-newer'
/**
* Writes are being attempted and rejected — quota, or storage revoked mid-session. Set on a
* failed write, cleared by the next successful one, so it is stable exactly as long as the
* condition is.
*
* **What the player loses by missing it: everything since the last successful write**,
* which grows for as long as they keep playing.
*/
| 'write-failing'
/**
* The adapter is not durable — private mode, or storage refused at boot. The session plays
* and will not be there tomorrow.
*
* **What the player loses by missing it: this session, once they close the tab.** Known at
* construction, and it never changes for the life of the store.
*/
| 'not-persistent'
What is wrong with this store right now, as a condition rather than a message.
@latticekit/ui latches player-facing notices on this value, so the contract is narrow:
- It is stable while the condition is. A bare member of this union, always. It never carries a timestamp, an attempt count, a byte size or a version number. Interpolating a detail would defeat the latch in exactly the case the latch exists for — the autosave rediscovers full storage every four seconds, and a status that differs each time is shown each time. The details live on
Autosave.lastWrite, store.rejected() and OpenResult.failure; the status is the key, not the payload. - It is readable the moment
open() returns, before a single tick. The newer-save case has to reach a player whose session has not started. - One value, most severe first.
refusing-newer masks write-failing masks not-persistent, and that is correct rather than a compromise: a store that is refusing to write has nothing to say about whether its writes would have survived.
interface StoreOptions<Head extends number, T> {
Everything createStore needs. Four of these are required and none of them has a default.
12 members
readonly key: stringThe storage key, and with it the lifetime. One store, one key, and no store ever reads
or writes another store's key — which is what makes progress, settings and replays
genuinely independent rather than independent by convention.
The convention this kit recommends: campus:save, campus:settings,
campus:replay:<id>, plus campus:save:rejected, which the store manages itself. Save
slots are separate stores on separate keys; there is no slot concept and there does not
need to be one.
If any part of the key is derived from something a player typed, hash it through
hashString(name.normalize('NFC')) — see defaultChecksum's note. Without the
normalization the same name typed on macOS and on Windows produces two different keys and
two different worlds.
readonly chain: MigrationChain<Head, T>The chain. Its head is the store's version; there is no other version number.
readonly adapter: StorageAdapter
readonly fresh: () => TA brand-new game. Called on first run and on every degraded read. Must not throw — if
this throws, boot is over and there is nothing left to degrade to.
readonly now: NowThe game's calendar. Required, with no default, deliberately.
persist stamps savedAt and cannot read a clock of its own: Date.now is banned inside
every package's src/ and the linter enforces it. Defaulting this to () => 0 would be
the worst bug this package could ship, because every save would load with an elapsed time
of zero, offline progress would silently pay out nothing, and *nothing would look
broken*. A missing argument is a compile error; a zeroed timestamp is a support ticket in
eight months.
readonly minWriteIntervalMs?: numberFloor on the interval between coalesced writes. Default 4000.
readonly conflict?: 'last-write-wins' | 'refuse'What to do when another tab has written since we last did. Default 'last-write-wins',
which is free; 'refuse' costs one extra adapter read per write and reports
WriteSkip: 'conflict' so the game can say "this game is open in another tab".
This is detection, not a lock, and it is deliberately not one. Web Locks is not available
everywhere this kit runs and BroadcastChannel cannot tell you the holder was killed
rather than closed, so a half-lock leaks on a crashed tab and locks a player out of their
own game permanently. With no lock the loser merely overwrites; with a half-lock the
winner cannot play at all.
readonly checksum?: ChecksumDefault defaultChecksum, which is core's hashString as eight hex digits.
readonly quarantine?: false | {
readonly maxBytes?: number;
}Keep unreadable saves under ${key}:rejected. Default on, capped at 64 kB.
readonly maxBytes?: numberRefuse to write an envelope larger than this rather than discover the quota by throwing.
Default 1,000,000.
readonly onFailure?: (failure: ReadFailure) => voidCalled once, during open() or decode(), with the same record the result carries. For
a counter or a breadcrumb — the result is the source of truth, and a game that ignores
this loses nothing.
readonly onWriteError?: (failure: WriteFailure) => voidCalled on a failed write. Expect it more than once: a full quota does not heal.
interface AutosaveOptions {
How an Autosave decides when to write.
1 member
readonly schedule?: ScheduleThe timer, injected. When supplied, the handle re-arms itself after every write and you
never call tick.
Prefer this to polling: a tick driven by the simulation stops when the simulation stops,
and a paused or backgrounded game still owes the player the last four seconds of progress.
interface Autosave {
A coalescing write handle bound to one getter. store.autosave makes it; store.reset and
store.close kill it.
4 members
tick(): booleanThe polling form, for a game with no scheduler. Writes iff minWriteIntervalMs has
passed since the last attempt, reading the instant from the store's injected now.
Returns a boolean, not a result object: this is called for the life of the session and
an object per call is a garbage-collector pause with a pleasant signature. The detail of
the last write that actually happened is on lastWrite.
Do not drive it from requestAnimationFrame: rAF is 0 Hz in a hidden tab, and a save that
stops when the tab is backgrounded is a save that never survives the tab being closed. A
no-op returning false when schedule was supplied, so wiring both is harmless rather
than a double write.
flush(): WriteResultWrite now if anything is owed, ignoring the interval. What the visibility handler calls.
readonly lastWrite: WriteResult | nullThe last write this handle attempted, or null. One object per real attempt, not per tick.
stop(): voidDetach and cancel any scheduled write. Idempotent. A stopped handle's tick and flush
are no-ops reporting 'closed' — which is half of why reset() actually resets.
interface Store<T> {
One key's worth of saved state, versioned by its chain.
14 members
readonly key: string
readonly version: numberThe chain head. There is no other version number in the system.
readonly phase: 'new' | 'open' | 'closed'
readonly writable: booleanFalse when a save from the future is on disk. Every write then skips 'not-writable'.
readonly status: StoreStatusThe current condition, safe to read on every frame and every update.
A plain property returning a string literal: no allocation, no event subscription, no
disposer to leak. ui polls it, latches on it, and decides for itself what a given
condition is worth interrupting a player for — which is the correct division, because this
package cannot know what the player is in the middle of.
open(): OpenResult<T>Read storage and produce a state. Never throws, for any content whatsoever. Calling it
twice re-reads; calling it after reset() or close() reopens the store.
decode(text: string): OpenResult<T>open() minus the adapter: the entire read pipeline as a function of a string.
This is the testing seam — a fixture file per historical version, run through decode, is
the regression test that the chain still reaches the head. It touches no storage, so it
quarantines nothing (failure.quarantined is always false here) and changes no store
state: the writable on its result is what open() would have set.
encode(state: T): stringThe envelope text for state, exactly as save would write it. A backup or share-code
button is this plus the game's own encoding of choice.
ThrowsTypeError if the state cannot be JSON-encoded — a BigInt, a cycle, or a bare
undefined. This is the one write-path function that throws, because it is a developer
tool rather than something a pagehide handler calls; save() catches the same failure
and reports it as a WriteResult.
save(state: T): WriteResultWrite now, unconditionally, subject only to writable, phase, maxBytes and conflict.
autosave(get: () => T, options?: AutosaveOptions): Autosave
reset(): TA real reset. In order: close the store to writes, stop every autosave handle it has
created, remove the key and the quarantine key, return a fresh state.
The ordering is the whole point. 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. The game this kit came from lost real time to exactly that, and its fix was a
hand-rolled window.game.reset(). Here it is the API: after reset() returns, no code
path in this package writes to the adapter until open() is called again.
Scoped to one store. A game's START OVER calls it on the save store only; resetting
the settings store means "back to factory volume", which is a different button most games
do not have. There is no resetEverything() and the absence is deliberate.
close(options?: {
readonly flush?: false;
} | {
readonly flush: true;
readonly get: () => T;
}): voidTear down. Pass a getter to flush on the way out; pass nothing to close silently — which
is what a "delete my save" button wants and what reset does internally. Idempotent.
rejected(): Rejected | nullThe last save this store could not read, if quarantine kept it. Survives a reload.
clearRejected(): void
function inspect(text: string): Envelope | null
The envelope only, payload untouched, or null if this is not one.
For tools, debug panels, and the future check — which must work without parsing a payload
written by a build that no longer exists. That is the whole reason d is a string.
Never throws: null covers "not JSON", "JSON of another shape", and "an envelope whose
numbers are NaN". The numeric fields are checked with core's isSerializable rather
than expectSerializable precisely because this runs on the boot path.
function elapsedSince(opened: OpenResult<unknown>, now: EpochMillis): number
Milliseconds between the loaded save and now — the offline gap, and the one derived
quantity this package will compute for you.
Returns 0 when there is nothing to measure (a first run, or a degraded read), which is the
correct elapsed time for a game that has just begun. Clamped at zero from below, because
a player who changes their device date produces a savedAt in the future and a negative
elapsed is not a thing a simulation should have to defend against.
Not clamped from above. An offline cap is a balance decision — how much of eight hours
away a game chooses to pay out — and it belongs to @latticekit/sim, not here. This function
reports the gap; sim decides what it is worth. Read OpenResult.savedAt directly if you
want to detect the backwards clock and say something about it.
function createStore<Head extends number, T>(options: StoreOptions<Head, T>): Store<T>
Build a store for one key.
There is no version option. The chain is the version: createStore reads chain.head,
so declaring 7 while shipping a chain that ends at 6 is not expressible. There is no
validate option either — validation is per-version, inside the chain, so there is one
concept rather than two.
ThrowsTypeError if key is not a non-empty string, or fresh/now is not a function.
ThrowsRangeError if minWriteIntervalMs is negative or non-finite, or maxBytes is not a
positive finite number. All of these are developer errors at construction, which is a
different moment from a player's save at boot and is allowed to be loud.
Replay: the kit's headline claim, made falsifiable.
AGENTS.md #1 promises that a session replays from a seed and an input log and lands on
the same pixel. Nothing owned that, which made the claim unfalsifiable — the worst state
for a kit selling determinism, because it is either the best feature or a lie and nothing
decides which. A replay is a save with a different payload: it needs a version, integrity,
and an envelope, and all three already live here.
A replay is evidence, and evidence is never migrated
Everything in migrate.ts argues that a save must survive at almost any cost. A replay
takes the opposite policy, and a reader arriving from there will assume otherwise, so it
is stated here as a contrast rather than left to be inferred.
A save is a player's progress, and progress that cannot be read is a loss the player feels.
A replay is evidence, and evidence that has been migrated is no longer evidence. A
session recorded at a 16.667 ms step and replayed at 20 ms will not land on the same pixel;
a session recorded under a tap threshold of 8 px and replayed at 12 px turns one pointer
stream into a different sequence of actions. "Migrating" either would produce a confident
wrong answer, and a divergence report that cannot be trusted puts the determinism claim
back where it started while looking like it has been tested.
The mechanism is worth noticing: "never migrate" is expressible in the machinery already
here, as a chain with zero rungs. A replay store is an ordinary createStore whose chain
has floor equal to head, so a replay in an older format reads as orphaned — an existing
failure reason, already meaning "older than anything this build will read", already
degrading without a throw. No second code path, and no exception to the read pipeline.
The compatibility triple is checked in a second, separate place — createVerifier, before
the first tick — because the two refusals answer different questions. orphaned means
this build cannot read the file. A Refusal means *the file is readable and was recorded
under conditions this build does not reproduce*. Collapsing them would lose the distinction
that tells you whether to go and find an older build or go and fix the step.
What is not here, and where it went
The cursor that plays a log back belongs to @latticekit/input: this package stores the
log verbatim, which necessarily means opaquely, and a package that cannot see inside a
structure cannot iterate it. The driver — constructing a game, restoring the rng
snapshot, turning the fixed-step crank — belongs to @latticekit/loop, which this package may
not import. persist hands over a log and a verifier; loop turns the crank.
interface ReplayCompat {
The only three fields this package reads out of a recorded input log.
@latticekit/input owns the log's shape and persist may not import it — input is layer 2 and
this is layer 1, so the edge does not exist. This structural constraint is therefore the
entire coupling between them: three fields, compared for exact equality, never interpreted.
Everything else about a log is opaque here and is stored verbatim.
3 members
readonly version: numberThe input log's own format version.
readonly stepMs: numberThe fixed step the session was recorded at. A replay driven at a different step is a
different simulation, however similar it looks — which is why this is compared for
equality and never coerced.
readonly profile: stringThe gesture/binding profile in force. A tap threshold that moved turns one recorded
pointer stream into a different sequence of actions.
type Digest<T> = (state: T) => number
"The same pixel", reduced to a uint32.
The game supplies it because only the game knows what is canonical: the wallet and the
building list, probably; a camera position and a tween phase, definitely not, or every
replay diverges the first time somebody scrolls. Build it from core's hashParts, and
keep it Tier A — a digest that reaches Math.pow may disagree in the last bit between
two conforming engines, and a determinism check that fails on a different browser is worse
than none.
interface Checkpoint {
Eight bytes. The interval between them trades log size against how tightly a divergence can
be bracketed — a checkpoint every ten seconds means "somewhere in these 600 ticks".
2 members
readonly tick: number
readonly digest: number
interface ReplayLog<L extends ReplayCompat> {
A recorded session: a starting stream, an input log, and the digests that make the claim
checkable.
Store it in an ordinary createStore whose chain has no rungs, so an old one reads as
orphaned rather than being migrated into a confident wrong answer.
7 members
readonly kit: stringThe kit build this was recorded under. A divergence against an unknown build is
unattributable, and an unattributable divergence report is theatre.
readonly game: stringThe game's own build identity, however the game versions itself.
readonly rng: RngSnapshotThe stream the session started from, cursor included — not just the seed. A log that
restores a seed but not the cursor re-rolls every draw the session had already spent, and
it looks correct for the first few draws, which is what makes it expensive.
readonly startTick: number
readonly endTick: number
readonly inputs: LThe input log, verbatim. Never rewritten, never normalized, never migrated.
stepMs and profile live in here rather than being copied up to this level,
deliberately: a duplicated field is a field that can disagree with itself, and the copy
that disagrees is always the one the check reads.
readonly checkpoints: readonly Checkpoint[]Ascending by tick.
interface RecorderOptions<T> {
What a recorder needs to know before the first tick.
6 members
readonly kit: string
readonly game: string
readonly rng: RngSnapshotThe stream's full state at startTick, cursor included.
readonly startTick: number
readonly digest: Digest<T>
readonly checkpointEvery?: numberTicks between checkpoints. Default 600 — ten seconds at 60 Hz.
interface Recorder<T> {
Records checkpoints, and nothing else.
It does not record inputs: @latticekit/input already keeps a per-tick bucketed log keyed by
an integer tick index, and a second recorder here would be a second copy of the same data
with its own ordering bugs. The game hands that log over once, at stop.
Checkpoints are digests, not states. Storing states would make a replay a save-scumming
format and a hundred times larger, and would answer a question nothing asked ("what did it
look like") in place of the one that matters ("did it diverge").
3 members
mark(tick: number, state: T): booleanAdvance to tick, taking a checkpoint if one is due. Returns whether it took one.
A boolean, not a result object: this is called every tick for the whole session, and
digest runs only on the ticks that actually checkpoint.
A no-op returning false after stop(). Ticks that arrive out of order or behind the
next due point are ignored rather than rejected, because a driver that skipped is a
driver problem and losing the recording is not the proportionate answer.
readonly checkpointCount: number
stop<L extends ReplayCompat>(tick: number, state: T, inputs: L): ReplayLog<L>Take a final checkpoint and seal the log around the input log you pass in. Idempotent:
the second call returns the first call's log and ignores its arguments, so a driver that
stops in both a normal path and a teardown path does not record two different endings.
type Refusal = {
readonly kind: 'mismatch';
readonly field: 'kit' | 'game' | 'log-version' | 'stepMs' | 'profile';
readonly recorded: string | number;
readonly current: string | number;
} | {
readonly kind: 'no-checkpoints';
}
Why a replay was not run. A refusal is never a pass, and the field that differed is
named because "incompatible" sends someone reading five things to find out which one.
interface Divergence {
Where two runs first disagreed, and the bracket the bug is inside.
5 members
readonly tick: numberThe checkpoint tick where the digests first disagreed.
readonly lastAgreedTick: numberThe last tick known to agree. The bug is between these two numbers — which is the
entire value of a checkpoint interval, and why the report leads with the bracket.
readonly expected: number
readonly actual: number
readonly checkpointIndex: number
interface ReplayVerdict {
The answer. matched is true only if every recorded checkpoint was checked and agreed.
4 members
readonly matched: booleanTrue only when every checkpoint in the log was reached and agreed. A driver that stopped
early reports false with no divergence, because a verifier that reported green over
checkpoints it never visited is the same failure as one that reported green because it
refused to check.
readonly checkpointsChecked: number
readonly divergence: Divergence | nullThe first divergence only. Every later one is a consequence of this one, and reporting
them is noise that buries the line that matters.
readonly refused: Refusal | nullNon-null means the replay was declined before it started. matched is then false, never
true — a verifier that reported green because it had refused to check is exactly how a
determinism claim rots into a slogan.
interface ReplayVerifier<T> {
Drives digest comparisons tick by tick. One per replay attempt; not reusable.
2 members
mark(tick: number, state: T): booleanCompare at tick if a checkpoint is due there. Returns false once it has diverged or
refused, so a driver can stop immediately rather than run an hour of ticks past the
answer.
finish(): ReplayVerdict
function createRecorder<T>(options: RecorderOptions<T>): Recorder<T>
Start recording checkpoints for a session.
ThrowsTypeError if digest is not a function — without one there is nothing to compare
and the recording would be a log that always matches.
ThrowsRangeError if checkpointEvery is not a positive integer. Zero would checkpoint
every tick and make the log the size of the session.
function createVerifier<T, L extends ReplayCompat>(log: ReplayLog<L>, current: {
readonly kit: string;
readonly game: string;
readonly inputs: ReplayCompat;
readonly digest: Digest<T>;
}): ReplayVerifier<T>
Build the verifier for a log, against this build's identity and input configuration.
The compatibility check is exact equality on five values and runs before the first tick. It
is not a migration and there is no coercion: a near-miss is refused by name, because the
alternative is a divergence report nobody should trust.
Pass current.inputs read off a freshly created input log rather than typed out at the
call site, so the recorded and current triples cannot drift apart in a refactor.