Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Stores and saves

useWallet, useScenes, useInput, useTime and useLoading read stores the engine owns. usePlayer queries the world for the player’s character instead, and useRound queries it for the round. usePlayer also says who plays: the name the player shows in the player app and an id that is theirs in this game alone, which the app hands the page when the game runs in its frame, as the trust boundary says. In a game with rooms the app hands them with the seat, once the room opens, so both read undefined while the page loads during a room’s start. Both are undefined outside the app, as on the game’s dev server, so a game falls back to its own, such as a name the player types. A game’s own state comes from the engine’s createStore, and a game reads it the same way; wrapped in the engine’s persist, it is kept in the player’s save, as Save a game’s own store says.

The stores’ shapes are WalletState, InputState, TimeState and LoadingState; useRound returns a RoundStatus.

Game builds the quality store, and keeps it in the browser’s storage under its name: sled-quality for Game’s sample, so two games on one origin keep separate settings. Anything under Game reads it with useQualityStore(), which returns the store for useStore from zustand. A camera that remembers its framing keeps it beside it, as the camera page says. The player’s progress is the save, which Saving progress describes. The quality store holds what the player set in the Graphics rows and the level Automatic picked for the device; useQualitySettings() reads the values the engine draws with, and Game’s quality prop changes what each level means and the level each kind of device starts at, as Graphics quality describes. A scene mounted headless reads stores of its own that start empty and write to no storage. A game that renames itself loses its players’ settings on each device, so a game keeps its name.

An address with ?quality=<level>, one of auto, minimum, low, medium or high, opens the game at that level for the visit. The quality store keeps the saved level in storage, and saves a level the player picks after the game opens. The boot checks open every game at ?quality=minimum, so the software GPU they run on never decides a level’s values. A page opened with ?profile also takes ?qualityLevels=, level values over the game’s own for the visit, as Frame shows.

A save is plain data, as it is stored. The character’s position reads back as three numbers, { x, y, z }, the StoredVector type from @spawnite/schema, not a three.js Vector3, so code that reads a save and draws nothing loads no three.js. A game that needs a vector builds one where it reads the save, new Vector3(x, y, z); the engine’s spawnCharacter does so itself. Unity, Unreal and Godot store a vector as three numbers the same way.

A game declares its save once, with defineSave in src/save.ts, and passes it to <Game save={save}>. The declaration names the engine saves the game wants in include, and holds one schema, read and restore for the game’s own state:

src/save.ts
import { defineSave } from "@spawnite/engine";
import { z } from "@spawnite/schema";
import { GoldTrait } from "./traits";
export const save = defineSave({
include: ["entity", "inventory", "random"],
version: 2,
schema: z.object({ gold: z.int(), quests: z.array(z.string()) }),
read: ({ player }) => ({
gold: player.get(GoldTrait)?.amount ?? 0,
quests: [],
}),
restore: ({ player }, saved) =>
player.set(GoldTrait, { amount: saved.gold }),
migrations: { 2: (v1) => ({ ...v1, quests: [] }) },
});

defineSave takes the following fields. The first six describe the game’s own state, under the record’s game key. schema, read, restore and version come together, or not at all:

  • version: a whole number from 1 that counts the shape of the state. Any change to the shape raises it, an added optional field included, and adds a step.
  • schema: required with the game’s own state. It types what restore receives, and it checks every state that loads.
  • read: returns the state to keep, as plain JSON. It is synchronous and changes nothing. At every read, in development and when published, the engine checks that what it returns passes the schema and survives JSON. A read that throws, returns undefined, or holds a Set, a symbol, an undefined field, a number that is not finite or a value inside itself is refused, and so is the whole record that read: the last save stays, and play goes on.
  • restore: puts a loaded state back: a player-scope state onto her player after every onPlayerJoin and before any onPlayerReady, an entity-scope state onto a saved entity as setSavedEntity registers it, after its spawn set it up and after the engine saves are restored. A restore that throws refuses the load, as a record that fails to load does, so nothing is written over the save, and refuses a join it runs in.
  • migrations: one step per version. Step 2 turns a version 1 state into version 2, so a version 1 state that a version 4 game loads runs steps 2, 3 and 4. A step is a pure function from old JSON to new JSON. A step that throws or returns undefined refuses the load.
  • scope: "player", the default, where read and restore receive { world, player, account }; "entity", kept per saved entity under entities.<key>, where they receive { world, player, account, entity, key }; or "world" for a page game with no player state, such as a puzzle with a board, where they receive { world, account }. world is the koota World, player her player entity, entity the saved entity, which restore receives as a PredictedEntity, so it may write its predicted traits as it appears, key its saved-entity key, and account is { id }, the platform’s id for the player in this game.
  • include: the engine saves the game wants, listed below.

