NarraLeaf

LiveGame

LiveGame methods for starting, saving, loading, undo and redo, history, screenshots, full screen, and fast-forwarding playback.

LiveGame is the main class that represents the game's current state.

Public Properties

game

The Game instance

story

The current Story instance

Public Methods

getStorable

Returns the Storable instance

The same instance for the life of the LiveGame — newGame() and deserialize() rebuild the namespaces inside it, not the store itself, which is why a subscription made through Watching stored values survives both.

get storable

Returns the Storable instance

const game = useGame();
const storable = game.getLiveGame().storable;

// is equivalent to
const storable = game.getLiveGame().getStorable();

newGame

Starts a new game

  • return this

deserialize

Load a saved game

After calling this method, the current game state will be lost, and the stage will trigger force reset

**Note: **Even if you change just a single line of script, the saved game might not be compatible with the new version

Example:

const savedGame = {
    // ...saved game data
};

// use hook inside a component
const game = useGame();

// pass the saved game data to the game instance
game.getLiveGame().deserialize(savedGame);

serialize

Serialize the current game state

You can use this to save the game state to a file or a database

**Note: **Even if you change just a single line of script, the saved game might not be compatible with the new version

onCharacterPrompt

Called when a character says something

const game = useGame();
const [texts, setTexts] = useState<string[]>([]);

useEffect(() => {
    const token = game.getLiveGame().onCharacterPrompt((event) => {
        setTexts((prevTexts) => [...prevTexts, event.text]);
    });

    return () => {
        token.cancel();
    };
}, []);

return (
    <div>
        {/* Your Text Log */}
    </div>
);

onMenuChoose

Called when a menu is completed

capturePng

Capture the game screenshot, will only include the player element

Returns a PNG image base64-encoded data URL

**Note: **Image returned by this method is not compressed, and it is not affected by the screenshotQuality option

const game = useGame();

function handleButtonClick() {
    game.getLiveGame().capturePng().then((dataUrl) => {
        // do something with the dataUrl
    });
}
  • Returns Promise<string>

captureJpeg

Capture the game screenshot, will only include the player element

Returns compressed JPEG image data URL

  • Returns Promise<string>

captureSvg

Capture the game screenshot, will only include the player element

Returns an SVG data URL

  • Returns Promise<string>

capturePngBlob

Capture the game screenshot, will only include the player element

Returns a PNG image blob

  • Returns Promise<Blob | null>

requestFullScreen

Request full screen on Chrome/Safari/Firefox/IE/Edge/Opera, the player element will be full screen

Note: this method should be called in response to a user gesture (for example, a click event)

Safari iOS and Webview iOS aren't supported, for more information, see MDN-requestFullscreen

  • options?: FullscreenOptions | undefined
  • Returns Promise<void> | void

exitFullScreen

Exit full screen

  • Returns Promise<void> | void

onPlayerEvent

Listen to the events of the player element

const game = useGame();

useEffect(() => {
    return game.getLiveGame().onPlayerEvent("click", (event) => {
        // do something
    }).cancel;
}, []);
  • type: K - The event type
  • listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any - The event listener
  • options?: boolean | AddEventListenerOptions
  • Returns LiveGameEventToken

getHistory

Get the game history. This method is used to create backlog.

The backlog is every line read up to and including the one the game is on. Since save format v2 it is persisted, so this returns the full history immediately after deserialize — a loaded game no longer starts with an empty backlog.

After undo has stepped back, the lines beyond the play head are not here. They are a future the player can step into again, and getFuture returns those — a backlog listing them would be showing what has not happened yet.

import { useLiveGame, GameHistory } from "narraleaf-react";
const liveGame = useLiveGame();
const history = liveGame.getHistory();

function handleRestore(entry: GameHistory) {
    liveGame.restoreToHistory(entry.token);
}

return (
    <div>
        <h3>Backlog</h3>

        {history.map((item) => (
            <div
                key={item.token}
                onClick={() => handleRestore(item)}
            >
                {/* show the action text */}
                {/* text is available when the action is "say" or "menu" */}
                {item.element.text}
            </div>
        ))}
    </div>
);

An entry's token keeps naming its line across saves and rewinds, so a backlog UI can hold one and use it later.

  • Returns GameHistory[] - The game history, see GameHistory

