NarraLeaf

Puppet

The `Puppet` element for hosting an external character renderer, covering backend registration, lifecycle, and posing.

This class extends Displayable

Available since 0.20.0.

Puppet is a box on the stage whose interior is drawn by a backend you register. The engine owns the outside of the box: its position, layer, transform, opacity, and its entry in the saved game. Your backend draws the inside.

import {Puppet} from "narraleaf-react";

The engine ships no renderer. src, options, command names and payloads are opaque values that it stores, forwards and serialises untouched, so a host can plug in a 2D model renderer, a particle system, or any other renderer it already owns. The character that renderer draws still stands where the script puts it, moves with the Camera, poses from the story, and is restored from a saved game.

With no backend registered, the element still takes its place on stage, transforms, saves and restores, and draws nothing. That is a normal state, not a crash. See Missing or failing backends.

Registering a backend

Register a backend on the Game instance, not through config: it is a live object with methods, and config is deep-merged and may be frozen. Register before the game mounts. A plugin can call this from its own register(game).

The backend below is complete. Its model is a JSON manifest naming a colour and a texture, which covers the two things a real backend has to handle: a bundle it can only resolve after parsing, and a first pose that arrives while it is still loading.

swatch.ts
import {Game, type PuppetBackend} from "narraleaf-react";

const swatch: PuppetBackend = {
    name: "swatch",
    mount(container, ctx) {
        const box = document.createElement("div");
        box.style.width = "100%";
        box.style.height = "100%";
        box.style.backgroundSize = "contain";
        box.style.backgroundRepeat = "no-repeat";
        box.style.outline = ctx.options.outline ? "2px solid white" : "none";
        container.appendChild(box);

        // The manifest names the rest of the bundle, so the siblings cannot be
        // resolved until it has landed.
        const loaded = fetch(ctx.resolveSrc(ctx.src))
            .then((response) => response.json() as Promise<{colour: string; texture: string}>)
            .then((manifest) => {
                box.style.backgroundColor = manifest.colour;
                box.style.backgroundImage = `url(${ctx.resolveSibling(manifest.texture)})`;
            });

        return {
            ready: () => loaded,

            // A complete state, never a diff. The first one arrives before `ready()`
            // is called, so wait for the load rather than drawing into nothing.
            apply: (state) => loaded.then(() => {
                box.textContent = [state.motion, state.expression].filter(Boolean).join(" / ");
                box.style.opacity = String(state.params.alpha ?? 1);
            }),

            // One-shot things the state does not model.
            command(name, payload) {
                if (name === "flash") {
                    box.animate([{filter: "brightness(3)"}, {filter: "none"}], {duration: 200});
                } else {
                    ctx.warn(`swatch: unknown command "${name}"`, payload);
                }
            },

            resize(size) {
                box.style.fontSize = `${size.height / 20}px`;
            },

            dispose() {
                box.remove();
            },
        };
    },
};

export const game = new Game();
game.registerPuppetBackend(swatch);

Hand that game to the provider:

<GameProviders game={game}>
    <Player story={story} onReady={({liveGame}) => liveGame.newGame()} />
</GameProviders>

Registering under a name that is already taken replaces the previous backend.

Backend lifecycle

The engine calls a backend in this order:

  1. mount() returns the instance.
  2. apply() is called at once with the complete initial state, before ready() is called at all. The first pose arrives while the model is still loading.
  3. ready() is called once whatever apply() returned has settled. The element reaches "ready" when it resolves.
  4. apply(), command() and resize() follow for as long as the element is on stage. Any of them can arrive before ready() has resolved.
  5. dispose() ends it, at any point, loading included. The engine calls nothing on that instance afterwards, and the container is emptied.

Step 2 is fixed behaviour. Handle it in one of two ways:

  • Hold the state and re-apply it once the model is up.
  • Return a promise from apply() that waits for the load. That also holds ready() back until the pose has landed, so the element does not reach "ready" before it looks right. The example above does this.

Implement every member of PuppetInstance that is not marked optional. The engine checks ready and resize for existence before calling them, because a plain JavaScript host or a PuppetBackend cast into place can hand over an object that does not satisfy the contract. Those checks do not make the two optional.

Resolving files in a model bundle

A 2D character model is a manifest plus an atlas plus texture pages, or a model file plus motions plus physics plus textures. Which siblings exist is knowable only after parsing the first file, so the backend resolves them itself.

ctx.resolveSibling(path) resolves a path against the directory src sits in:

