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.
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:
mount()returns the instance.apply()is called at once with the complete initial state, beforeready()is called at all. The first pose arrives while the model is still loading.ready()is called once whateverapply()returned has settled. The element reaches"ready"when it resolves.apply(),command()andresize()follow for as long as the element is on stage. Any of them can arrive beforeready()has resolved.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 holdsready()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 winsRules:
.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
srcitself. \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.
| Field | null means |
|---|---|
motion | Nothing 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. |
expression | No 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. |
skin | The 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 tonull, meaning the stage size.layer?: Layer- See Layer.className?: string- Class names for the box. They land on the element carrying the box'sposition: relativeand its width and height, which is the parent of the container handed tomount, not the container itself. The wrapper above it is where the transform is written, so a class that setstransformhere 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.
- Returns
PuppetStatus- See PuppetStatus
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, ornullto 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, ornullto clear it
alice.setExpression("smile");setSkin
Request a named skin or costume. Persistent state, like the motion.
skin: string | null- The skin to request, ornullto clear it
alice.setSkin("winter");setParam
Set one numeric parameter, leaving every other parameter as it stands.
id: string- The parameter idvalue: 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 idvalue: string | null- Its new value;nullclears 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 verbatimpayload?: unknown- Forwarded verbatimoptions?: 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 itInherited 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'sbackendconfig 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 throughresolveSibling.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 viaresize.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 ownsrc, 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, beforeready(), then on every change.command(name: string, payload: unknown): void | Promise<void>- Run a named command. The engine never interpretsnameorpayload. 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 tofalse.
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, ornullwhen it does not report one.
PuppetSize
width: numberheight: 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
| Value | Meaning |
|---|---|
unmounted | The element is not on the stage, or its component has not mounted yet. |
missing-backend | The 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. |
loading | The backend was mounted and its ready() has not resolved. |
ready | The first frame has been drawn. |
error | Mounting, 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, ornull.game.listPuppetBackends(): string[]- The names of every registered backend, in registration order.