Before 0.26.0 a fresh token was minted for every entry whenever the backlog was rebuilt, so loading a save or restoring a line silently invalidated every token a caller was holding — a backlog's buttons stopped working until it re-read the list, and restoring to the same line twice failed the second time. Tokens are now written into the save and kept when it is read back. Saves written earlier carry none, and their entries are given fresh tokens as before.

getFuture

The lines ahead of the play head: read once, stepped back past with undo, and reachable again with redo.

Empty during ordinary play, and empty right after loading a save — a save written after stepping back carries the backlog up to that line and nothing beyond it, so loading it opens there with nothing to step forward into. That is what saving in the past means.

const liveGame = useLiveGame();

// The lines the player stepped back past, oldest first.
const future = liveGame.getFuture();
  • Returns GameHistory[] - The lines ahead, see GameHistory

Available since 0.26.0.

canUndo

Whether there is a line before this one to step back to.

<button disabled={!liveGame.canUndo()} onClick={() => liveGame.undo()}>
    Back
</button>
  • Returns boolean

Available since 0.26.0.

canRedo

Whether a line stepped back past is waiting ahead.

<button disabled={!liveGame.canRedo()} onClick={() => liveGame.redo()}>
    Forward
</button>
  • Returns boolean

Available since 0.26.0.

undo

Step back one line.

liveGame.undo();
  • Returns boolean - true if the game moved; false if this is already the first line, or that line carries no snapshot.

Backward and forward are one mechanism: every line records a self-contained snapshot of the game as it is reached, and moving in either direction restores the snapshot of the line being moved to.

Stepping back works after loading a save. Before 0.26.0, undo walked a stack of closures held in memory and nothing else. Closures cannot be written to a file, so loading a save left the player with a backlog they could not step back into — the button was there and did nothing. The live stack is still the preferred route while it can reach the line, precisely because it steps back without disturbing anything: the music keeps playing, running transitions are left alone, and the stage is not rebuilt. The snapshot takes over where the stack stops, so the boundary is no longer there.

Reading forward again keeps what is ahead. The line stepped back from is not discarded. Stepping back three lines and then reading forward retraces those same lines rather than overwriting them, so the rest of what had been read is still ahead. The future is dropped only when the story goes somewhere else — the other side of a choice — because that future no longer follows from where the story is.

Signature change in 0.26.0. undo() no longer takes an action id; a line is named by its token through restoreToHistory. It returns false rather than throwing when there is nowhere to go.

redo

Step forward one line, into a line stepped back past.

liveGame.redo();
  • Returns boolean - true if the game moved; false if there is nothing ahead, or it carries no snapshot.

This only reaches lines the player has already read: it replays the recorded future rather than running the story on. To carry on past the end of the future, let the game advance normally.

Available since 0.26.0.

restoreToHistory

Move the game to a recorded line, named by its token.

The same mechanism as undo and redo, and it reaches in either direction: a token from getHistory steps back, one from getFuture steps forward. Like both of them it works after loading a save, and it discards nothing — the lines past the one it moves to stay reachable.

const history = liveGame.getHistory();

// Jump to a line — including one restored from a loaded save.
liveGame.restoreToHistory(history[0].token);
  • token: string - The token of the history item to move to, see GameHistory.
  • Returns boolean - true if the line was restored; false if the token is unknown or the entry has no restore snapshot.

Before 0.26.0 this reached backwards only and trimmed the backlog to the line it moved to, so everything after it was lost. It now moves the play head instead of cutting the timeline.

notify

Create a notification.

The style of the notification is defined by Notification.

// notify for 3 seconds
game.getLiveGame().notify("Save success", 3000);
// to control the notification, set the duration to `null` to make the notification stay forever
const token = game.getLiveGame().notify("Fast forward", null);

// cancel the notification when the player releases the right key
window.addEventListener("keyup", (event) => {
    if (event.key === "ArrowRight") {
        token.cancel();
    }
});
  • message: string - The message to notify
  • duration?: number | null - The duration of the notification, default is 3000ms. Set to null to make the notification stay forever.
  • Returns NotificationToken - See NotificationToken

playSound

Play a sound immediately and return its SoundToken.

const game = useGame();

game.getLiveGame()
    .playSound("https://example.com/voice.mp3")
    .then((token) => {
        token.once("ended", () => {
            console.log("Voice playback completed");
        });
    });

