quest'n'click 4.5.0 · r1

Built for quest'n'click 4.5.0, revision r1. Whether it is published, and which one is current, is recorded at publication.json.

State and saves: persistent or temporary, renaming an id, migrations

Français · Type: explanation + how-to · Audience: author · Context: engine repository, created project · Status: ready · Applies from: 4.2.2

What a player's save keeps, what it forgets, and how to change your game without breaking the saves of the people already playing it: renaming an id, migrating a save from one version of your content to the next, and what happens when an installed game updates itself.

What a save holds, and what it does not

A save is the state of the game in progress (GameState, src/engine/core/types/state.ts), wrapped in an envelope that names the game, its saveVersion, the time and the world (SaveEnvelopeV4, src/engine/core/save.ts).

Persistent: in the save.

WhatField
The current room, the hero's position in each room, the rooms visited and how oftenroom, hero, visited
The inventory and the items already usedinventory, used
Every flag, with its value (boolean, number or text)flags
Each prop's state, and actors moved, hidden or shown by a scriptprops, actors (keys <room>.<id>)
The places unlocked on the mapunlocked
The counters of once, nth, cycle and random blockscounters (keyed by the block's id)
What was already seen: topics, once listeners, once choices, applied Reality signalsseen (topic.<id>, event.<id>, choice.<id>, reality.<signal>)
Where each moving character iswhere
Each script's position: its next step, finished or stoppedscripts (the step by its stepIds id)
The camera of the current roomcamera
Several playable characters: who is active, the others' room, positions and inventoryactive, players
Whether the game is finisheddone
The music and where it wasmusic (written and read by the player's app only)
The link to a Reality Bridge: cursor and signals appliedreality

Temporary: never in the save.

So the rule of thumb: what must survive a reload is a flag, an item, a prop's state, a counter or a script position. A variable you keep in a custom command's JavaScript (see Custom code) is lost on reload unless you write it into the state. Which of flags, variables and events to use is explained in Flags, variables and events.

What is guaranteed

Where saves live. The autosave and the manual slots live in IndexedDB (src/engine/dom/save-store.ts). Writes are serialised, and each committed write is read back and compared before the save counts as done (ADR 0005). A browser without IndexedDB (a private window, a locked profile) gets the localStorage store (src/engine/dom/storage.ts), announced once.

Guarantee · save-read-back · referenced · executed on aff6caab7514 · scope self-checked by the author (AI), not reviewed The autosave is written to IndexedDB without an error, and found again, identical, after the database is reopened.

Each write is read back and verified before it counts (whenIdle() rejects when the copy does not match).

Every operation says what it did. save() reports a refused write through the store's failure callback and whenIdle() rejects; clear() and clearSlot() return false when the browser refused the deletion (the save is still there, and the page keeps showing it). The player's app acts on it:

Each case shows ui.saveFailed (English default "Save failed").

Guarantee · save-refused-write-reported · referenced · executed on aff6caab7514 · scope self-checked by the author (AI), not reviewed A write refused by the browser (a full quota) makes whenIdle() reject and is reported; the state is kept in memory.

What is refused, and what is adjusted. When a save is loaded (the autosave at start, a slot, an imported file), parseSave checks it against the game:

Guarantee · save-refused-untouched · referenced · executed on aff6caab7514 · scope self-checked by the author (AI), not reviewed A save of another game, a corrupt shape or a missing current room is refused without changing the game in progress.

Guarantee · save-stale-pruned · referenced · executed on aff6caab7514 · scope self-checked by the author (AI), not reviewed Stale references that resuming does not need are pruned, and the player is warned once.

Across releases of the engine. A save written by any release of the 3.x or 4.x line loads in every later 4.x: the envelope is versioned (schema 4 since 4.1.15, schema 3 still read), and your game's own migrations carry its ids. tests/save-v3.test.ts loads one frozen save of the sample game per release (tests/fixtures/saves/demo-<version>.json, 3.0.0 to 4.4.0 at 4.4.0), migrates it and plays it to the ending. In the engine repository, in a real browser, npm run e2e -- --save checks that a manual save survives a reload, and --save --no-indexeddb the same for the fallback. The storage part of npm run e2e:a11y (a CI gate since 3.3, Chromium and WebKit) upgrades an older save with the real build: the v2 localStorage autosave and slot are copied into IndexedDB and removed only once the copy is verified, a 3.1.0 envelope already in IndexedDB is migrated, the title's Continue resumes it, and a manual save then survives a reload.

A speedrun's .wsrun file is schema 2 since 4.1.16 (the run's world sealed into its proof); a schema 1 file of 4.1.14 or 4.1.15 is still verified, as a Story run. Its format is on File formats.

Limit · save-own-content-only The engine guarantees its own envelope across releases. Whether your saves survive a change of your content depends on your migrations: the engine cannot guess that you renamed a flag.

Renaming an id without breaking saves

Since schema 3 (schemaVersion: 3 in game.ts), everything whose position survives in a save has an explicit, unique id: every Rule, TalkTopic, Choice, EventRule, and every once, nth, cycle or random block; a ScriptDef adds one stepIds entry per top-level command. The validator refuses a v3 game with missing or duplicate ids. So reordering content or translating a text no longer changes what a save means; renaming an id does. The contract itself is on Content principles.

schemaVersion: 3,
// …
{ id: 'open.pantry', verb: 'open', a: 'pantry', do: […] }
{ id: 'ask.key', topic: 'Where is the key?', do: […] }
{ once: […], id: 'arrival.first' }
{ id: 'clock', stepIds: ['wait', 'chime'], do: [{ wait: 1000 }, { sfx: 'chime' }] }

The ids tool with --write --map writes those ids into an existing game, and writes the migration step its saves need (ids.migration.json) and the map of translation paths (ids.paths.json) beside the game's sources. The full move to schema 3 is in Upgrade notes, v2 to v3.

What a rename touches in a save. A flag, an item, a room, a prop, an actor, a map place, a script, a script step, a playable character, a moving character, a counter's block id, or the id of something remembered in seen (a topic, a once listener, a once choice). When in doubt, look for the id among the keys of a save's state (the table at the top of this page). A hotspot's id never appears there; renaming it can still change translation paths (below).

The procedure. The game's sources are in game/ in a created project, in games/<id>/ in the engine repository; the tools run as npx quest-n-click <tool> in a created project, as GAME=<id> npm run <tool> -- in the engine repository.

  1. Rename the id everywhere in the content: its definition and every reference (conditions, commands, goals, objectives). The validator lists every reference you missed as an unknown id.
  1. Bump saveVersion in game.ts, by one.
  1. Add one migration step from the old version, with the rename:

    saveVersion: 4,
    migrations: [
      // …the earlier steps, unchanged
      { from: 3, renameFlag: { found: 'key_found' } },
    ],
    

    The renamed target must exist in the content: the validator refuses renamed item does not exist: "<id>" (likewise for rooms, map places, scripts, players, characters, props and actors).

  1. Follow the texts. Translation paths contain ids (room:house/props.pantry.name). After a rename, run the i18n tool's extract --lang <xx> for each language: what no longer matches is parked under _stale:<path>; move each translation from its old path to its new one by hand (see Translations). Voice clips keyed by a line id (audio.voices, voicesByLang) are renamed in the same way (Voices).
  1. Check that the content validates and that every translation is still in place:

    # in the created project
    npx quest-n-click validate
    npx quest-n-click i18n status
    
    # in the engine repository
    GAME=<id> npm run validate
    GAME=<id> npm run i18n -- status
    

Save slots and migrations

Manual slots are opt-in:

saves: { slots: 3 },   // the pause menu gets Save / Load with three slots, plus export and import as a JSON file
ui: { …, save: 'Save', load: 'Load', slot: 'Slot {n}', emptySlot: 'empty', exportSave: 'Export to a file', importSave: 'Import a file', confirmOverwrite: 'Overwrite this slot?' },

Absent or 0: autosave only. Manual slots are stored like the autosave: in IndexedDB, as envelopes, read back after each write. A file imported from the menu also fills the first free slot. The autosave is a validated envelope, checked before the current game changes; the first run of a v3 build imports the old <game>.save localStorage autosave once (and the <game>.slot.<n> slots), removing them only after the verified copy. What the player sees of slots, export and import is on Saves, for players.

Migrations. When the content changes incompatibly, bump saveVersion and carry the state over as data, one step per version:

saveVersion: 3,
migrations: [
  { from: 1, renameFlag: { found: 'key_found' }, renameItem: { cle: 'key' } },
  { from: 2, renameRoom: { lobby: 'hall' }, renameProp: { 'hall.lamp': 'hall.lantern' }, dropFlag: ['tmp'] },
],

A step goes from from to from + 1; keys are old ids, values new ones. The complete Migration type (src/engine/core/types/game.ts):

FieldWhat it carries
renameFlag, dropFlagFlags.
renameItem, dropItemItems, in the active player's inventory and in used.
renameRoomThe current room, the hero's positions, visited, the room part of prop and actor keys, where.
renameProp, renameActorroom.prop → room.prop, room.actor → room.actor.
renamePlaceUnlocked map places.
renameCounter, dropCounterThe counters of once / nth / cycle / random blocks.
renameSeen, dropSeenSeen topics, listeners and choices (topic.<id>, event.<id>, choice.<id>).
renameScript, dropScriptScript positions.
renameScriptStepScript id → old step id → new step id.
renamePlayerThe active player and the other players' records.
renameCharacterMoving characters in where.

renameItem and dropItem do not reach the inventories of the other playable characters (players): an item id renamed there is no longer known, and loading prunes it with the warning below.

The chain is applied in order on a copy, until the save reaches saveVersion (src/engine/core/migrate.ts). The validator refuses two steps from the same version, a from that is not below saveVersion, and a renamed target that does not exist; it warns when a version has no step (no migration from version <n>: those saves start a new game).

Guarantee · migration-newer-save-refused · referenced · executed on aff6caab7514 · scope self-checked by the author (AI), not reviewed An older save is migrated by the game's steps; a save newer than the game is refused, never read in part.

A save with no path to saveVersion is not loaded: the player starts a new game. Map positional counter and topic keys of old content only when you know the old content and its meaning: after content has been reordered or translated, there is no safe automatic conversion. An unmapped stale optional reference is pruned with the visible warning above.

Saves across updates of an installed game

A published game is a PWA: once visited, it plays offline from the service worker's cache. When you publish a new version, an installed game finds it on its next visit online, and the player's save meets your new content.

  1. The update waits for a verified save. The page shows ui.updateAvailable ("A new version is ready.") with a button ui.updateNow ("Save and update"). On a tap, a game in progress is saved first, and the new version is activated only after the store confirms the write (offerUpdate in src/engine/dom/update.ts). If the write is refused, the update does not happen, ui.saveFailed is shown, and the button can be tapped again. On the title screen there is no live state: the autosave already on disk is the progress.
  2. The new version reads the old save with your migrations, as above: migrated, adjusted with a warning, or refused (and then a new game).
  3. Until the player taps, the open page keeps the version it loaded, offline included. The new service worker waits (registerType: 'prompt', skipWaiting: false in vite.config.ts); a browser switches to it by itself only once every tab of the game is closed, and the autosave written after each action is then the progress.

Limit · update-needs-migrations The update protects the write, not the meaning: if your new content renamed an id without a migration, the old save loses that reference (pruned with a warning) or, for a current room that no longer exists, is refused.

On a hosted Studio, the previews of every revision share one origin, so a save made in one revision can be resumed in another. A preview build asks first when the content differs (Resume here, Restart, Stay in revision N); the player's builds never ask. That is ADR 0026; see Preview, apply, undo.

Why it works this way

References


Edit the source of this page · Report an error