NarraLeaf

Service

The `Service` abstract class for custom, story-registered logic shared across scenes, covering actions, serialization, and async handling.

Service is an abstract class for custom actions, behaviors, or any other logic shared across scenes.

To use it, extend it, register a handler for each of its actions in the constructor, and implement serialize and deserialize to save its data with the game.

import {Service} from "narraleaf-react";

Example

Here is an example of a custom service that implements a simple gallery.

type GalleryActions = {
    add: [name: string];
};

type GalleryData = {
    unlocked: string[];
};

class Gallery extends Service<GalleryActions, GalleryData> {
    // custom data
    unlocked: string[] = [];

    constructor() {
        super();

        // register the action handler; `name` is typed by GalleryActions
        this.on("add", (ctx, name) => {
            this.unlocked.push(name);
        });
    }

    /* Save the data with the game, and restore it from a save */
    serialize(): GalleryData {
        return {
            unlocked: [...this.unlocked],
        };
    }

    deserialize(data: GalleryData): void {
        this.unlocked = [...data.unlocked];
    }

    /* Custom service logic */
    add(name: string) {
        // trigger the action
        return this.trigger("add", name);
    }
}

and we can use this service in the game:

const gallery = new Gallery();
story.registerService("gallery", gallery);

myScene.action([
    gallery
        .add("image1")
        .add("image2")
        .add("image3"),
]);

Creating Custom Service

Implementing Service

To implement a service, extend the Service class. Its two type parameters are the actions it handles and the data it saves:

type MyCustomActions = {
    myAction: [arg0: string, arg1: number];
};

type MyCustomData = {
    count: number;
};

class MyCustomService extends Service<MyCustomActions, MyCustomData> {
    count = 0;

    /**
     * Serialize the service to data
     *
     * **Note**: data must be JSON serializable, return null if nothing needs to be saved
     */
    serialize(): MyCustomData | null {
        return {count: this.count};
    }

    /**
     * Load data to the service
     * @param data data returned by serialize
     */
    deserialize(data: MyCustomData): void {
        this.count = data.count;
    }
}
  • serialize runs whenever the game is saved, and whenever a line or a menu is recorded in the backlog, so that the player can return to it. Return a copy of the service's state, not the state itself: the engine keeps what serialize returns, and a live reference would go on changing with the service.
  • deserialize runs when a save is loaded, with what serialize returned. A service the save holds no data for - it returned null, or the save was made before it was registered - is not called, and keeps the state it has.
  • A service with nothing to save returns null from serialize and leaves deserialize empty.

Registering Actions

To register actions, use the on method in the constructor. A handler is looked up by name each time its action runs, including after a save is loaded, so it has to be registered before the story starts. An action whose handler is missing logs a warning and does nothing.

class MyCustomService extends Service<MyCustomActions, MyCustomData> {
    constructor() {
        super();

        // ctx, arg0 and arg1 are typed by MyCustomActions
        this.on("myAction", (ctx, arg0, arg1) => {
            // custom logic
        });
    }

    /* serialize and deserialize */
}

For ctx, see ServiceHandlerCtx.

The action map is what type-checks the handlers and the triggers. A service declared without one, extends Service, accepts any action name with any arguments.

Async Actions

A handler that returns a promise holds the story until the promise settles, the way a line of dialogue does. A handler that returns anything else lets the story carry on at once.

this.on("load", async (ctx, url: string) => {
    const response = await fetch(url, { signal: ctx.signal });
    const data = await response.json();
    if (ctx.signal.aborted) return; // cancelled while waiting: leave the game alone

    this.data = data;
});

If the promise rejects, the story stops at this action and the error is reported.

Since 1.3.0 a handler is waited for whenever it returns a promise. Before, only a function declared async was: a plain function returning a promise - (ctx) => fetch(url) - ran without the story waiting for it, and a promise that rejected left the story waiting forever without reporting anything.

While it holds the story, a handler hears about the player in two ways: it is cancelled when the game moves away from it, and it is asked to hurry when the player skips.

Cancellation

A run is cancelled when the player steps back past it, the story jumps away from it, a Control.any it ran in is won by another branch, or a save is loaded. ctx.signal is then aborted. It is a standard AbortSignal, so it can be handed to fetch and anything else that takes one.

A cancellation cannot be refused, and it does not wait for the handler: by the time the signal fires the game has already moved on. It is the script's responsibility to stop any work still in flight (network requests, timers, animations) and roll back the side effects created so far, so that running the action again after a step back gives exactly the same result. Check ctx.signal.aborted after each await before touching the game again. The rejection a cancelled run ends with - the AbortError of a fetch - is not reported.

