NarraLeaf

Write your own

The module contract a drawing runtime has to satisfy, and how Studio finds, validates and packages it.

Studio's model characters are not tied to Live2D or Spine. Any renderer that can draw into a container can serve as a runtime — an existing renderer, a particle system, a sprite-sheet player, a WebGL scene. Installed as a Custom runtime, it behaves like the two named products in every other respect.

What Studio requires of a module

One folder under runtimes/puppet/, containing an index.js that is a browser-loadable ES module and yields an object satisfying the engine's PuppetBackend:

runtimes/puppet/my-renderer/index.js
export default function createPuppetBackends({ game, resolveFile, log }) {
    log("info", "my-renderer registered");
    return {
        name: "my-renderer",
        mount(container, ctx) {
            // Draw inside `container`. Return the instance the engine drives.
            return {
                ready: () => loadedPromise,
                apply: (state) => { /* a COMPLETE state, never a diff */ },
                command: (name, payload) => { /* one-shot things */ },
                describe: () => ({ motions: [], expressions: [], skins: [], params: [] }),
                resize: (size) => { /* the box changed */ },
                dispose: () => { /* container is emptied by Studio */ },
            };
        },
    };
}

Only name and mount are required. name must be non-empty, and it is what a character refers to — see the note on naming below.

The engine's Puppet page is the full contract: what each method receives, when it is called, why apply gets a whole state rather than a change, and why a saved game is restored with a single apply rather than a replay.

Accepted export shapes

Studio accepts several export shapes, so that a module can export other things as well:

ExportTreated as
export default function (ctx)A factory. Called with the host context; may return a backend, an array of them, or a promise.
export default backendThe backend object itself.
export default [a, b]Several backends from one module.
export const createPuppetBackends / puppetBackends / puppetBackendAny of the above under a name, leaving the default slot free.

A factory that returns nothing is not an error.

The host context

The factory receives:

FieldIs
gameThe Game these backends are being registered into.
resolveFile(path)A URL for a file inside the module's own folder, for a runtime with siblings of its own — a wasm core, a shader, a lookup table. Access is confined to that folder.
log(level, message)Reports to the host console, prefixed with the module's name.

resolveFile resolves files next to the module; ctx.resolveSibling on the mount context resolves files next to the model. Both exist because a runtime and the models it draws are separate bundles.

Install

Use Project → Runtimes → Custom runtime, enter a name, then point Studio at a single bundled index.js or at a folder to copy whole.

Studio then loads the module exactly as the game will and confirms that it produces a backend. A module that registers nothing is rejected and the copy is rolled back.

The folder is named after the backend the module registers, not after the name entered at install time. The engine resolves a character's runtime by the registered name while the editor lists folders, and a mismatch leaves the character undrawn on stage. Studio renames the folder to agree and reports the name it used.

What happens at build time

Every folder under runtimes/puppet/ that has an index.js is copied into the build output, and the game loads it before the first scene mounts. A folder without an index.js is skipped with a warning rather than failing the build.

A puppet whose backend never arrives does not crash the game. The engine keeps the box — its position, its transform, its saved state — and draws nothing inside it.

Practical notes

  • Bundle to one file where possible. A single self-contained ES module raises no resolution questions. Use a folder only when the runtime genuinely loads siblings at runtime.
  • A module is loaded once per game, so an implementation never has to reason about which game it is drawing for.
  • Implement describe() where possible. It turns Studio's motion and expression fields from text boxes into lists, and it draws the preview in the character editor. It is optional, and the editor works without it.
  • dispose() need not empty the container. Studio does that, including when the mount threw.
  • Options are interpreted by the runtime. Studio passes the character's option map through without reading a key of it. Use it for anything the runtime needs that is not one of the engine's three state channels.

On this page