Guide

GuideDebugging & PerformanceAuthoring extensions

Authoring extensions

Package custom renderers, asset handlers, and node serializers as a distributable ExoJS extension — the descriptor model, per-application selection, and the package policy the official extensions follow.

Advanced~8 min read

Before you start

Authoring extensions

The previous chapter showed how to register a custom renderer against one running Application. An extension packages that same work — plus custom asset handlers and node serializers — into a single immutable descriptor you can publish as an npm package and drop into any project. This is exactly how the official @codexo/exojs-particles, @codexo/exojs-tiled, @codexo/exojs-tilemap, and @codexo/exojs-physics packages plug into the core.

The model is deliberately small and add-only: an extension can only contribute capabilities (a new drawable type’s renderer, a new asset type, a new serializable node). It never patches or replaces core behaviour. The core ships nothing extension-specific — an Application understands your drawable or asset type only once its extension is active.

Extension anatomy

An Extension is a plain, immutable descriptor. It holds no Application, backend, GPU, or loader instances — only bindings that describe how to build those things later. The registry reads it exactly once, at Application construction, and never touches it on a hot path.

import type { Extension } from '@codexo/exojs/extensions';

const myExtension: Extension = {
    id: 'com.example.confetti', // globally unique; reverse-DNS or the npm package name
    dependencies: [], // other Extensions this one needs materialised first
    renderers: [], // RendererBinding[] — drawable type → renderer factory
    assets: [], // AssetType[] — installed on this application, and on no other
    serializers: [], // SerializerBinding[] — node type ↔ serialize/deserialize pair
    install(app) {
        /* anything the arrays cannot express; return a disposer to undo it */
    },
};

Every field except id is optional, so the smallest possible extension is just an id. The three binding arrays are the interesting part:

  • renderers — a RendererBinding maps one or more Drawable constructors to a renderer factory. This is the typesafe custom-renderer path (below).
  • assets — an AssetType carries a stable id, the file suffixes it claims, how its data is read, and the factory that builds its resource. This is how @codexo/exojs-tiled teaches the Loader about .tmj files. Importing the package installs nothing; listing the type here does.
  • serializers — a SerializerBinding maps a custom SceneNode type to a NodeSerializer, so your node type survives Scene.serialize/deserialize.

Bindings are pure descriptors — no active renderer, no GPU resources, no side effects until their create(...) is called once per backend/loader during Application construction. install is the escape hatch for the rest — see Lifetime below.

Equipping an application

An extension does nothing until an Application is given it. There is exactly one way to do that — ApplicationOptions.extensions:

import { Application } from '@codexo/exojs';
import type { Extension } from '@codexo/exojs/extensions';

const confettiExtension: Extension = { id: 'com.example.confetti' };

const app = new Application({ extensions: [confettiExtension] });

Omitting extensions, or passing [], gives you a core-only application.

This is deliberately the only path. What an application can do is decided where it is constructed, not by which modules happened to get imported somewhere in the program — so two applications in one process can hold different sets. An editor beside its runtime preview, two canvases with different renderers, a test that must not see what a neighbouring test installed: all of those need the selection to be local, and a process-wide catalogue cannot give it.

Snapshot semantics

The selection is flattened once per Application, at construction, into an immutable snapshot:

  • Dependency ordering — descriptors listed in dependencies are materialised before their dependents (stable depth-first, post-order). A binding can safely assume its dependencies’ bindings already exist.
  • De-duplication — the same descriptor object reached by several paths (a diamond dependency) is materialised once.
  • Loud failures — the same id provided by two different objects, or a dependency cycle, throws with a descriptive path rather than silently winning.
  • Immutability — in development builds every visited descriptor and its nested binding arrays are frozen, so an accidental post-construction mutation fails fast.

Lifetime: install and dispose

The binding arrays cover contributions the engine knows how to materialise for you. install(app) covers the rest: an app-level System on app.systems, a subscription on an application signal, a debug overlay next to the canvas, a worker, an observer. It runs once per Application, as the final construction step — every core system and every materialised binding already exists — and dependencies listed in dependencies are installed before their dependents.

Return a function to undo it. The application holds those disposers and runs them in reverse installation order when it goes down:

import { Application } from '@codexo/exojs';
import type { Extension } from '@codexo/exojs/extensions';

const frameCounterExtension: Extension = {
    id: 'com.example.frame-counter',
    install(app) {
        const readout = document.createElement('output');

        document.body.append(readout);

        const onFrame = (): void => {
            readout.value = `${app.frameCount} frames`;
        };

        app.onFrame.add(onFrame);

        // Everything the install did, undone — nothing else.
        return () => {
            app.onFrame.remove(onFrame);
            readout.remove();
        };
    },
};

// `install` runs here; its disposer runs inside `app.destroy()`.
const app = new Application({ extensions: [frameCounterExtension] });

Four rules follow from where in the lifecycle those two calls sit:

  • Per-application state lives in the closure, never on the descriptor. The descriptor is a shared, frozen singleton — the same object equips as many applications as you hand it to, and each gets its own install call and its own disposer.
  • An extension’s lifetime is its application’s lifetime. There is no unregister and no scene-level scope: extensions equip an application, not a scene. A contribution that should come and go with a scene belongs on scene.systems or in the scene’s own track scope.
  • Disposers are synchronous. Application.destroy() does not await them. Asynchronous work that must be stopped belongs behind an AbortSignal your extension owns and aborts from the disposer.
  • A throwing disposer is contained. It is reported through the log and the remaining disposers — and the rest of the engine’s teardown — still run.

