NarraLeaf

events

Subscribe to game lifecycle and story events, and check whether this environment can ever fire one.

app.game.events lets a plugin react to the running game without being wired into a graph — preload finishing, a line ending, a choice being made, the window being closed.

{
  "contributes": {
    "runtimeCapabilities": ["events"]
  }
}

Methods

MethodSignature
on<K>(event: K, listener: (payload) => void) => RuntimePluginCleanup
available(event) => boolean

on returns a cleanup that removes the subscription. The host also tracks every listener, so a plugin that fails cannot leak them.

export default defineRuntimePlugin({
    setup(app) {
        app.game.events?.on("choiceMade", ({ text }) => {
            app.game.log("info", `player chose: ${text}`);
        });
    },
});

The events

EventPayloadNotes
preloadCompletevoidFirst-pass preload finished. Replayed — subscribing late still fires.
firstSceneReadyvoidFirst scene mounted and painted once. Replayed.
sceneEnter{ sceneId: string | null }A scene component mounted. See the warning below.
sceneExit{ sceneId: string | null }The counterpart unmount.
dialogueEnd{ textId: string | null }One line of dialogue finished displaying. textId is the line's unit ID — the id the translation table and the engine's voiceId use — or null for a line the host cannot name. It is the id to record against when a plugin keeps its own account of what the player has heard.
choiceMade{ text: string }The player picked a choice.
choiceShown{ options: { index: number; text: string; disabled: boolean }[] }A choice menu went on screen, with every option it is offering — the other half of choiceMade, which only says what was taken. Fires whenever the menu mounts, so a rollback into the same choice reports it again. index is the engine's index and is what picking one addresses; options a condition hid are absent and do not shift it, so the indices can have gaps.
characterPrompt{ character: string | null; text: string }A character line was shown.
audioPlayed{ assetId: string }The story started playing an audio asset: a /bgm or /sound row, or a scene whose configured background music begins as it mounts. Reports what the story began, not every sound the game makes — a clip a Page starts through Play Sound belongs to the interface and does not appear here. Like sceneEnter it follows execution rather than a first visit, so make what you do with it idempotent.
gameEndvoidThe story reached an ending: an /ending row ran, or the action stack drained with a save context present. Both, deliberately — an authored ending is the game ending, so a plugin watching only the drained stack would stop hearing about endings the moment a project started marking them.
endingReached{ endingId: string; name: string }An /ending row ran, naming the ending it declares. The one that can say which ending it was.
beforeRestorevoidA save is about to be restored.
afterRestorevoidA save has been restored.
fullscreenChangedbooleanThe new fullscreen state.
closeRequestedvoidThe player asked to close the window. Desktop only.
saveWritten{ id: string }A save slot was written.

sceneEnter / sceneExit are rendering events, not "the story reached this scene". A remount fires sceneEnter again for a scene the player never re-entered. If you need story progress, drive it from the story itself (a blueprint node) rather than from mounting.

Check availability, do not assume a shell

Some events cannot exist in some environments. closeRequested is the clear case: the web export has no window to close, so a listener registered there will never fire — a bug that only shows up on one target.

if (app.game.events?.available("closeRequested")) {
    app.game.events.on("closeRequested", () => { /* flush something */ });
} else {
    // web: flush on `dialogueEnd` or on your own schedule instead
}

A closeRequested listener is an observer, not a veto. Plugins are consulted in parallel and each is isolated; throwing from your listener is read as "no objection", not as a request to cancel the close. Nothing a plugin does can keep the window open.

Replayed versus live

preloadComplete and firstSceneReady replay: if the plugin subscribes after they already happened, the listener fires immediately. Every other event is live only — a listener registered after the fact hears nothing about the past.

This matters because setup may be async. A plugin that awaits something before subscribing can miss live events fired in the meantime, but will still receive the two replayed ones.

On this page