Skipping

When the player skips - presses the skip key, holds it, or a host calls skipDialog or fastForward - a handler holding the story is asked to hurry through ctx.skip. Being asked does not end it: the handler decides what being skipped means for it.

AnswerHowHolding the skip keyfastForward
Finish nowPut what it was doing into its final state and return, in ctx.skip.onRequestMoves onMoves on
Ignore itDo nothing - what every handler written before 1.3.0 doesWaits for the handlerWaits up to stepTimeout, then reports "stalled"
Refusectx.skip.refuse(message?)Waits for the handlerStops at once and reports "refused"
type MinigameActions = {
    countdown: [seconds: number];
    fishing: [];
};

class Minigames extends Service<MinigameActions, null> {
    constructor() {
        super();

        // Finish now: skipping ends the countdown at once.
        this.on("countdown", async (ctx, seconds) => {
            await new Promise<void>(resolve => {
                const timer = setTimeout(resolve, seconds * 1000);
                ctx.skip.onRequest(() => {
                    clearTimeout(timer);
                    resolve();
                });
                ctx.signal.addEventListener("abort", () => clearTimeout(timer));
            });
        });

        // Refuse: a fast-forward cannot play the minigame for the player, so it stops here.
        this.on("fishing", async (ctx) => {
            ctx.skip.refuse("waiting for the fishing minigame");
            await playFishing(ctx.signal);
        });
    }

    serialize() {
        return null;
    }

    deserialize() {
    }
}
  • onRequest is called the first time the player skips, and once more the first time it is in the skip mode (request.forced) - the distinction a line of dialogue draws between one press of the skip key, which reveals a line up to its next pause, and holding it, which walks past the pauses too. It is not called for every repeat of the skip mode.
  • refuse returns a function that withdraws the refusal. Refusals stack: the action can be skipped again once all of them are withdrawn. The message is for people - it is logged and handed back by fastForward - and nothing in the engine reads it.
  • ctx.skip.fastForwarding says whether the game is fast-forwarding right now, for a handler that can go straight to its final state on entry.
  • Requests are broadcast, as they are to everything else on the stage: a handler running in a Control.all branch or an async one is asked whenever the player skips. A click on the stage is not a request.
  • Only a handler that returns a promise is ever asked. A synchronous one has finished before the player could ask.

See ServiceHandlerCtx for every member.

ctx.signal and ctx.skip are available since 1.3.0. ctx.onAbort, which fires on the same occasions as ctx.signal, is deprecated.

Trigger Action

To trigger an action, you can use the trigger method.

const service = new MyCustomService();

scene.action([
    service.trigger("myAction", "foo", 123)
]);

or you can wrap the action in a method.

class MyCustomService extends Service<MyCustomActions, MyCustomData> {
    myAction(arg0: string, arg1: number) {
        return this.trigger("myAction", arg0, arg1);
    }
}

const service = new MyCustomService();

scene.action([
    service.myAction("foo", 123),

    service
        .myAction("foo", 123)  // actions are wrapped and chainable
        .myAction("bar", 456), // the game will manage the chain behavior automatically
]);

Accessing Service

After creating the service, register it with the story under a name. Registering is what puts the service's data in saves, and what lets the interface look it up.

const story = new Story(/* ... */);
story.registerService("gallery", gallery);

A component reads it through useLiveGame:

function GalleryPage() {
    const liveGame = useLiveGame();
    const gallery = liveGame.story?.getService<Gallery>("gallery");

    return (
        <ul>
            {gallery?.unlocked.map((name) => (
                <li key={name}>{name}</li>
            ))}
        </ul>
    );
}

getService throws when no service is registered under the name.

Public Methods

on<K extends StringKeyOf<Content>>

Register an action handler.

class MyCustomService extends Service<MyCustomActions, MyCustomData> {
    constructor() {
        super();

        this.on("myAction", (ctx, arg0, arg1) => {
            // custom logic
        });
    }
}
  • key: K - The action key
  • handler: ServiceHandler<Content[K]> - Called with the ServiceHandlerCtx and the action's arguments. A handler that returns a promise holds the story until it settles; see Async Actions
  • Returns this

Chainable Methods

trigger<K extends StringKeyOf<Content>>

Trigger an action.

const service = new MyCustomService();

scene.action([
    service.trigger("myAction", "foo", 123)
]);
  • key: K - The action key
  • ...args: Content[K] - The action arguments

On this page