Saving progress
Your game declares what it keeps of a player’s progress, and the engine and the platform write it. A signed-in player who reloads, or opens the game on another device, picks up from the last save the platform wrote, which What a save does not promise bounds. Here the engine and the platform own the schedule and the one-writer rule, and your game declares only what it keeps.
Declare what the game saves
Section titled “Declare what the game saves”Your game declares its save once, in src/save.ts, and passes it to <Game save={save}>. Nothing is saved that the game did not declare.
import { defineSave } from "@spawnite/engine";import { z } from "@spawnite/schema";import { GoldTrait, QuestsTrait } from "./traits";
export const save = defineSave({ include: ["entity", "inventory", "random"], version: 2, schema: z.object({ gold: z.int(), quests: z.array(z.string()), }), // A player no system has given the traits yet reads as a new one. read: ({ player }) => ({ gold: player.get(GoldTrait)?.amount ?? 0, quests: [...(player.get(QuestsTrait)?.done ?? [])], }), restore: ({ player }, saved) => { const gold = { amount: saved.gold }; const quests = { done: new Set(saved.quests) }; if (player.has(GoldTrait)) player.set(GoldTrait, gold); else player.add(GoldTrait(gold)); if (player.has(QuestsTrait)) player.set(QuestsTrait, quests); else player.add(QuestsTrait(quests)); }, migrations: { 2: (v1) => ({ ...v1, quests: [] }), },});import { trait } from "koota";
export const GoldTrait = trait({ amount: 0 });export const QuestsTrait = trait(() => ({ done: new Set<string>() }));defineSave takes the following fields. schema, read, restore and version come together: a game that keeps nothing of its own beside the engine’s saves leaves all four out, as defineSave({ include: ["entity", "inventory"] }).
schema: it types whatrestorereceives, and it reads every record that loads.read: returns what to keep, as plain JSON. It runs when the engine is about to save, and as what it reads goes: her player as she leaves, a saved entity as it is destroyed. It is synchronous and changes nothing. Every read is checked against the schema and against JSON, in development and when published, so aSetor anundefinedfield is refused with the field named: nothing is written for that read, the status readsunsaved, and the last save stays. Soreadreturns a value the schema accepts for every player, one with no state yet included, such as a player read before a system gave her the trait: fall back to the starting value.restore: puts a loaded record back. A player-scope save restores onto her player entity after every plugin’sonPlayerJoinand before anyonPlayerReady, so a saved value wins over a join default and a ready hook reads it; an entity-scope save restores onto a saved entity as it is registered, after its spawn set it up and after the engine’s own saves are restored. An entity-scoperestore’sentityis aPredictedEntity, so it may set a trait her page predicts, as it appears;readtakes a plain entity and reads such a trait withreadTrait.version: counts the shape of the record, from 1.migrations: one step per version. Step 2 turns a version 1 record into version 2. A step is a pure function from old JSON to new JSON: it reads nothing but its argument.include: the engine’s own saves the game wants, by name, listed in the next section.scope: where the state is read from and restored onto."player", when you leave it out, is her player entity: what outlives any character, such as coins, a score or a choice of class."entity"is each saved entity, kept under its saved-entity key: what a character uses, such as her bag or her health, as the next sections say."world"is the world alone, for a page game with no player state, such as a puzzle with a board; a game with rooms may not use it.
world is the game’s koota world, player is her player entity, which the core makes as she joins, and account is { id }, the platform’s id for this player in this game. An entity-scope save’s functions also receive entity, the saved entity, and key, its saved-entity key, and a world-scope save’s receive world and account alone. None of them is saved: only what read returns is.
Name the engine’s saves you want
Section titled “Name the engine’s saves you want”The engine keeps the saved form of its own traits, so an engine release can change a trait without stranding your players’ saves. Each is off until include names it:
| Name | What it keeps | Kept on | Where |
|---|---|---|---|
entity |
Scene, position, facing, health | Each saved entity | Any game |
stats |
Base values and lasting modifiers | Each saved entity | Any game |
resources |
Mana and the like | Each saved entity | Any game, with stats |
inventory |
The bag and what is worn | Each saved entity | Any game |
wallet |
Coins picked up in the world | Her player | Any game |
levels |
Finished levels | The world | A game with no rooms |
loot |
Loot already taken | The world | A game with no rooms |
random |
The world’s random stream | The world | A game with no rooms |
Name random when a reload mid-run should replay the draw it would have had, so a player cannot reload on a bad draw. A new game draws a fresh sequence either way. A kit listed in plugins saves its own part, unless you pass the kit save: false.
A pickup’s coins go into her player’s wallet, not her character’s, so they survive a new character. To keep an engine save on the other side, name it with a scope: include: [{ name: "inventory", scope: "player" }] keeps one bag on her player entity across every character she plays, where your game keeps its bag on the player. stats and resources stay on the same side, since a resource’s maximum is a stat.
Keep each character apart
Section titled “Keep each character apart”The engine’s entity, stats, resources and inventory saves, and any save of scope: "entity", are kept per saved entity: an entity her save keeps under a key. The characters plugin registers each player’s character under main, so her bag, her stats and her health follow her across a respawn, a level change and a new character spawned in place of the old. Her record keeps each key’s part apart, under entities.<key>:
{ "engine": { "wallet": { "version": 1, "state": { "coins": 340 } } }, "entities": { "knight": { "engine": { "inventory": { "version": 1, "state": { "slots": [], "equipment": {} } } } }, "mage": { "engine": { "inventory": { "version": 1, "state": { "slots": [], "equipment": {} } } } } }}A game whose player switches between characters names a key for each with spawnCharacter(step, player, { key }): the new character restores that key’s record, and the one it replaces is captured into its own as it goes. setSavedEntity(step, player, entity, { key }) registers any other entity, such as a dragon a game spawns in place of a character or a tamed pet, and releaseSavedEntity(step, player, { key }) captures it and stops keeping it. restore: false on either call starts the entity fresh, as a roguelike’s new run does. A key with no live entity keeps what it last captured or loaded. The player page shows a party of three.
Keep a store the page owns
Section titled “Keep a store the page owns”State your page keeps outside the world, such as a career total on a title screen, is a zustand store wrapped in the engine’s persist. It takes zustand’s own options:
import { createStore, persist } from "@spawnite/engine";
interface Career { kills: number; runs: number; finishRun: (kills: number) => void;}
export const useCareer = createStore<Career>()( persist( (set) => ({ kills: 0, runs: 0, finishRun: (kills) => set((career) => ({ kills: career.kills + kills, runs: career.runs + 1, })), }), { name: "career", version: 1 }, ),);The store loads when the save has arrived, and the engine writes it with the rest of the record. Import it from any file of the page, a scene, App.tsx or a title screen, behind a lazy import too: the build finds every module that imports persist and names each store it makes in the manifest, so the platform keeps its part. Make the store as its module loads, as above. A module that imports persist and makes no store as it loads, such as one that makes it inside a function, fails the build, because the platform would keep nothing of it.
A persisted store has no schema for spawnite publish to compare, so publish checks only its version and prints a warning that says so. That warning is expected. What keeps older saves loading is the store’s version and its migrate: when the shape of its state changes, raise version and add a migrate that turns the older state into the new one. To have a schema decide what loads, as defineSave’s does, parse the stored state in merge and throw what the schema refuses: the load is then refused. A store a module makes after the save loaded, as a scene loaded later does, reads its part the same way, and a part it cannot read stops the game as any failed load does. A persisted store is for a game with no rooms.
Ask for a write at a moment that matters
Section titled “Ask for a write at a moment that matters”Your game never writes a save. It changes its state, and the platform writes the newest state on its own schedule: every 2 minutes while the record changes, when the tab is hidden, and when the player leaves. The device also keeps the newest state every 5 seconds, or every 30 for a record over 256 KiB as JSON, so a reload on the same device loses at most those seconds. At a moment that matters, such as a level’s end, call save(): it marks the state as changed, and the next write carries it. It never forces a write, so call it as often as you like.
useSaveStatus() answers saved, unsaved, saving or refused, and where the save goes: the platform, the browser on your dev server, or nowhere. A new game reads unsaved until its first write is stored. Draw your own indicator from it; the engine draws none.
A save must fit two limits: 16 MiB as JSON, and 4 MiB once compressed. A save that grows past either one while the player plays, up to twice the limit, is stored once. Then the game stops and shows “We couldn’t load your progress”, the status reads refused, and the development console names the limit and the size. Each later open, and each join of a room, measures the save again as your current version reads it, after its migration steps, and stops the same way while it is still past the limit. So a new version of your game whose step trims the save lets the player back in. A save past twice either limit, the platform’s ceiling, is not stored at all: the last save that fit stays, and play goes on. The console warns at three quarters of either limit.
Change what you save
Section titled “Change what you save”Each part of a save is read in three steps:
- The version is compared. A record that a newer version of the game wrote is refused, and is never overwritten.
- The steps run. A record at an older version runs each step in order, up to the current version.
- The schema reads the result. A field the schema does not name is dropped, and a missing field with a default gets it. Anything else the schema refuses refuses the load.
So any change to a saved shape raises version and ships the step from the version before, an added optional field included. spawnite publish enforces this: it compares each part’s version and schema fingerprint with every version you ever published, and refuses a version lower than the highest one, or the same version with another schema. The platform runs the same check again as it makes a version live, so no client publishes past it, and there is no flag to. When it cannot compare a fingerprint, it warns and publishes: a persisted store, which has no schema, always reads so.
An engine release can raise the version of the engine’s own saves, such as entity or inventory. So spawnite publish also refuses a version pinned to an engine release older than one a published version of your game runs on: the saves players wrote on the newer release may not load on the older one. Build against the newer release, or publish without the pin.
What a player sees when a save does not load
Section titled “What a player sees when a save does not load”A save that does not load is never written over. The game does not start, and the player sees “We couldn’t load your progress”, a line that their progress is safe and unchanged, and a Try again button. Publishing a fixed version is what lets the next try load. spawnite versions list shows how many loads each version refused, and which part failed.
Save in a game with rooms
Section titled “Save in a game with rooms”In a game with rooms, the room is the only writer of a player’s save. The page still passes the declaration to <Game save={save}>, so the build and the manifest see it, but the page’s own save session writes nothing. Export the same declaration from each scene’s file, beside plugins, which is where the room reads it:
export { save } from "../save";The room loads a player’s save as she joins and restores it onto her player, then onto each saved entity as it is registered, her character among them. It saves everyone in it together, by a checkpoint: it reads every player’s record at one tick, and either every save moves to that tick or none does. So an item one player hands another by ordinary game code is in both saves or in neither, whatever fails. The room takes a checkpoint every minute while anything changed, as a player leaves, and as the room stops. A room game has no save(): at a moment that matters, such as a match’s end, its server half calls requestSave(world), and the room takes a checkpoint within 5 seconds. The call names no player, and calling it often costs nothing.
A player entity’s PlayerSaveTrait says where her save stands: saved, unsaved, saving or refused. levels, loot, random, a world-scope save and a persisted store are errors in a game with rooms, because a room’s world is shared. spawnite build, build --server and publish refuse a room game whose scene file does not export its save, one that uses persist, and one that imports @spawnite/engine or its core whole, since the build cannot see what a whole import takes: import the names you use. A save the room cannot load refuses her join and shows her the same screen a page shows.
A room game’s save, on a trait
Section titled “A room game’s save, on a trait”The following declaration is Holdfast’s. It keeps a player’s career between runs on a trait of her warden, the one saved entity each player has, so it is an entity-scope save: the XP she earned, the runs she played, the nights she won, and the night seeds of the last runs counted, so a run she joins again is not counted twice. The trait also carries this run’s XP, runXp, which the end screens show: the trait streams to the page whole, but read keeps only what outlives the run. The level is worked out from the XP and never kept.
import { defineSave } from "@spawnite/engine";import { z } from "@spawnite/schema";import { CareerTrait, recentRunsKept } from "./siege/traits";
export const save = defineSave({ scope: "entity", version: 1, schema: z.object({ xp: z.int().check(z.nonnegative()), runs: z.int().check(z.nonnegative()), dawns: z.int().check(z.nonnegative()), recentRuns: z.array(z.int()).check(z.maxLength(recentRunsKept)), }), read: ({ entity: warden }) => { const career = warden.get(CareerTrait); return { xp: career?.xp ?? 0, runs: career?.runs ?? 0, dawns: career?.dawns ?? 0, recentRuns: [...(career?.recentRuns ?? [])], }; }, restore: ({ entity: warden }, saved) => { if (warden.has(CareerTrait)) warden.set(CareerTrait, saved); else warden.add(CareerTrait(saved)); },});import { defineTrait } from "@spawnite/engine/core";
/** The runs a career remembers it counted, newest last. */export const recentRunsKept = 8;
// The career and this run's XP, which the room streams to every page.export const CareerTrait = defineTrait("career", { xp: 0, runs: 0, dawns: 0, recentRuns: (): number[] => [], runXp: 0,});restore runs once the characters plugin has spawned her character and registered it, and before the game’s own systems first step her, so a system that adopts a new character, giving her a career where she has none, finds the restored one and keeps it. As a run ends, the server half adds the run’s XP to the career and asks for a checkpoint. The following function is a shorter form of Holdfast’s creditCareers, which also credits a warden who joined late and pays the XP for dawn:
import type { Entity, World } from "koota";import { requestSave } from "@spawnite/engine/core";import { CareerTrait, recentRunsKept } from "./traits";
/** Adds the run's XP to her career as the run ends, counts the run once * by its night seed, and asks the room for a checkpoint. */export function creditCareer( world: World, warden: Entity, runXp: number, nightSeed: number,) { const career = warden.get(CareerTrait); if (!career) return; const counts = !career.recentRuns.includes(nightSeed); warden.set(CareerTrait, { xp: career.xp + runXp, runs: career.runs + (counts ? 1 : 0), recentRuns: counts ? [...career.recentRuns, nightSeed].slice(-recentRunsKept) : career.recentRuns, runXp, }); requestSave(world);}What stops a save
Section titled “What stops a save”Three things stop a save:
- A save past the size limit. Keep what outlives a session, such as a level or a count, and never a log that grows each time she plays. A save past the limit and within twice the limit is still kept once; a save past twice the limit is never stored, and the last save that fit stays. Once a save past the limit is kept, on a page, the game then stops and each later open is refused while the save stays past the limit. In a room, she leaves the room and takes no seat in your game for 10 minutes.
- Save code that throws. Keep
reada plain copy of your state, and checkrestorewithspawnite save check. In a room, areadthat throws twice in a row ends the room: everyone in it goes back to the last checkpoint, and the player whose save failed takes no seat in your game for 10 minutes. - A value that is not JSON. A
Set, anundefinedfield or a number such asNaNrefuses the whole save, as areadthat throws does. Copy aSetinto an array, and keep numbers finite.
The engine’s saves page says what each one does in a page and in a room.
What a save does not promise
Section titled “What a save does not promise”A save keeps what one player did, written by one session at a time. A failure can still lose play, within these bounds:
- A room that ends keeps what its last checkpoint kept. A room that crashes, or that cannot save for 45 seconds, sends every player in it back to its last checkpoint: up to a minute of play, two while the room holds one player, and up to 45 seconds more while a checkpoint waits to commit. A
requestSaveat a moment that matters keeps that moment within 5 seconds. - A browser killed without warning loses its last few seconds. The same device gets them back from its journal at the next open, up to 5 seconds of play, or 30 for a record over 256 KiB as JSON; another device gets the last save the platform committed.
Test saves on your machine
Section titled “Test saves on your machine”Your dev server keeps saves the way the platform does: a page game’s record in the browser’s storage, and a dev room’s under .spawnite/saves/, which git ignores. A reload or a rejoin restores progress, and the load-failure screen shows as a player would see it. The following commands work on those saves:
spawnite save clearempties the game’s development saves: the dev room’s files, and the browser’s record, which the dev page drops at its next load.spawnite save checkplays a scene for a moment, reads its save, restores that save into a fresh world, steps both once more and compares them, part by part: the same after the reload, or where it differs. It plays from a new game, or from a save file with--save <file>, and holds the keys--keysnames, such asw,Shift. It catches a fieldrestoreforgets to put back, and a position restored without moving the body with it, but only for a part the played time changed: a new game left idle holds what a fresh world holds, and a restore that put nothing back would read the same. So it says which parts a new game holds the same, and where none changed it says the round trip proved nothing and exits 1, unless--allow-empty. Start it from a save file, or play it with--keys, so the save holds what a new game does not. From a file, it also compares the file with what the world reads as it loads, which a restore that drops a field fails, and it reads the fresh world as it restores the saved moment, before any step, so a restore that a system’s next step would cover up fails too. A part that migrated, or a field its schema drops, is left tosave check <file>, and the random stream is compared before the scene mounts, since a mount may draw from it. It exits 1 on a difference.--scenenames the scene, which a game with one scene needs not, and--secondshow long it plays, 1 by default. A game that declares no save is told so.spawnite save check <file>reads a save file through your current declaration and prints, part by part, whether it loaded, which version it migrated from, or why it was refused. It exits 1 on a refusal. It checks each part’s version, steps and schema, and a store’smigrateandmerge; it runs norestore, and arestorethat throws refuses a real load all the same. Run it on an old save before you publish a change to its shape.--save <file>onspawnite play startandspawnite simulatestarts from a save file instead of a new game, and so doessaveon the studio’ssimulateandstart_playtesttools. The file is handed over once, so in a play session a reload or a rejoin keeps what was played since, and nothing is written to the file. Without it, both start clean every time, as do playtests and profile runs.
A development room refuses a second page under a name already playing, since both would write one file: open the second page under another name.
A test that renders the game’s <Game> awaits resetPageSave() from @spawnite/engine/testing after it unmounts the game, and after each case: it waits for the save the unmount sent, then ends the page’s save, so the next <Game> loads afresh. Read the saved record after it.