A game sets no interval: the platform writes the save on its own schedule, as When the save is written and Saves in a room say.

A game that keeps no state of its own, only the engine saves it names and its kits’ saves, gives none of the first six fields, and its record has no game key:

export const save = defineSave({ include: ["levels", "random"] });

Giving some of schema, read, restore and version without the rest fails to typecheck, and defineSave throws naming the missing fields. defineSave also throws for a version that is no whole number from 1, a step missing between 2 and the version, a name in include that the engine has no save for, resources without stats or in another scope than stats, and a world’s save moved to another scope.

The engine owns the saved form of its own traits, so a game never stores the engine’s shapes itself, and an engine release that changes one converts the saves already stored. A game names each engine save it wants in include:

Name What it keeps
entity Each saved entity’s scene, position, facing and health. A position saved in another scene is dropped, and its body moves with it
stats Each saved entity’s stat bases and lasting modifiers. Timed modifiers are left out
inventory Each saved entity’s bag and what it wears
resources What each saved entity has of each resource, such as mana. Needs stats, which sets each maximum
wallet The coins in her player’s wallet. A restored wallet adds nothing to a round’s score
levels The levels finished. A page game’s alone
loot The loot already taken. A page game’s alone
random The world’s random stream, so a continued game draws what it would have drawn. A page game’s alone

The camera is not saved: its framing is a view setting, not progress. A game with rooms may not declare a world scope, levels, loot and random, since a room’s world is shared and it seeds its own random stream. Game raises each one when it mounts in development, and spawnite build refuses them.

A room mounts a scene and not <Game>, so a game with rooms exports its save from the room’s scene file too, beside plugins: export { save } from "../save". spawnite build fails where src/save.ts declares a save and the room’s scene file does not export it, and where any module of either half calls defineSave while the room’s scene exports no save, so a room never saves nothing by mistake.

A saved state that is empty is saved as empty: an emptied bag writes an empty bag, never the bag the save held before.

The save is one JSON record. The game, each kit and each engine save keep their own key, each with its own version, so one owner’s change never touches another’s:

{
"game": { "version": 2, "state": { "gold": 140, "quests": ["intro"] } },
"stores": {
"career": { "version": 1, "state": { "kills": 9, "runs": 2 } }
},
"kits": { "shop": { "version": 3, "state": { "stock": [] } } },
"engine": {
"inventory": {
"version": 1,
"state": { "slots": [], "equipment": {} }
},
"random": { "version": 1, "state": [1, 2, 3, 4] }
}
}

Every owner’s key loads the same way, the engine’s, a kit’s and the game’s own alike, in three steps:

  1. The version is compared. A stored version above the owner’s refuses the load.
  2. The steps run. A stored version below the owner’s goes through each numbered step in order, up to the current one.
  3. The schema reads the result, and it is the only judge. A field the schema does not name is dropped. A missing field that has a default gets it. A missing required field or a value of the wrong type fails the schema. A strict schema, such as z.strictObject, refuses a field it does not name.

A key that fails any step, or a step that throws or is missing, refuses the whole record: the engine restores nothing and writes nothing over it, and the result names the owner, the key and the reason. A key no owner in this version claims, such as one a removed kit wrote, is kept as it is and written back.

A kit’s key is its plugin’s name, kits.<name>. Two kits of one name share a key, so a kit that takes the name of one the game removed reads that kit’s saved data, and refuses the load where the data does not fit its schema or is at a higher version. A kit’s factory takes a save: false option and passes save: false to definePlugin: the kit then saves nothing, and its stored key is kept untouched.

Every change to a saved shape raises its owner’s version and adds a step, an added optional field included. The engine holds its own saves to that in its tests, which also allow only added fields inside one compatibility line of engine releases: the major from 1.0.0 on, and the major and minor below it. spawnite build writes, for each owner, its version and a fingerprint of its schema into the bundle’s manifest, under save:

{
"game": {
"version": 2,
"fingerprint": { "format": 1, "hash": "9f3a0c2be1d47f60" }
},
"engine.inventory": { "version": 1, "fingerprint": null }
}

