NarraLeaf

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.

save-storage.ts
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.

use-save-system.ts
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().

On this page