Typesafe renderer registration

A RendererBinding is { targets, create }: the drawable constructors it renders, and a factory that receives the live RenderBackend and returns a matching renderer — or undefined when the backend is unsupported (the binding is then skipped for that backend). Write it with defineRendererBinding, which infers the drawable union from targets, so a renderer that does not handle every target is rejected where you write it rather than at draw time. Branch on backend.backendType to build the right per-backend renderer; the satisfies never exhaustiveness check makes an unhandled backend a compile error, not a runtime surprise:

import type { Extension, RendererBinding } from '@codexo/exojs/extensions';
import { defineRendererBinding, RenderBackendType } from '@codexo/exojs/renderer-sdk';

// `Confetti` is your own Drawable subtype; `WebGl2ConfettiRenderer` /
// `WebGpuConfettiRenderer` extend the ABSTRACT SDK base renderers.
const confettiRenderer: RendererBinding = defineRendererBinding([Confetti], backend => {
    if (backend.backendType === RenderBackendType.WebGl2) {
        return new WebGl2ConfettiRenderer();
    }
    if (backend.backendType === RenderBackendType.WebGpu) {
        return new WebGpuConfettiRenderer();
    }
    throw new Error(`Unsupported render backend: ${String(backend.backendType satisfies never)}`);
});

export const confettiExtension: Extension = {
    id: 'com.example.confetti',
    renderers: [confettiRenderer],
};

A renderer implements the Renderer contract — connect(backend), disconnect(), render(drawable), flush() — mapping to GPU resource acquisition, release, per-drawable recording, and batch submission.

A serializer binding follows the same shape — a descriptor plus a factory. An asset type is a class instead: its option type flows from the class to every call site, so nothing has to be repeated. See the Loader reference for the factory lifecycle and the serialization chapter for registering a serializer for a custom node type.

Packaging policy

Study how @codexo/exojs-particles is set up — the official packages all follow the same shape.

One entry point, no side effects. The package root exports the descriptor and the public API and does nothing else — importing it must not change any application’s behaviour. There is no registration module, because there is nothing global to register into; the consumer decides by passing the descriptor to the application that should have it.

// src/index.ts — exports only, no side effects
export { confettiExtension } from './confettiExtension';
export * from './public';

package.json. One entry under exports, sideEffects: false (the package has none, so a bundler may drop anything the consumer does not reach), and the core as a peer dependency — never a regular one, or a project could end up with two copies of @codexo/exojs:

{
    "name": "@codexo/exojs-confetti",
    "type": "module",
    "sideEffects": false,
    "exports": {
        ".": {
            "types": "./dist/esm/index.d.ts",
            "import": "./dist/esm/index.js"
        },
        "./package.json": "./package.json"
    },
    "files": ["dist/esm/", "README.md", "LICENSE"],
    "peerDependencies": {
        "@codexo/exojs": "0.15.x"
    },
    "devDependencies": {
        "@codexo/exojs": "workspace:*"
    }
}

Import the SDK, not internals. Author against the three public entry points only: @codexo/exojs (core runtime types), @codexo/exojs/extensions (the Extension and binding types), and @codexo/exojs/renderer-sdk (the abstract base renderers, RenderBackend, and RenderBackendType). Anything reached by a deep path is internal and unstable.

Versioning pre-1.0

ExoJS is pre-1.0, so every minor release is a clean break — no back-compat shims. Extensions track the core in lockstep:

  • Pin the peer range to the core’s current minor ("@codexo/exojs": "0.15.x"), and publish a matching minor of your extension for each core minor.
  • Publish your extension version to move in step with the core version it targets; document the compatibility in a small table in your README (0.15.x ↔ 0.15.x), as the official packages do.
  • Because the id-collision check is by descriptor identity, a mismatched-version duplicate install throws loudly at registration rather than failing subtly at draw time.

A tiny extension end to end

Putting the pieces together, the smallest useful extension is a descriptor plus one binding, handed to an Application:

import { Application } from '@codexo/exojs';
import type { Extension } from '@codexo/exojs/extensions';

// 1. Define one immutable, module-level descriptor.
export const confettiExtension: Extension = {
    id: 'com.example.confetti',
    // renderers: [confettiRenderer]  // ← the RendererBinding shown above
};

// 2. Equip the application that should have it.
const app = new Application({ extensions: [confettiExtension] });

From here, add real bindings: subclass an abstract SDK renderer for your Drawable type (see Custom renderers), wire it into a RendererBinding, keep the package root side-effect-free, and pin the peer range. The result installs and activates exactly like the official extensions.

Where to go next

  • Custom renderers: the previous chapter covers the renderer contract and the abstract SDK base classes your RendererBinding builds on.
  • Reference extensions: read the Particles and Tiled chapters and their package sources for a renderer-contributing and an asset-contributing extension respectively.
  • Serialization: Serialization & prefabs shows the serializer side of the binding model.