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, and registered Service data. Preferences are separate; export them with game.preference.exportPreferences().