The clip starts at the volume its Sound was configured with — Sound.voice({src, volume: 0.4}) starts at 0.4, not at full volume. A source given as a string or URL becomes a default Sound, which is full volume; pass a Sound to say otherwise.

Before 0.22.0, "no volume said" was read as full volume here, so a configured volume was discarded. A clip replayed after setVolume now comes back at the volume it was last set to instead of jumping to full.

There is no fade: the token's volume is already settled when this resolves and no ramp is left running, so a setVolume or a fade driven on the returned token afterwards wins outright.

  • sound: Sound | string | URL - The sound instance, sound source string, or URL
  • Returns Promise<SoundToken> - See @NarraLeaf/Sound for more information about the soundToken instance

waitForRouterExit

Wait for the router to exit.

This method is useful when you want to create a new game and wait for the router to exit.

const game = useGame();
const router = useRouter();
const liveGame = game.getLiveGame();

useEffect(() => {
    router.clear().cleanHistory();

    const token = liveGame
        .newGame()
        .waitForRouterExit()
    
    token
        .promise
        .then(() => {
            dispatchState({ isPlaying: true });
        });

    return () => {
        token.cancel();
    };
}, []);
  • Returns { promise: Promise<void>; cancel: VoidFunction; }

waitForPageMount

Wait for the page to mount

const game = useGame();
const router = useRouter();
const liveGame = game.getLiveGame();

useEffect(() => {
    router.push("home");
    const token = liveGame.waitForPageMount();

    token.promise.then(() => {
        // do something
    });

    return () => {
        token.cancel();
    };
}, []);
  • Returns { promise: Promise<void>; cancel: VoidFunction; }

onWindowEvent

Listen to the events of the window

const game = useGame();

useEffect(() => {
    return game.getLiveGame().onWindowEvent("resize", (event) => {
        // handle window resize
    }).cancel;
}, []);
  • type: K - The event type
  • listener: (this: Window, ev: WindowEventMap[K]) => any - The event listener
  • options?: boolean | AddEventListenerOptions
  • Returns LiveGameEventToken - See LiveGameEventToken

reset

Reset the game state

Note: Calling this method will lose the current game state

const game = useGame();
const router = useRouter();

game.getLiveGame().reset();
router.clear().cleanHistory().push("home");

skipDialog

Skip the current dialog

game.getLiveGame().skipDialog();

fastForward

Fast-forward playback to the next menu, to the end of the story, or to a specific action.

Every line in between is executed for real, so the backlog and its restore snapshots accumulate exactly as in normal play — only faster and silent. Audio is muted for the duration, and the timed pauses the run executes (Control.sleep, auto-forward) resolve at once. It stops as soon as a menu is waiting for a choice, so the choice itself is always left to the player. Because history accumulates the whole way, getHistory and restoreToHistory cover the fast-forwarded span just like normal play.

Skipping a line is a request broadcast to the renderer, not a synchronous state change, so it is re-issued until the line settles. A line that never answers ends the run with "stalled" rather than hanging — this method always settles. A line that answers by refusing to be skipped — a Service handler calling ctx.skip.refuse() — ends it at once with "refused", and is left running where it is.

// Jump ahead to the next decision point.
await game.getLiveGame().fastForward();

// Or run to the end of the story.
await game.getLiveGame().fastForward({ until: "end" });

// Or park the play head on a specific action, without running it.
const result = await game.getLiveGame().fastForward({ until: { actionId: "act-42" } });

if (result.reason === "action") {
    // parked on act-42, not yet executed
} else if (result.reachedTarget === false) {
    // a menu blocked the path, the stack drained, maxSteps was hit, or a line stalled or refused
}

