Save System with localStorage
NarraLeaf provides the save data, not the storage UI. serialize() returns a SavedGame; deserialize() restores it. This example keeps five normal slots and one quick-save slot in localStorage.
localStorage is device-local, synchronous, and easy for users to edit. Use it for small browser games. Use IndexedDB, files, or a server when saves are large or must sync between devices.
Storage helpers
Keep parsing and compatibility checks in one place.
import type { SavedGame } from "narraleaf-react";
const PREFIX = "my-game:save:";
export function writeSave(slot: string, save: SavedGame) {
localStorage.setItem(PREFIX + slot, JSON.stringify(save));
}
export function readSave(slot: string): SavedGame | null {
const raw = localStorage.getItem(PREFIX + slot);
if (!raw) return null;
try {
const value: unknown = JSON.parse(raw);
if (!isSavedGame(value)) return null;
return value;
} catch {
return null;
}
}
export function deleteSave(slot: string) {
localStorage.removeItem(PREFIX + slot);
}
function isSavedGame(value: unknown): value is SavedGame {
if (!value || typeof value !== "object") return false;
const save = value as Partial<SavedGame>;
return typeof save.name === "string"
&& typeof save.meta?.updated === "number"
&& typeof save.meta?.storyHash === "string"
&& typeof save.game === "object";
}This is a minimal guard, not a full schema validator. Use Zod, Valibot, or a similar validator when save files come from uploads or a server.
Save and load hook
Check the story hash before replacing the current game. deserialize() does not perform this check for you.
import { useLiveGame } from "narraleaf-react";
import { readSave, writeSave } from "./save-storage";
export function useSaveSystem() {
const liveGame = useLiveGame();
function save(slot: string) {
const data = liveGame.serialize();
writeSave(slot, data);
return data.meta;
}
function load(slot: string) {
const data = readSave(slot);
if (!data || !liveGame.story) return false;
if (data.meta.storyHash !== liveGame.story.hash()) {
throw new Error("This save was created by an incompatible story version.");
}
liveGame.deserialize(data);
return true;
}
return { save, load };
}Changing action order, branches, scenes, or other story structure can invalidate old saves. Text-only edits normally keep the same non-strict hash.
Save slot UI
Read metadata to display the timestamp and last line. Refresh the local state after writing a slot.
import { useState } from "react";
import type { SavedGameMetaData } from "narraleaf-react";
import { readSave } from "./save-storage";
import { useSaveSystem } from "./use-save-system";
const SLOT_IDS = ["1", "2", "3", "4", "5"];
export function SaveSlots() {
const { save, load } = useSaveSystem();
const [slots, setSlots] = useState(() => readMetadata());
function saveSlot(slot: string) {
save(slot);
setSlots(readMetadata());
}
return (
<div className="grid grid-cols-2 gap-3">
{SLOT_IDS.map((slot) => {
const meta = slots[slot];
return (
<section key={slot} className="rounded border p-3">
<strong>Slot {slot}</strong>
{meta && (
<>
<p>{new Date(meta.updated).toLocaleString()}</p>
<p>{meta.lastSpeaker ?? "Narrator"}: {meta.lastSentence}</p>
</>
)}
<button onClick={() => saveSlot(slot)}>{meta ? "Overwrite" : "Save"}</button>
<button disabled={!meta} onClick={() => load(slot)}>Load</button>
</section>
);
})}
</div>
);
}
function readMetadata(): Record<string, SavedGameMetaData | null> {
return Object.fromEntries(
SLOT_IDS.map((slot) => [slot, readSave(slot)?.meta ?? null]),
);
}Quick save and load
Register keyboard listeners in an effect and remove them on cleanup. Avoid browser-reserved keys such as F5 and Ctrl+S unless you intentionally prevent their defaults.
import { useEffect } from "react";
import { useSaveSystem } from "./use-save-system";
export function QuickSaveKeys() {
const { save, load } = useSaveSystem();
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === "F6") {
event.preventDefault();
save("quick");
} else if (event.key === "F9") {
event.preventDefault();
load("quick");
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [save, load]);
return null;
}Mount QuickSaveKeys inside GameProviders. Add liveGame.notify() in the two branches if the game should confirm the result.
What a save includes
SavedGame contains Persistent namespaces, scene-local state, element state, the action stack, async stacks, player stage state, the backlog, and registered Service data. Preferences are separate; export them with game.preference.exportPreferences().
Since save format v3 (0.26.0), the element state written out is only what differs from what the script wrote, and loading resets every element before applying the save — so a save is sized by what is on stage rather than by the size of the project. A save written by 0.26.0 cannot be read correctly by an older engine; see SavedGame.