The fingerprint hashes the schema as JSON Schema, so the order of its fields, its descriptions, its titles and its examples do not change it. A codec’s tagged value counts by the codec’s name. A schema JSON Schema cannot say whole has a fingerprint of null: one with a refinement, a transform, a pipe, a check that rewrites the value, or a default or fallback that a function computes. compareSaveManifestEntries(published, next) from @spawnite/schema takes the save entry of every version the game published, and says how each owner changed since the highest version any of them gave it, since a player may hold a save from any of them: same, changed-without-version, version-lowered, version-raised, added, or unchecked where a fingerprint is missing or in another format, or a published entry cannot be read.

include also takes { name, scope } for an engine save kept on the other side: { name: "inventory", scope: "player" } keeps one bag on her player across every saved entity, and { name: "wallet", scope: "entity" } gives each saved entity its own coins, which a pickup and an item then pay into.

The owners restore in a fixed order: the engine saves in the order of the table above, then each kit’s in the order of the list, then the game’s own. The world’s saves restore as the record loads, the player’s onto her player as she joins, and each saved entity’s as setSavedEntity registers it. A saved entity is captured into the record as it goes, by releaseSavedEntity, a destroy or her leave, and her player as she leaves, so a character spawned at the next scene, or after a respawn by destroy and spawn, restores what the last one had under its key. The player page says how a game registers its own.

Game loads the record before anything inside it mounts, and writes it while the game plays, as Where the save goes says. createSaveSession(world, { save, account, onRefuse }) holds one player’s record while a game plays:

  • load(record) loads one, or null for a new game, and returns what it found. A record that fails, a saved entity’s record among them, or a restore that throws, refuses the load, and onRefuse receives the refusal, at the load or at a later restore.
  • attachPlayer(player) restores the player-scope saves onto her player entity and makes the session the one her saved entities restore from; the core’s join calls it.
  • read() returns { ok: true, record } with the record as it stands, or { ok: false, problem } where one owner’s read failed, with the owner, its key, the reason and a message: nothing is written for that read, the status reads unsaved with the problem, and the next read tries again. The record holds what each read returned, not a copy, so turn it into JSON before the game changes again. It returns null before a load and after a refused one, since nothing may be written then. versionsOf(record) names each owner’s version in a record by its key, such as engine.inventory, and isOlderReader says whether a reader is older than a record for any owner.

A kit or a game that keeps an engine value inside its own key keeps it through the engine’s codec, which tags it with its form and converts an older form wherever it sits: a shop keeps itemStack.toSave(stack) and reads itemStack.fromSave(saved). defineSaveCodec makes the same for a value of a kit’s own.

To keep a store’s state in the player’s save, wrap its creator in the engine’s persist. It takes zustand’s persist options, so only the import differs from the zustand middleware you know:

src/store/progress.ts
import { createStore, persist } from "@spawnite/engine";
interface Progress {
kills: number;
addKill: () => void;
}
export const useProgress = createStore<Progress>()(
persist(
(set) => ({
kills: 0,
addKill: () => set((state) => ({ kills: state.kills + 1 })),
}),
{
name: "progress",
version: 1,
partialize: ({ kills }) => ({ kills }),
},
),
);

The store keeps its entry under stores in the record, by its name, with its version. It differs from zustand’s own in three ways:

  • It loads when the Game’s save has loaded, never when it is created. Game mounts nothing inside it until the record and every persisted store have loaded, so no component reads a default in place of a saved value.
  • A change writes nothing by itself. Each record the engine writes reads the store as partialize keeps it, as When the save is written says.
  • An entry the store cannot read refuses the whole save, as a game’s own key does: one a newer version wrote, an older one with no migrate, and one whose migrate or merge throws. Every store is checked before anything restores, so the game stops on the load-failure screen with no store loaded, and nothing is written over the save. zustand’s own throws that state away and plays on. A store with no entry in the save starts on its defaults, and its merge is not called.

A store that a lazily loaded module makes after the save has loaded reads its entry as it is made. An entry it cannot read refuses the save as a load does: the game stops on the load-failure screen, the app is told, the entry stays as it is, and the page writes nothing more that session.

useProgress.persist keeps zustand’s API: getOptions, rehydrate and hasHydrated read as they do there. Two persisted stores with one name throw, except under hot reload, where the store the edited module makes replaces the old one and keeps its live state. Stores live once per page, so the first Game on the page loads and saves them; a second Game, as on a Storybook page, saves nothing. A test that renders its Game calls resetPersistedStores() and awaits resetPageSave() from @spawnite/engine/testing after each case: the first puts every store back to its defaults, unloaded, and the second waits for the write the last Game’s unmount sent to reach the host, then ends the page’s save, so the next Game loads its host’s record. A test that unmounts its Game and then reads what was saved awaits resetPageSave() first.