if (result.reason === "refused") {
    // a line is waiting for the player; result.refusal is its message, to show, if it gave one
}
  • options.until?: "menu" | "end" | { actionId: string } - "menu" (default) stops at the next menu; "end" runs until the story finishes; { actionId } runs until that action surfaces as the next thing to execute and stops just before running it, so the play head is left parked on that line. Only the root execution stack is scanned — an id buried inside an in-flight Control.all / Control.any or async branch is not a stop point. A menu that blocks the path stops an actionId run too, since the target cannot be reached until the player decides.
  • options.maxSteps?: number - safety bound on the number of advance steps (defaults to the maxStackModelLoop config).
  • options.stepTimeout?: number - how long, in milliseconds, a single suspended line is given to settle before the run reports "stalled". Default 10000. Raise it for a story that fast-forwards through long unskippable media.
  • Returns Promise<{ reason: "menu" | "end" | "maxSteps" | "action" | "stalled" | "refused"; reachedTarget?: boolean; refusal?: string }> - why it stopped.
    • reason - "action" reached until.actionId; "menu" a menu is waiting for a choice; "end" the stack drained; "maxSteps" the step cap was hit; "stalled" a line did not settle within stepTimeout; "refused" a line refused to be skipped.
    • reachedTarget - present only when until: { actionId } was requested, and true only for reason "action". A "menu" / "end" run keeps its plain { reason } shape.
    • refusal - with "refused", the message the line gave, if it gave one. It is for showing to a person; do not decide anything by it.

until: { actionId }, the "action" reason and reachedTarget are available since 0.16.0. options.stepTimeout and the "stalled" reason are available since 0.17.1. The "refused" reason and refusal are available since 1.3.0; an exhaustive switch over reason needs the extra arm to keep compiling.

A run can end early on a step that cannot be skipped. A step already in flight when the run started, a video (allowSkipVideo is false by default), and a camera or layer transition all ignore the skip request. Such a step still settles on its own if it finishes within stepTimeout, so only one that outlives the timeout ends the run — with "stalled", and with volume and the fast-forward flag restored on the way out.

Before 0.17.1 the run parked on such a step instead: the promise settled neither way, the game stayed muted and permanently in fast-forward mode. Nothing that worked before starts failing, but a host that treats any non-"menu" reason as success should now distinguish "stalled", and an exhaustive switch over reason needs the extra arm to keep compiling.

onCurrentActionChange

Available since 0.16.0.

Subscribe to the current-action stream. The handler runs each time an action begins executing, carrying that action's id and type — enough for an external play head, such as an editor timeline, to follow a running game.

It fires for every executed action, including those inside Control.all / Control.any and the async branches of Control.doAsync / Control.allAsync. A listener that only tracks top-level lines should filter by its own set of ids.

const liveGame = useLiveGame();

useEffect(() => {
    return liveGame.onCurrentActionChange(({actionId, actionType}) => {
        // move the play head to actionId
    }).cancel;
}, []);
  • fc: (payload: {actionId: string | null, actionType: string | null}) => void - Called with the action that just began executing. Both fields are null for an action the story carries no id or type for.
  • Returns LiveGameEventToken - See LiveGameEventToken

Experimental, read-only. This surface exists for tooling that watches a game run. Do not drive story logic from it, and do not persist what it reports — serialize is the save format.

getCurrentActionId

Available since 0.16.0.

The id of the most recently executed action. The pull-based companion to onCurrentActionChange, for a host that polls rather than subscribes.

const actionId = game.getLiveGame().getCurrentActionId();
  • Returns string | null - null before the first action of the run has executed, and for an action the story carries no id for.

getStackSnapshot

Available since 0.16.0.

A top-first view of the execution stacks, for a call-stack or debug panel: the root stack, plus one entry per in-flight async stack started by Control.doAsync / Control.allAsync.

const {root, async} = game.getLiveGame().getStackSnapshot();

// the line currently executing
root.frames[0]?.actionId;

// a concurrent frame carries its branches, each a whole snapshot
root.frames[0]?.branches?.[0]?.frames;

// a Control.repeat frame carries its iteration counter
root.frames[0]?.branches?.[0]?.loop?.counter;
  • Returns {root: StackSnapshot, async: StackSnapshot[]} - Empty frames before the game starts.

StackSnapshot is {tag?: string, frames: StackFrameSnapshot[], loop?: {type: "count" | "condition", counter: number, limit?: number, broken: boolean}}. frames is ordered top-first: frames[0] is the frame executing now.

StackFrameSnapshot is {actionId: string | null, actionType: string | null, branchWaitType?: string, branches?: StackSnapshot[]}. A frame that is a concurrent group (Control.all / Control.any) lists its branches, and each branch is a whole StackSnapshot — so a nested stack's own loop counter and tag come with it.

Experimental, read-only. The shape is a convenience projection, not a stability contract, and it may change without a major version. Never serialize a snapshot — use serialize for saves.

On this page