Saves sync
A game's saves live on the device first. When the game has a backend, each save also syncs to a PlayerSaves Durable Object that belongs to that player alone, and every change shows up live on the player's other copies of the game (a second phone, a browser). The game code does not change: defineSave and defineProgression sync by themselves once GAME_BACKEND_URL is set. This page is the backend side; the save API (defineSave, defineProgression, useSave, useSettings) is on Saves.
Turn it on
Deploy the backend scaffold, which includes PlayerSaves, and set GAME_BACKEND_URL. Every save syncs unless its spec says sync: false (device only). sync can also be another backend URL.
import { defineSave } from '@/engine';
export const save = defineSave({
version: 1,
defaults: { level: 1, coins: 0 },
});
Player settings (useSettings: sound, music, haptics, controls) are per device and never sync.
One object per player
PlayerSaves (backend/src/saves.ts) is named from a SHA-256 hash of the player's secret id (saveOwner in util.ts), so the secret never names anything stored. Each object keeps the player's slots in its own SQLite table: for each slot its id, revision, the game's save version, when it changed, its epoch, the device that wrote it and the data as JSON.
| Route | Body or reply |
|---|---|
GET /saves (header x-player-id) | { slots: [{ slot, rev, version, at, epoch, device }], time } |
GET /saves/:slot | { slot, time }, or 404 when the slot was never written |
PUT /saves/:slot | Body { base, version, at, epoch, device, data }. Reply { ok: true, rev, time }, or 409 { ok: false, conflict: true, remote, time } |
GET /saves/live?player= (WebSocket) | { t: "hello", time, slots } on connect, then { t: "save", slot } for every write |
Limits, from save-protocol.ts and saves.ts:
| Limit | Value |
|---|---|
| Slot data | 64 KB of JSON |
| Slots per player | 8 |
| Slot ids | 1 to 32 lowercase letters, digits, - or _ |
| Slots per player, counted | every save's slots together (a progression's main is a slot of its own) |
| Writes | a burst of 10, then 2 a second per player |
| Open live sockets per player | 8; the oldest closes past that |
Which id a save's slot is stored under
:slot is not the game's slot id as is: it is saveSlotId(key, slot) from save-protocol.ts. The game's default save (key: 'save') keeps the slot id (main, 2); any other save is stored as <key>__<slot> (progress__main for a defineProgression; a key with dots, colons or capitals gets a short hash, such as the puzzle module's puzzle-save-…__main). Each id is a valid slot id, so every backend stores it as is. An authoritative RPC that changes a progression on the server reads and writes progress__main.
Engines before 2026-11-11 stored every save under the bare slot id, so a game with two saves had them overwriting each other in one row. What happens to that data now, with no change on the backend (old and new PlayerSaves behave the same):
- A save with its own key (a progression, the puzzle or kingdom module's save) reads the row at the bare id once per device. When the data looks like its own (its fields, compared with every other save the game defines), it merges it in and writes it to its own id. A device that had synced it before sends its copy to the new id on its next sync and merges with whatever another updated device put there first.
- The default save keeps reading and writing the bare id, as before. When the row there was last written by another save, it ignores that copy and writes its own over it.
- The old row is never deleted; it keeps counting toward the 8 slots.
- Devices still running an older engine keep writing the bare id until their game updates; then their copies are merged in as above.
How a sync goes
The device's copy is always the one the game reads and writes. The syncer in src/engine/save/sync.ts keeps it in step:
- On first use of a slot, it pulls what the server holds.
- After a change, it waits 1.5 seconds (changes in between go together) and sends a
PUTwithbase: the revision this copy last synced. - The server's rule (
acceptPut, shared by both sides): a write must start from the revision the server holds. If it does, the revision goes up by one, the write is stored and pushed at once to the player's other open copies. - A stale
baseis a conflict. The server answers 409 with its copy. The device merges and writes again:- With a
mergefunction in the spec, the two copies are combined by it.defineProgressionmerges field by field. - Without one, the copy changed last (
at) wins, and the other is kept on the device under<key>.<slot>.backup. Nothing is dropped silently.
- With a
- Starting over bumps the slot's
epoch. A higher epoch replaces a lower one instead of merging, so a reset on one device is not undone by an old copy on another. - The live socket says
hellowith the server's slot headers when it connects, so a copy that was away fetches what it missed; after that eachsavemessage brings another copy's write.
Offline, changes stay on the device and are marked unsent; the syncer tries again 20 seconds later, and when the socket reconnects. useSyncStatus(save) returns local (no backend), idle, syncing, offline or error, for a small indicator if the game wants one.
The migration rules still apply to synced data: a slot written by an older version of the game runs through the spec's migrations when this copy reads it.
The server's clock
Every save response carries the server's time. The engine records the offset, and from then on gameNow() returns the server's time, so daily rewards and offline earnings cannot be farmed by changing the device's date. Without a backend, gameNow() is the device's clock.
When progress must be fair
By default the device's copy is trusted: right for single-player progress. Rewards with real value, trades between players and competitive economies belong on the server. backend/CLAUDE.md describes two ways: validate writes in PlayerSaves.write, or add an RPC method on the class (claimDaily, buy) that changes the stored slot on the server and pushes it like any write.
A backend made before saves sync
A backend/ copied before PlayerSaves existed has no src/saves.ts. The realtime-backend skill lists the steps: copy saves.ts and save-protocol.ts from the scaffold, copy saveOwner into util.ts, add the export and app.route("/saves", saves) in index.ts, add the SAVES binding and the next migration tag ({ "tag": "v2", "new_sqlite_classes": ["PlayerSaves"] }, or the next free tag) in wrangler.jsonc, then test and deploy.
Testing
backend/src/save-protocol.tsandsrc/engine/net/save-protocol.tsmust be identical:cmp backend/src/save-protocol.ts src/engine/net/save-protocol.ts.- The scaffold's smoke test covers listing, writes, a 409 on a stale revision, live delivery to a second copy, the size limit and that another player cannot read the slot.
- On the game side,
src/engine/save/testing.tshasmemoryStore()andmemorySaveServer()(the backend's rules in memory) for unit tests of migrations and merges without a network. See Saves.