A persisted store is for a game with no rooms. In a game whose package.json gives it a room, a spawnite.room, the room writes every player’s save, so a persisted store is an error: when the game mounts in development and in spawnite simulate, each naming the store, and at spawnite build, naming each module that takes persist from @spawnite/engine. The build reads the modules the bundler parses in both halves, so a re-export through a file of the game’s own, a file outside src and a lazily imported module all count. A module that takes @spawnite/engine or @spawnite/engine/core whole, as a namespace, an export * or a dynamic import, fails the build too, because the build cannot tell whether it uses persist or defineSave: import each name by name. spawnite build --server checks the server half the same way. Save that value from the server half with a plugin’s save instead. The game lint refuses persist from zustand/middleware, which writes to one browser and bypasses the save. A setting that belongs to the device, as the engine’s quality and volume do, keeps the browser’s storage.

A game never writes its save. It changes its state, and the platform writes the newest state on its own schedule, the same for every game. The engine reads the record every 5 seconds, or every 30 seconds while its JSON is over 256 KiB, and each time it changed, it numbers it, gzips it and hands it to the page’s host for the journal on the device, which a reload on the same device picks up. It sends the record to the cloud at the following moments, each only when the record changed since the last one sent:

  • Every 2 minutes.
  • When the tab is hidden, but no sooner than 30 seconds after the last write.
  • When the Game unmounts, and when another tab or device asks for the save.

save() marks the record as changed at a moment that matters, such as a level’s end: the status reads unsaved from the call, and the next write carries the newest state. It never forces a write, so a game can call it as often as it likes. A new game reads unsaved until its first record is stored. Nothing is written until the save has loaded. A read of the record that throws, as a game’s read can, writes nothing: the status reads unsaved, the development console names the error, and the next read tries again.

A record must fit two limits: 16 MiB as JSON and 4 MiB gzipped. One that passes either while she plays goes to the cloud at once, and nothing newer is read after it: the status reads refused, the development console names the limit and the measured size, and once the host stores it the game stops with the load-failure screen. A write of it that fails is sent again at the next window, a hidden page’s flush, or a hand-over’s ask. As it opens, the page reads the record once as a trial, after the migration steps and the restore of a world-scope save, measures that record as JSON and gzipped, and refuses a record past either limit the same way. The body it loaded is not measured, so a version whose migration step trims the record opens it. A record past twice either limit, the platform’s ceiling, is never stored: the status reads refused, the last save that fit stays, and play goes on. Where the cloud lacks the last record that fit, as after its write failed, the engine sends that record again. A body that unpacks past 32 MiB, the platform’s ceiling, is not read. The console warns once a record passes three quarters of either limit, with both sizes.

useSaveStatus() answers the state of the save and where it goes, so a game can draw its own indicator; the engine draws none. It returns the following fields:

  • status: saved, unsaved, saving or refused, as the table below says.
  • destination: platform, browser or nowhere, as Where the save goes says.
  • size: the newest record’s unpackedBytes as JSON and compressedBytes gzipped; null before the first.
  • problem: a size limit the newest record is past, with the limit, the bytes, the maxBytes and ceiling, true where it is past the platform’s ceiling and so never stored, while status reads refused; or an owner’s read that failed, with its key, owner and reason, while status reads unsaved. null while neither holds.
Status Means
saved The platform, or the browser on the dev server, holds the newest state
unsaved A change waits for its write, the last write or read failed, or nothing was ever stored
saving A write is on its way
refused The newest record is past a size limit: stored once and the game stops, or past the ceiling and never stored

The page’s save goes to one of three places, which destination names:

  • platform: in the player app’s frame, for a game with no rooms. The app hands the page the newest body in its init, and the page hands each record back over the same channel, as the trust boundary lists. The page holds no credential and reaches no network for its save. Once the app has answered the page’s hello, the page waits for its init, however long it takes.
  • browser: on the dev server, and on any page with no app around it, a frame that leaves the page’s hello unanswered for 2 seconds included. Every record is kept in the browser’s storage under <name>-record, so a reload restores the newest, and a record sent to the cloud reads saved once written.
  • nowhere: in a game with rooms, where the room writes each player’s save; on a spawnite play profile run, which starts clean and seeds the world’s random stream with a fixed seed so runs compare; and in a second Game on one page, as on a Storybook page.