// src: "models/alice/alice.model.json"
ctx.resolveSibling("alice.atlas");            // -> "models/alice/alice.atlas"
ctx.resolveSibling("textures/page-0.png");    // -> "models/alice/textures/page-0.png"
ctx.resolveSibling("../shared/eyes.png");     // -> "models/shared/eyes.png"
ctx.resolveSibling("https://cdn/x.png");      // -> unchanged; absolute wins

Rules:

  • . and .. are folded away, clamping at the root instead of climbing out of it.
  • An already-absolute path (a scheme, a leading /, a protocol-relative //host/…, a data URI) comes back as it stands.
  • An empty path resolves to src itself.
  • \ is read as /, and the result always uses /.

The directory is the only structure the engine reads out of src, and only when resolveSibling asks for it. The engine does not know the format of src, its contents, or which files it pulls in. A backend whose src is an opaque key rather than a location has no directory to resolve against, gets the path back untouched, and reads its own options instead, which the engine also forwards verbatim.

Preloading

ctx.resolveSrc resolves a source by the same rules images use: a data URI comes back unchanged, and anything else is looked up in the preload cache before being handed back untouched. resolveSibling ends with the same pass, so a texture warmed with scene.preloadImage is served from the cache there too.

A puppet's own src is not registered for preloading. It is a model manifest, not an image, and the engine does not know which textures it pulls in. To warm a backend's textures, call scene.preloadImage for them. Everything not in the cache is a plain URL the backend fetches itself.

Creating a puppet

backend and src are required. Everything else has a default.

const alice = new Puppet({
    backend: "swatch",
    src: "models/alice/alice.model.json",
    size: {width: 900, height: 1200},   // omit for the stage size
    position: {xalign: 0.3},
    motion: "idle",
});

Place a puppet on the stage the same way as an Image: referencing it from a scene's actions is enough for the scene to initialise it.

size is the logical size of the box in pixels. The default, null, means the stage size. The backend scales its own content inside the box, and the element's transform (position, zoom, scale, rotation) applies on top of it, as it does to an image.

A puppet cannot change its src. The backend instance lives for exactly as long as the element is on stage. Use a second element instead.

A puppet has no transitions of its own in this release. show() and hide() fade the box with opacity.

Posing a puppet from the story

Six chainable actions, split along the line PuppetState draws:

scene.action([
    alice.show({duration: 400}),

    alice.setMotion("idle"),
    alice.setExpression("smile"),
    alice.setParam("ParamAngleX", 12),
    alice.setSlot("prop", "umbrella"),

    alice.command("playMotion", {id: "wave"}),                 // the story moves straight on
    alice.command("playMotion", {id: "bow"}, {await: true}),   // this one waits for it

    alice.setExpression(null),
    alice.hide({duration: 400}),
]);

The five set* actions each write one field of the persistent state and then hand the backend the whole state. A load restores it in one apply and an undo reverses it in one more, and nothing is replayed. params and slots merge key by key, so setting one parameter leaves the rest in place.

command sends a one-shot that the engine neither models nor interprets. It leaves nothing behind, so a load does not restore it and an undo does not take it back. Use it for a motion that plays once, a hit test, or lip sync.

Nothing waits unless it is asked to. {await: true} is opt-in on command, and the set* methods have no equivalent. A waiting command is skippable like any other timed action.

A backend that throws, rejects, or was never registered is logged rather than fatal: the state change still stands and is applied in full the next time the element mounts. A command aimed at a puppet that is not on stage warns and is dropped.

Puppet state

A puppet's persistent state is {motion, expression, skin, params, slots}, and it is everything a saved game carries about one.

apply receives a complete state, never a delta. Loading a saved game rebuilds the state from the save and applies it once, instead of replaying every pose change the model went through.

One-shot effects therefore go through command(), which is also where anything the state does not model belongs. motion, expression and skin are the three names every 2D character renderer has. Anything proprietary goes in params (free numbers), slots (free strings), or a command. None of these names belong to a particular renderer.

What null means

Every field is a request, and null is the absence of one, never "leave whatever is there". A state is applied whole, so a cleared field visibly clears, and a load or an undo reproduces what it recorded.

Fieldnull means
motionNothing is playing. The model rests at whatever it looks like with no motion applied: its setup / rest / bind pose, or the backend's own default where the format has no such thing.
expressionNo expression is applied; the face is whatever the motion and the skin make it. Clear the track rather than substituting a model's own named "neutral", so that null and "neutral" stay distinct.
skinThe model's default skin, the one it shows before anybody picks one.
slots[id]That slot is cleared, which is the same state as a key that is not there at all. The key survives because setSlot(id, null) merges over the existing map.
params[id]Does not arise: there is no null in params. A parameter the map does not mention keeps the model's own default, so clearing one means dropping the key.

A name the model does not have produces a ctx.warn, not a throw. Throwing out of apply() puts the whole element into "error".

Keys written by a newer engine survive a load untouched.

Public Methods

constructor

  • config: Partial<IPuppetUserConfig> & {backend: string; src: string}

Fields:

  • backend: string - The name a registered backend answers to. Required; a puppet without one throws at construction.
  • src: string - The resource descriptor handed to the backend, passed through verbatim. Required.
  • options: Record<string, unknown> - Backend-specific options, passed through verbatim. Defaults to {}.
  • size: PuppetSize | null - The logical size of the box in pixels. Defaults to null, meaning the stage size.
  • layer?: Layer - See Layer.
  • className?: string - Class names for the box. They land on the element carrying the box's position: relative and its width and height, which is the parent of the container handed to mount, not the container itself. The wrapper above it is where the transform is written, so a class that sets transform here is overwritten frame by frame.
  • motion: string | null - Initial motion. Part of the saved state, so it survives a save/load round trip.
  • expression: string | null - Initial expression.
  • skin: string | null - Initial skin.
  • params: Record<string, number> - Initial numeric parameters.
  • slots: Record<string, string | null> - Initial string slots.

Plus every transform property a displayable takes: position, scale, rotation, opacity, and so on.

const alice = new Puppet({
    backend: "swatch",
    src: "models/alice/alice.model.json",
    options: {outline: true},
    motion: "idle",
    params: {ParamAngleX: 12},
});

getStatus

What the backend drawing this puppet is currently doing.

Two values are worth acting on. "missing-backend" means nothing answers to config.backend, and "error" means the backend threw or the model failed to load. In both cases the element is still on stage, still transforming and still saving, and is not being drawn.

The status describes the live instance and is not part of the saved game: a load re-mounts, and the status starts over from "unmounted".

if (alice.getStatus() === "missing-backend") {
    // the renderer this project depends on was never registered
}

onStatusChange

Listen for this puppet's status changing, receiving the new status.

  • listener: (status: PuppetStatus) => void - Called with the new status
  • Returns LiveGameEventToken - dispose it to stop listening

A backend fails asynchronously: the element mounts, then the model does or does not load. Subscribe to find out whether the renderer came up.

const token = alice.onStatusChange((status) => {
    if (status === "error") console.warn("Alice is not being drawn");
});

useLayer

Override the layer used to render this puppet.

  • layer: Layer - See Layer
  • Returns this
puppet.useLayer(foreground);

Chainable Methods

setMotion

Request a named motion, usually the loop the model settles into.

  • motion: string | null - The motion to request, or null to clear it

This is persistent state, not a one-shot: it is saved, and re-applied in full the next time the model mounts. A motion meant to play once and end belongs in command. The story does not wait for the backend to take the pose.

alice.setMotion("idle");

setExpression

Request a named expression. Persistent state, like the motion.

  • expression: string | null - The expression to request, or null to clear it
alice.setExpression("smile");

setSkin

Request a named skin or costume. Persistent state, like the motion.

  • skin: string | null - The skin to request, or null to clear it
alice.setSkin("winter");

setParam

Set one numeric parameter, leaving every other parameter as it stands.

  • id: string - The parameter id
  • value: number - Its new value

What an id means is the backend's business: a rig parameter, a bone override, a blend weight. The engine remembers it, saves it, and hands the whole map back on a load.

alice.setParam("ParamAngleX", 12);

setSlot

Set one free string slot, leaving every other slot as it stands.

  • id: string - The slot id
  • value: string | null - Its new value; null clears that slot

Slots carry the named things motion / expression / skin do not cover: an attachment point, a swapped-in prop, or whatever a particular renderer calls its own.

alice.setSlot("prop", "umbrella");

command

Send the backend a command the engine neither models nor interprets.

  • name: string - Forwarded verbatim
  • payload?: unknown - Forwarded verbatim
  • options?: PuppetCommandOptions - See PuppetCommandOptions

Use it for what PuppetState leaves out: a motion that plays once and ends, a hit test, lip sync. None of it is saved, so a command is not restored by a load and not taken back by an undo. Anything that has to survive either belongs in the state, through the set* methods above.

The story does not wait unless it is asked to.

alice.command("playMotion", {id: "wave"});                  // the story moves straight on
alice.command("playMotion", {id: "bow"}, {await: true});    // this one waits for it

Inherited from Displayable

Everything a displayable offers works on a puppet unchanged, and applies to the box as a whole:

pos, scale, scaleX, scaleY, scaleXY, zoom, rotate, opacity, transform, show, hide, and the visual effects: mask, clip, wipe, filter, backdrop, blend and their counterparts.

The backend contract

These types import nothing from the rest of the library, so a renderer can be written against them on its own.

PuppetBackend

  • name: string - The key a puppet's backend config refers to.
  • mount(container: HTMLDivElement, ctx: PuppetMountContext): PuppetInstance - Create an instance bound to a host element. The engine owns the box, the backend owns what is inside it. The container is emptied when the instance is disposed.

PuppetMountContext

What the engine tells a backend when it mounts one.

  • src: string - The resource descriptor the puppet declared, verbatim. The only structure the engine reads out of it is the directory it sits in, and only through resolveSibling.
  • options: Readonly<Record<string, unknown>> - The author's options for this backend, verbatim.
  • size: PuppetSize - The logical size of the box, in pixels. Later changes arrive via resize.
  • resolveSrc(src: string): string - Resolve a source to a URL by the same rules images use. See Preloading.
  • resolveSibling(relativePath: string): string - Resolve a path relative to this puppet's own src, a sibling in the same bundle. See Resolving files in a model bundle.
  • warn(message: string, detail?: unknown): void - Report a non-fatal problem. The engine logs it and keeps the stage alive; it never throws.

PuppetInstance

One mounted model. The engine holds this handle and nothing else. For when each member is called, see Backend lifecycle.

  • ready(): Promise<void> - Resolves once the model is loaded and its first frame has been drawn.
  • apply(state: Readonly<PuppetState>): void | Promise<void> - Apply a complete state. Called once on mount, before ready(), then on every change.
  • command(name: string, payload: unknown): void | Promise<void> - Run a named command. The engine never interprets name or payload. Returning a promise lets a caller that passed {await: true} wait for it; nothing waits by default.
  • resize(size: PuppetSize): void - The box changed size.
  • describe?(): Promise<PuppetDescription> - Optional. Describe the model to an editor host.
  • dispose(): void - Tear the instance down. The container is emptied afterwards.

describe is not gated on status. It can be called any time between mount and dispose, before ready() has resolved, and more than once. A backend that can only describe a loaded model awaits its own load inside describe. Rejecting is also safe: the host logs it and falls back to letting the author type names.

PuppetState

See What null means for each field.

  • motion: string | null - The named action currently requested, usually the loop the model settles into.
  • expression: string | null - The named expression currently requested.
  • skin: string | null - The named skin / costume currently requested.
  • params: Record<string, number> - Free numeric parameters.
  • slots: Record<string, string | null> - Free string slots, for whatever the three names above do not cover.

PuppetCommandOptions

How the story treats a one-shot command.

  • await?: boolean - Wait for the backend to finish the command before the story moves on. Defaults to false.

A waiting command is skippable like any other timed action.

PuppetDescription

A model describing itself to an editor host, so the host can fill its inspector's dropdowns from the live instance. Backends that cannot answer do not implement describe, and the host falls back to letting the author type names.

  • motions: string[]
  • expressions: string[]
  • skins: string[]
  • params: {id: string; min: number; max: number; default: number}[]
  • size: PuppetSize | null - The model's own canvas size, or null when it does not report one.

PuppetSize

  • width: number
  • height: number

Logical pixels.

Missing or failing backends

A puppet with no registered backend keeps its place on the stage, its transform and its saved state, and draws nothing. The engine warns once per backend name, not once per element.

A backend that misbehaves is treated the same way. A mount that throws, a model that never loads, an apply that rejects: each is logged, and the stage stays alive.

To react to either case, read getStatus() for the live instance and subscribe with onStatusChange().

PuppetStatus

ValueMeaning
unmountedThe element is not on the stage, or its component has not mounted yet.
missing-backendThe element is on the stage, but nothing answers to its backend name. The box still takes part in transforms, layers and saves, and draws nothing.
loadingThe backend was mounted and its ready() has not resolved.
readyThe first frame has been drawn.
errorMounting, applying state, or loading threw. The stage stays alive regardless.

Game methods

  • game.registerPuppetBackend(backend: PuppetBackend): this - Register a backend. Returns the game, so calls chain.
  • game.getPuppetBackend(name: string): PuppetBackend | null - The backend registered under a name, or null.
  • game.listPuppetBackends(): string[] - The names of every registered backend, in registration order.

On this page