The page’s host is read once a page. A Game that mounts again, or another Game of the same name, loads the record the last one left, and the page’s records keep one rising number. Game opens its save once for its world, so a save object made anew on each render, as one written inline, changes nothing.

A record that will not load stops the game: a key that fails, a restore that throws, or a body that cannot be read. Game restores nothing and mounts nothing, writes nothing over the save, tells the app, and shows “We couldn’t load your progress” with the reason, in development as a player sees it.

A room mounts a scene and not <Game>, so a game with rooms exports the same declaration from each scene’s file, beside plugins: export { save } from "../save";. The room is the only writer of its players’ saves, and the page holds none.

The room saves everyone in it together, by a checkpoint: it reads every player’s record at one tick, uploads each record that changed, and commits them all at once. Either every player’s save moves to that tick, or none does. So whatever the game moved between two players, such as an item one hands the other, is in both saves or in neither, whatever fails. The room takes a checkpoint at the following moments:

  • Every 60 seconds while any record changed, and every 2 minutes while the room holds one player.
  • Within 5 seconds of requestSave(world), which a game’s server half calls at a moment that matters, such as a run’s end. The call names no player, since the checkpoint saves everyone. Calling it often costs nothing: the room starts 12 checkpoints a minute at most.
  • When a player leaves the room, as her page closes or another tab or room asks for her seat, or as the last player leaves. Her leave is read in the tick she leaves, after each plugin’s onPlayerLeave, so what a hook hands on as she goes is in the other player’s save and not hers, and the checkpoint that holds it frees her seat.
  • As the room stops, for every player still in it.
requestSave(world);

A player’s join enters whole or not at all. The room loads her save before her player is made, restores it onto her player after each plugin’s onPlayerJoin, so each onPlayerReady reads what she saved, and onto each saved entity a ready hook registers, her character among them as the characters plugin spawns her, and once every onPlayerReady has run, reads her record once more as a trial and measures it as JSON and gzipped, as this version reads it after its migration steps, so a version whose step trims it lets her in. A save the room cannot load, a restore that throws, onto her player or onto a saved entity, a trial read that fails, or a record past the size limit refuses her join, takes out what her join and ready hooks spawned, takes back their events, and writes nothing over her save: the room closes her socket with saveNotLoadedCloseCode, or with saveNewerCloseCode where a newer version of the game wrote the save. Each reason reads with readSaveRefusalReason, and the loading screen says her progress did not load. The room’s other players are not touched.

Where a seated player’s save stands is on her player entity, as readTrait(player, PlayerSaveTrait)?.status: saved, unsaved, saving or refused.

A development room keeps each save as a JSON file in .spawnite/saves in the game’s folder, by the name the page sends, and commits it through the same checkpoints, so a restart or a rejoin restores progress. A room the cli starts for spawnite play, a playtest or a profile keeps none unless --room-env ROOM_SAVES=1 asks, as a check of a save across a rejoin does, and spawnite simulate runs no room, so each starts every player afresh.

Three things stop a save, in a page and in a room alike. Each is the game’s to avoid:

  • A record past the size limit. A record must fit 16 MiB as JSON and 4 MiB gzipped. In a page, a record over either is stored once, then the game stops with the load-failure screen, and each later open measures the save again and refuses it while it is still past the limit; a record past twice the limit is never stored, and the last save that fit stays. In a room, the checkpoint still saves it with everyone’s, up to twice the limit, and then removes her from the room; she takes no seat in that game for 10 minutes, and after that her join is measured again and refused while her save is still past the limit. Keep the record small: save what outlives a session, such as a level or a count, never a log that grows each time she plays. The development console warns once a record passes three quarters of either limit.
  • Save code that throws. A read, a restore or a migration step that throws. In a page, a read that throws writes nothing and the next read tries again. In a room, a read that throws at a checkpoint is tried once more on the next tick, and a second failure ends the room: every player in it goes back to the last checkpoint, and the player whose record failed takes no seat in that game for 10 minutes. At a join, a restore or a trial read that throws refuses her join. Keep read a plain copy of state into JSON, and test a restore with spawnite save check.
  • A value that is not JSON. A read that returns undefined, or holds a Set, a symbol, an undefined field, a number that is not finite such as NaN, or a value inside itself, is refused as a read that throws is. So is a value the schema refuses. Copy a Set into an array, and keep numbers finite before they reach the trait the save reads.

spawnite save check plays a scene, reads its save, restores it into a fresh world and compares the two, so it catches a read or a restore that fails before a player does.