Module SDK
Modules plug playable content into theprototype.app: instruments, game
prototypes, generators, custom nodes and tools. A module registers itself
through one register(api) call and everything it does is visible to every
connected peer.
Two ways to ship one:
| Core (in-repo) | User (zip / URL) | |
|---|---|---|
| Lives in | src/modules/<id>/ + listed in src/modules/index.js |
installed via the Modules manager |
| Imports | anything the repo can import (three, stores, .svelte components) |
none — the entry must be self-contained; use api.THREE, api.assetUrl |
| Custom node UIs | own .svelte components |
generic param-driven nodes only |
| Distribution | git | .zip or a URL serving the package layout |
Start by downloading a core module from the manager ("Download as example") —
hello is the smallest complete one.
Giving your module a panel or settings? Build it from the app's own parts and design tokens so it matches every theme: see UI kit for module authors and the live kit page.
Writing a user module? The companion repo theprototype-app/modules carries the working end of this page:
- AUTHORING.md — one document covering the rules, the packaging contract and the test recipe, written to be read straight through or pasted whole into an AI assistant.
- modules/_template
— a working scaffold (
npm run new -- my-module), alongside example modules from a keypad-and-door to a first-person walk mode, each with a Playwright test-flight that installs the real zip through the real manager. - DEVX-REQUESTS.md — gaps external modules hit that core modules do not, and what has shipped for them.
The one rule that matters
A module runs on every peer. There is no game server. Whatever your module does must end up identical on all clients, one of three ways:
- Deterministic from shared inputs — effects driven by node
data(already replicated) and the synced clock (api.now()). Prefer this. - Broadcast events — a discrete thing happened ("button pressed",
"generate with seed 42").
api.send({...})a small message, apply the same change locally and inapi.onMessage. Never re-broadcast from a receiver. - State sync for late joiners —
registerStateSynchands your current state to peers who connect mid-session, automatically.
Randomness must be seeded (send the seed, not the result). Math.random() in
anything replicated is a desync. Don't accumulate in effects
(rotation.y += ...) — compute from base and time.
register(api) reference
export default {
id: 'mymodule', // stable + unique; routes your messages
name: 'My Module',
version: '1.0.0', // peers toast when versions differ
description: 'One line shown on the manager card.',
register(api) { /* wire everything here */ }
};
Nodes and effects
api.registerNodeGroup(
{
group: 'Modules',
items: [{
type: 'wave', // globally unique node type
label: 'Wave',
defaults: { amplitude: 0.4 }, // seeds node.data, replicated
params: [ // auto-generated controls
{ key: 'amplitude', kind: 'range', min: 0, max: 1.5, step: 0.05 }
// or { key: 'axis', kind: 'select', options: ['x','y','z'] }
]
}]
},
{ wave: MyWaveNode } // optional custom Svelte components (core modules only)
);
api.registerEffect('wave', (object, base, data, time) => {
// base = {pos, rot, scale, visible} — restored before every frame.
// Runs when an edge connects your node to an Object Selector.
object.rotation.z = base.rot[2] + Math.sin(time * (data.speed ?? 2)) * (data.amplitude ?? 0.4);
});
Scene content and interaction
api.registerPrimitive('Flag',
(w, h) => new api.THREE.PlaneGeometry(+w || 2, +h || 1),
{ label: 'Flag', command: '/create Flag 2 1' }); // spawn button on your manager card
api.registerClickHandler((object) => { // desktop click + VR trigger, exact mesh hit
if (object.userData.myButton) { press(object); api.send({ op: 'press', uuid: object.uuid }); return true; }
return false; // false = normal selection continues
});
api.registerInteractiveGroup('mymodule-stage'); // click handlers only see the replicated
// objects root by default — register your scene-root group's NAME to make it clickable
// 1.17: which editor modes a handler runs in — 'edit' | 'interact' | 'play'. ABSENT means
// ['interact', 'play']: an Edit click selects your game piece like any object, and Interact
// (the I key) and Play press it. An editor TOOL passes { modes: ['edit'] }.
api.registerClickHandler(pressKey, { modes: ['interact', 'play'] });
// 1.17: a readable row for your scene-root content in the object list's Module content section
api.registerListedGroup('mymodule-stage', { label: 'My stage' });
api.registerFrameTask((time) => { /* every frame, synced seconds */ });
api.registerMenu('Open my panel', () => { /* button on your manager card */ });
registerPostEffect
The scene look stack is a registry too, so a module can add an effect a user can then add to the scene like any built-in one:
api.registerPostEffect('bloomier', {
label: 'Bloomier',
group: 'camera',
params: [{ name: 'amount', type: 'number', default: 0.5, min: 0, max: 2 }],
make: (params, ctx) => new SomeEffect({ intensity: params.amount })
});
Your kind is namespaced to your module, so two modules cannot collide. If your module is later disabled — or the scene is opened by someone who never installed it — the authored effect is kept in the stack and skipped, not deleted, and it starts working again the moment the module is back. That is deliberately the same behaviour in both cases: a peer without your module is in exactly the position of a user who turned it off.
registerPostBackend
For a whole compiler rather than a single effect — something that turns a shader description into an effect:
api.registerPostBackend('fancy', 'Fancy compiler', (spec, ctx) => {
// spec: { fragment, uniforms, readsDepth, blend } -> return an Effect
return buildEffect(spec);
});
This is separate from registerShaderBackend on purpose: a shader backend returns a
material and a post backend returns an effect. An unknown key falls back to the
built-in compiler rather than failing, and the document keeps the key it asked for.
registerUnwrapBackend
The UV editor's Unwrap menu is a registry, so a module can add an unwrapping algorithm — or replace a built-in one under the same key — without the app shipping it:
api.registerUnwrapBackend('xatlas', 'xatlas (automatic)', async (faces, options) => {
// faces: triangles as plain data (positions, and the face they belong to)
// return { uvs, islands } — pure data; the app commits, replicates and undoes it
return { uvs, islands };
});
A backend is a pure function: it maps triangles to UV coordinates and returns
them. It never touches the scene, which is what lets the app treat your unwrap
exactly like a built-in one — one undo step, shared with peers, saved with the
scene. Backends may be async, so a heavy solver can do its work off the main
thread or load WebAssembly first.
WebAssembly works
A packaged module can ship a .wasm next to its code and load it with
WebAssembly.instantiateStreaming(fetch(api.assetUrl('lib/xatlas.wasm'))) —
assetUrl hands you a blob URL, so there is no network request and nothing to
allow-list.
Your key is namespaced per module, so two modules registering box never collide.
Audio devices
An instrument, an effect or a speaker is a device: an object carrying
userData.device = {kind, params} with a WebAudio subgraph the engine builds for it.
Your module supplies the kind; core supplies the object, replication, undo, saving,
the cables and the clock (see the Music Playground).
const kind = await api.registerAudioDevice({
kind: 'piano', // namespaced for you: 'mod-<moduleId>-piano'
label: 'Piano', icon: '🎹', group: 'Keys', // Add ▸ Devices: Keys
ports: { in: [], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
params: [
{ key: 'level', label: 'Level', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.8 },
{ key: 'wave', label: 'Wave', kind: 'select', default: 'sine',
options: [{ value: 'sine', label: 'Sine' }, { value: 'saw', label: 'Saw' }] }
],
build(ctx, node, params) { // ctx is the SHARED AudioContext — never make your own
const gain = ctx.createGain(); gain.gain.value = params.level;
return { input: null, output: gain, dispose: () => gain.disconnect() };
},
onParam(handle, key, value, node) { if (key === 'level') handle.output.gain.value = value; },
onNote(handle, { note, velocity, at }, node) { /* start a voice at `at` */ },
mesh: (THREE, spec) => new THREE.Mesh(...), // optional; a box otherwise
assets: (params) => params.sample ? [{ hash: params.sample }] : [] // what the scene manifest must carry
});
api.audio.addDevice(kind, { position: [0, 1, 0] });
The kind appears in the viewport's Add ▸ Devices menu (or Devices: ‹group›), the
params render in the Inspector's Device section and the Music toolbox, and every write
replicates and undoes. A peer without your module holds the same object as an inert
placeholder with its document intact, and the scene's module list names your module as a
requirement, so loading it offers to install you. Keep build pure and onParam cheap.
api.audio is the engine, the shared clock and the patch:
api.audio.context() // the shared AudioContext
api.audio.bus('instruments') // a named bus: music / sfx / voice / instruments
api.audio.voice({ buffer }) // an engine voice {output, start(at), stop(at), dispose()}
api.audio.sample(hash, { timeoutMs }) // a decoded AudioBuffer for an Explorer content hash
api.audio.timeFor(wallMs) // audio-clock time of a replicated `at` stamp
api.audio.transport() // {bpm, beat, bar, step, phase, playing, loopBeats, swing}
api.audio.play(true); api.audio.setBpm(124); // replicated
api.audio.schedule(beat, ({ beat, at, bpm, bar }) => { /* start voices at `at` */ }, { every: 4 });
api.audio.addDevice(kind, { position, params, name });
api.audio.device(uuid) // {kind, params} or null
api.audio.setParams(uuid, params, { before }); // replicated, one undo step
api.audio.previewParams(uuid, params); // live gesture: replicated, NO history
api.audio.note(uuid, { note, velocity, at });
api.audio.cable({ from: { uuid, port }, to: { uuid, port } }); api.audio.uncable(id);
api.audio.captureMic() // the RAW mic, separate from voice chat
api.audio.record({ maxSeconds, name, stream }); api.audio.stopRecording();
Live previews. A knob is a gesture: capture the document when it starts, stream
previewParams while it moves (throttle it — core sends every 66 ms), then commit once
with setParams(..., { before }). That is the only way a scrub becomes a single undo
step, and it is what the toolbox and the Inspector do themselves.
const before = api.audio.device(uuid); // gesture starts
api.audio.previewParams(uuid, { level: v }); // while it moves
api.audio.setParams(uuid, { level: v }, { before }); // release: one entry
Scheduling is deterministic. Everything on the transport runs on every peer from the
same document, so schedule's callback must be a pure function of its arguments — an
impure one desyncs silently.
registerDropHandler
Core has no placement for an audio or text item dropped on an object, so a module can claim the drop — a sample onto a pad, a script onto a console:
api.registerDropHandler((hit, item, target) => {
// hit: the exact mesh under the drop · item: {id, name, kind, hash} · target: the resolved object
if (item.kind !== 'audio' || !hit.userData.pad) return false;
api.audio.setParams(deviceOf(hit).uuid, { ['pad' + hit.userData.pad]: item.hash });
return true; // consumed
});
The first handler to return true owns the drop; when nobody does, the user is told the
item is used where it plugs in. Feed item.hash to api.audio.sample.
Content feeds
The Templates, Modules gallery and Packs feeds each read a base URL that a build can
redirect — VITE_SCENES_BASE, VITE_MODULES_BASE, VITE_PACKS_BASE — so content can be
tested against a fork before it is published. Unset, each is the public jsDelivr feed.
Content you add to api.objectsGroup() becomes part of the shared scene
(object list, GLTF sync to late joiners, movable/deletable by anyone). Derived
or regenerating content (a generated dungeon, a game board) belongs in your own
group on api.scene() — rebuild it from your module state instead, then it can
never drift; pair it with registerInteractiveGroup if it must be clickable.
Messages and state
api.onMessage((data) => { // {type:'module', moduleId, ...payload}
if (data.op === 'press') applyPress(data.uuid);
});
api.send({ op: 'press', uuid }); // broadcast to all peers
api.registerStateSync({
getState: () => ({ pressed: [...pressed] }), // late joiners receive this
applyState: (state) => state.pressed.forEach(markPressed)
});
The flow graph: api.flow
(Node positions, freeRegion, onChange, setNodesData and the undoable setNodeData are 1.15 additions.) For modules whose node needs its neighbours — a manager toolbox listing its instances, a count node reading its
siblings, a recipe that builds a wired example. Reads are deterministic because the graph is replicated; treat them like
replicated state.
api.flow.nodes('collectible') // [{id, type, graphId, x, y, data}] — graphId 'scene' or the owner's uuid;
// omit the type for every node. x/y are read-only
api.flow.edges() // [{id, source, target, sourceHandle, targetHandle, graphId}]
api.flow.nodeValue(id) // what the node's output carries this tick (undefined if none)
api.flow.triggerStamp(id) // {stamp, age} | null — a latch-style read, round-aware
api.flow.freeRegion({ w: 600, h: 300, graphId: 'scene' })
// {x, y, w, h}: where a block of new nodes fits without covering any
api.flow.addNodes({ graphId: 'scene',
nodes: [{ type: 'onclick', x, y }, { type: 'counter', x: x + 220, y }],
edges: [{ from: 0, to: 1, handle: 'pulse' }] }) // from/to: indices into nodes, or existing ids
// → the new ids. Replicated, and ONE undo step for the batch
api.flow.setNodeData(id, { step: 2 }) // replicated merge, one undo step → found?
api.flow.setNodesData([{ id, patch }, …]) // many writes, ONE undo step → how many were written
const off = api.flow.onChange(() => refreshToolbox())
// after any graph change or node firing, coalesced to once a frame;
// torn down with the module, or call off()
api.flow.seedGraph({ key: 'example', nodes: [/* … */], edges: [] })
// 1.23: addNodes, but only ONCE — nothing is added while any node
// carries this seed or is one of your module's node types
api.game.onChange(fn) and api.peerVars.onChange(fn) work the same way for the game state (state, round, variables)
and for any peer's row: coalesced to one call per frame, returning off, released with the module.
api.editorMode() (1.17) returns 'edit' or 'interact' — this screen's click mode; api.isPlaying() says whether
Play is on top of it. A module with its own pointer listeners should stand down while it reads 'edit' and nothing is
playing, so an Edit click selects its content like any object.
Storage (1.17)
// JSON on THIS device, namespaced to your module (tp:mod:<id>:<key>), 256 KB per module.
// Never sent to other players, never saved into a scene; survives disable and remove.
if (api.storage) {
const p = api.storage.get('progress', { unlocked: 1 }); // fallback when nothing is saved
api.storage.set('progress', p); // true, or false over the quota
api.storage.keys(); api.storage.bytes(); api.storage.remove('progress');
api.storage.clear(); // your "Reset progress"
}
Feature-detect it. On an older app write the same key yourself
(localStorage['tp:mod:<id>:<key>'] = JSON.stringify(value)) and progress carries over.
A board, puzzle or instrument game can ask for the free cursor in Play (no pointer lock)
by publishing userData.play.cursor = 'free' on its scene-root group. api.pointerRay() is
then the cursor's ray; in a pointer-locked game it is the crosshair ray.
The game shell: pause menu, levels, per-game settings (1.18)
Every game gets ONE pause menu from core — Resume · Restart · Levels · Settings · How to
play · Main menu — on the desktop (Escape in Play, or the corner Menu button) and in VR
(the left controller's X, or Menu on the game board / wrist card). You do not draw it;
you feed it. A scene counts as a game when it has a state-bound HUD screen, a spawn, or a
module publishing userData.play — or when your module registers levels. All of it is
feature-detected (api.game.levels?.(…)), LOCAL, and torn down with your module.
Levels — the picker on the desktop and on the VR board. Re-call it whenever a level
unlocks or earns stars; onPick never hears a locked level.
const refresh = () =>
api.game.levels?.({
list: LEVELS.map((l, i) => ({ id: l.id, label: 'Level ' + (i + 1), locked: i > unlocked, stars: best[l.id] ?? 0 })),
current: currentLevel.id,
onPick: (id) => loadLevel(id)
});
refresh();
onLevelWon(() => { unlocked++; refresh(); });
Your own settings rows — shown under the core rows, persisted per game on this device
(tp:game:<game>:<id>). type is 'toggle', 'choice' (options, optional optionLabels)
or 'range' (min, max, step). A core id is refused.
api.game.addSetting?.({
id: 'board', label: 'Board', type: 'choice',
options: ['globe', '2d'], optionLabels: ['Globe', '2D board'], default: 'globe',
onChange: (v) => setBoard(v)
});
setBoard(api.game.setting?.('board') ?? 'globe'); // the stored choice at load
A choice row with onLevels: true (1.19) is ALSO drawn as tabs above the Levels page in the
headset — Untangle's Globe / 2D board.
api.game.setSetting?.('board', '2d'); // your in-game button writes the same row
api.game.onSettingsChange?.((values) => console.log(values.board, values.sfx));
The core rows every game has — music, musicVolume (0..100), sfx, sfxVolume,
haptics, showFps, turning (default | snap | smooth | off), turnAngle
(default | 15 | 30 | 45 | 90), vignette, quality (auto | low | medium |
high). Core obeys them itself: the per-game volumes land on the audio BUSES (so
api.playSound, api.music, flow sound nodes and the scene's track all follow), haptics,
VR turning, the comfort vignette and the quality governor read them live. A module that
plays audio through its OWN WebAudio graph should read them:
const gain = ctx.createGain();
const apply = () => (gain.gain.value = api.game.setting?.('sfx') === false ? 0 : (api.game.setting?.('sfxVolume') ?? 100) / 100);
apply();
api.game.onSettingsChange?.(apply);
How to play — your words first, then the device's controls (keyboard or controllers). Without it the menu shows the game's Games-tab description.
api.game.setHelp?.([
'Drag the dots until no two lines cross.',
'Finish a level to unlock the next — stars for fewer moves.'
]);
Restart — the menu resets the game shell (host / alone; others carry on as it is), respawns the player and runs your hook:
api.game.onRestart?.(() => { resetBoard(); spawnWave(1); });
Your own Menu button (a module HUD, a VR board of yours):
api.game.openMenu?.(); // only while playing a game; false otherwise
api.game.closeMenu?.();
if (api.game.menuOpen?.()) pauseMyTimers();
Utilities
api.scene() // THREE.Scene
api.objectsGroup() // the replicated objects root
api.peerId() // our peer id (undefined before the mesh is up)
api.toast('hi') // corner toast
api.now() // the runtime clock in SECONDS — stamp replicated
// timestamps with this, never Date.now() directly
api.THREE // the app's three.js (user modules can't import it)
api.assetUrl('assets/pling.mp3') // blob URL of a packaged file (user modules)
api.keyOf(event) // 'G', 'Ctrl'-less key name, layout-independent — the app's own rule:
// the printed ASCII letter when there is one, else the physical key
api.letterOf(event) // the same for a bare letter, or null — use it in your own keydown
// handlers so a Cyrillic or Greek layout reaches your shortcuts too
Building in the shared scene
// create through the SAME replicated path a user's /create takes, and get
// back what appeared — so you can place it, physics it, or joint it
const [uuid] = await api.create('/create Box 1 1 1');
await api.create('/create Box 0.6 0.6 0.6', { at: [x, y, z] }); // placed too
api.moveObject(uuid, { pos: [0, 1, 0], rot: [0, 0, 0], scale: [1, 1, 1] });
api.physics.set(uuid, { mode: 'dynamic', mass: 30, friction: 0.3 });
api.physics.createJoint('revolute', bodyUuid, wheelUuid, 'x', { vel: 0, maxForce: 120 });
api.physics.running(); // a simulation runs somewhere in the session
api.isPlaying(); // Play mode is active
api.peerIds(); // connected peer ids — free state a departed peer left
api.flyTo([x, y, z], [lx, ly, lz]); // LOCAL camera move (never replicated)
api.playSound('pluck', [x, y, z]); // LOCAL spatial chime
api.playSound('coin', [x, y, z], { caption: 'Coin collected' }); // 1.41: a caption for players who have captions on
api.captions.show('A voice: "Over here!"', { seconds: 4 }); // 1.41: a caption line, LOCAL
api.captions.shown(); // 1.41: does this player have captions on?
api.followCam(uuid); api.stopFollowCam(); // LOCAL chase camera
Everything on the first block replicates; everything on the last block is per-viewer and deliberately local — a peer's module must never yank your camera.
VR and control
api.isVR() // true inside a VR session
api.haptic(0.6, 60) // buzz the VR controllers — no-op on desktop
api.haptic(0.6, 60, 'right') // one hand only
api.vrHand('left') // {position:[x,y,z], quaternion:[x,y,z,w],
// trigger, gripped, connected} — null when
// untracked / not in VR; poll from a frame task
api.fireObjectClick(uuid) // pulse On Click flow nodes targeting the object
// (replicated) — user graphs react to your events
api.possess(uuid, { camera: 'first', eyeHeight: 1.7, mouseLook: true });
api.possessModes // e.g. ['chase','orbit','none','first'] —
// feature-detect 'first' here; unknown camera
// values degrade silently on older builds
WASD / arrows, a gamepad's left stick or the VR left stick drive the object (tank controls);
Space or gamepad A hops it jumpHeight metres (default 1.2, 0 = no jump, since 1.54.3).
With camera: 'first' the eye sits at the object plus eyeHeight;
mouseLook: true requests pointer lock — horizontal look turns the object
(movement follows the view), vertical pitches the camera, and leaving pointer
lock (Esc) releases the possession.
The knock
When the knock is on for the scene, every hit a hand or a player lands on a physics body is reported to every peer. A module can listen to that feed, or read the recent ones — which is what the football module's last-touch rule stands on.
const off = api.onHit((hit) => { /* … */ }); // returns the unsubscribe
off(); // …or let the teardown do it
api.hitLog() // { last: {uuid: hit, …}, recent: [hit, …] }
One hit looks like this:
| Field | |
|---|---|
uuid |
the object that was hit |
by |
the peer whose hand or camera hit it (empty when you are alone) |
local |
true on the peer it was — the one whose probe landed the hit |
speed |
how hard, in m/s |
point |
[x, y, z], where it was hit |
linvel / angvel |
the velocity and spin the hit gave it |
at |
the session timestamp — the same number on every peer |
probe |
which probe it was: 'left', 'right' or 'head' (the desktop camera) |
onHit fires for your own hits and for every peer's, as each hit message is
applied, so a rule derived from it agrees everywhere without a message of your own.
It returns an unsubscribe, and is torn down with the module either way.
hitLog() hands back a copy: last is the most recent hit per object still in
the scene, keyed by uuid, and recent is the last 32 hits in order. It is runtime
state — a late joiner's log starts empty, so a module that needs history keeps its
own through registerStateSync.
For module authors: joints and your own physics world
New in 1.27 — what the Drivable Car uses to steer with its front wheels, and
Blocks to run a pile of its own. Feature-detect each (api.physics.rapier?.()) to stay compatible
with older apps.
// a hinge that only turns ±0.55 rad, whose two objects don't collide with each other,
// driven to an ANGLE instead of a speed, and that breaks quietly
const knuckle = api.physics.createJoint('revolute', bodyUuid, wheelUuid, 'y',
{ pos: 0, stiffness: 400, damping: 40 },
{ limits: [-0.55, 0.55], contacts: false, sparks: false });
// mid-run, on the peer running the simulation: steer
api.physics.setJointMotorPosition(knuckle.id, 0.3);
// the physics engine itself, for a world of your own
const RAPIER = await api.physics.rapier();
| Call | What it does |
|---|---|
createJoint(kind, a, b, axis, motor, opts) |
motor may be {vel, maxForce} (a speed, as before) or, since 1.27, {pos, stiffness, damping} — an angle in radians the hinge is driven to (stiffness 400 and damping 40 by default). opts: limits: [min, max] in radians (hinges only), contacts: false so the two objects pass through each other, sparks: false so a break during a run throws no sparks |
setJointMotorPosition(jointId, angle, stiffness?, damping?) |
drives a hinge to angle (radians) mid-run. Like setJointMotor, it works only on the peer running the simulation and returns false elsewhere — forward your input to that peer |
rapier() |
a promise of the Rapier module (rapier3d-compat, already initialised) the app runs. A world you build with it is yours and local — never synced, never stepped by the app; free it in api.onUnload |
Since 1.27 duplicating both objects of a joint copies the joint, and detaching one during a run breaks it — see Joints.
For cloud plugin authors: a What's New section
The hosted app's plugin can put a section at the top of the What's New window (see What's new on theprototype.app):
cloudApi.setWhatsNewSection({ title: 'theprototype.app cloud', markdown: '### Rooms\n- **Faster** joins' });
cloudApi.setWhatsNewSection(null); // removes it
The markdown is a safe subset: headings, bullets, bold, `code` and https links; anything else is shown as
text.
For cloud plugin authors: scene changes and whole projects
New in 1.29 — what a plugin needs to keep a scene or a project somewhere else (theprototype.app's cloud saves and
kept rooms use them). All are additive: feature-detect each one (typeof api.onSceneChange === 'function'), and
nothing listens until a plugin calls them, so an app without a plugin behaves exactly as before.
const rev = cloudApi.sceneRevision(); // a counter that moves on EVERY scene change, local or from a peer
const off = cloudApi.onSceneChange((rev) => { /* something changed — debounce, then save */ });
const sig = await cloudApi.sceneSignature(); // the CONTENT identity of the open scene (a short hash)
const { blob, meta } = await cloudApi.buildSceneBundle({ signature: true }); // meta.signature = the same hash
const project = await cloudApi.buildProjectBundle(); // the whole project as a .tp
await cloudApi.openRemoteProject({ url, title: 'My project' }); // asks before replacing the project
| Call | What it does |
|---|---|
sceneRevision() |
a number that moves on every change to the scene — objects, graphs, animation, looks, sky, physics, music, HUD, game state. Compare two reads to know whether anything changed in between. 0 before the app has booted |
onSceneChange(fn) |
calls fn(revision) after every change from now on (never for the current value); returns off |
sceneSignature() |
a short hash of what the scene is (no ids, timestamps, thumbnail or game state). The revision also moves on internal refreshes, so "did the scene really change since I saved it?" is answered by comparing two signatures |
buildSceneBundle({signature: true}) |
as before, plus meta.signature. Opt-in, because it is one more pass over the whole scene |
buildProjectBundle({versions?}) |
the Explorer's own Export project bytes as {blob, meta: {name, scenes, assets, items, bytes, appVersion}}; downloads and changes nothing |
openRemoteProject({url, title?}) |
fetches a .tp and opens it through the app's own path (format check, "This replaces your current project" confirm). View-only users are refused. Resolves the counts, or null when refused, declined or failed (the user saw why) |
For module authors: pointer, camera, play mode and VR seat
New in 1.26 — the hooks Race uses to drive on a phone and in a headset, open to every module. Each is torn
down with the module; feature-detect each one (api.vrSeat?.(…)) to stay compatible with older apps.
// hear a press, its drag and its release — desktop and touch (VR keeps its trigger hooks)
const off = api.registerPointerHandler(
{
down(hit, ctx) { return true; }, // true OWNS the gesture
move(hit, ctx) {},
up(hit, ctx) {}
},
{ modes: ['interact', 'play'] } // the default
);
api.onClickMiss(() => { /* a viewport click that hit nothing: drop a carried piece, disarm a tool */ });
const cam = api.camera(); // the camera the player looks through (the XR camera in a headset), read-only
api.onPlayMode((playing) => { /* … */ });
api.inGame(); // true in Play, or in a headset's game
api.vrSeat(carUuid, { seat: [0, 0.8, 0.3] }); // seat the VR player in an object
api.vrUnseat();
| Hook | What it does |
|---|---|
registerPointerHandler({down, move, up}, {modes}) |
hit is {object, point, uuid, distance} or null; ctx is {mode, ray, clientX, clientY, pointerType}. When down returns true the module owns the gesture: the camera stops orbiting, nothing is selected, and Play does not carry or tap |
onClickMiss(fn) |
a viewport click that hit nothing. It never consumes the click |
camera() |
the camera the player is looking through, read-only |
onPlayMode(fn) / inGame() |
true in Play, or in a headset's game (VR play is Interact with no pointer lock). api.isPlaying() alone reads false in a headset |
vrSeat(uuid, {seat}) / vrUnseat() |
seats the VR player in an object; seat is the object-local eye point (default [0, 0.8, 0.3]). The tracking space is carried with the object, and the sticks stay readable through api.input() |
input().touch |
the on-screen move stick, {x, y} from -1 to 1 (up is -y) |
input.actions(list, {preset: 'drive'}) |
the vehicle layout for touch controls: the stick steers, no look drag, pedals under the right thumb. A stick the module declared stays live under its own claimInput('keys') |
For module authors (1.24)
Since 1.24 the app draws a game's playing HUD in a headset itself — a curved band in front of the player, or the wrist card, whichever the player chose (The game HUD in a headset). A module that feeds the core HUD no longer needs a VR readout of its own.
api.hud.vrHud() tells you what the headset is doing:
const vr = api.hud.vrHud?.(); // feature-detect: undefined on an app older than 1.24
// vr = { placement: 'head' | 'world' | 'wrist', visible: true | false }
if (vr) {
// the app shows the HUD in the headset: drop your own VR-only readout
}
placement is the player's Game HUD setting (Follow head, Fixed in world, Wrist only); visible says whether the
band is up right now.
vrText for your own HUD element. An element kind you register with api.registerHudElement(kind, def) is DOM, and
the headset cannot show DOM. Give its def a vrText and the headset shows that line of text in its place:
api.registerHudElement('crossings', {
// …your mount, fields and defaults, as before…
vrText(element, runtime) {
return `${crossings} crossings`; // one line of text, from your module's own state
}
});
An element kind without vrText does not show in the headset. Untangle 2.4.1 does this for its clock and drops its old
floating VR sprite on 1.24.
Also in 1.24: VR game cards draw in one pass (they took two).
For module and game authors (1.23)
The games rebuilt in 1.23 keep their rules in a behaviour on the Main graph and leave only the engine in their module. Three things make that work, and every module can use them.
Behaviour sockets. A behaviour may declare sockets on its node:
export default behaviour({
name: 'Mini Golf rules',
state: { title: '', strokes: 0 },
inputs: ['teeOff'], // event sockets in
outputs: ['title', 'holeSunk'], // a state field, and an event
on: {
load() { kit.levels.define({ id: 'mini-golf', list: [/* … */] }); },
teeOff() { this.state.title = 'Hole 1'; },
'golf.stopped'(e) { /* … */ this.emit('holeSunk'); }
}
});
inputs: ['teeOff']are event sockets. A trigger wired into one runs the handler of the same name inon, on the authority, once per press — even if the press lands while the authority is moving to another player.outputsare sockets out. A name that is a state field is a value socket, typed by its initial value. Any other name is an event socket, fired bythis.emit('holeSunk'); it leaves after the state update, so every player's banner reads the new state.on: { load() {…} }runs once on every peer when the behaviour starts, read-only — for per-device setup such askit.levels.define(…)for the pause menu's level picker.
Engines: api.kit.provide(spec, impl). A module lends rules what they cannot be (a physics body, a
drag-to-aim arrow, a VR club) as a piece shaped like a kit piece:
const golf = api.kit.provide(
{ piece: 'golf', group: 'Mini golf (engine)', calls: [
{ name: 'hit', kind: 'action', label: 'Hit the ball' },
{ name: 'ballSpeed', kind: 'value', label: 'Ball speed' },
{ name: 'stopped', kind: 'event', label: 'On ball stopped' } ] },
{ hit(v) { /* … */ }, ballSpeed() { return speed; } }
);
golf.emit('stopped', { pos }); // every behaviour's on: { 'golf.stopped'(e) {…} } hears it
golf.listening('stopped'); // how many rules handle it (0 = none: your own default may decide)
golf.dispose(); // take the piece away (unloading the module does this too)
Rules call kit.golf.hit(…) and handle 'golf.stopped'; the live node view draws them like any kit
call. The handler runs on the authority, so emit on every peer that saw the moment (or on the one that
did). The piece is lifecycle-tracked: unloading the module removes it, and every behaviour that used it
reloads without it.
A wired example: api.flow.seedGraph. Like api.flow.addNodes, but only once: nothing is added
while any node carries the seed's key or is one of your module's node types (see
api.flow).
Also in 1.23: HUD Text draws its format socket (wire text into it), Announce takes a wired sub line,
and a Script may declare an output of type any (text).
New in 1.20
Unloading: everything you registered goes with you
A module can now be unloaded and loaded again while the app runs — switched off in the Modules
manager (core modules too, no reload any more), removed, updated, dev-reloaded, or left
behind by a scene switch. Everything you registered through api is recorded per module and
undone at unload, newest first: node groups, effects, handlers, frame tasks, menus, toolboxes,
key bindings, input claims, possess, VR panels, your music, game levels / settings / help /
restart hooks, LOD handles, listeners, backends, post effects, audio device kinds and voices,
HUD rows… Every off() the api hands you still works, and a call that replaces (api.game.levels
on every unlock, addSetting with the same id) replaces its entry rather than stacking.
What stays is what you put into the shared scene — api.create objects, objects in
objectsGroup, flow nodes you added, audio devices and cables, node data — because that is user
content now. So do shared game variables and what you saved with api.storage.
For what only your module knows about, four tools:
// your own teardown — a DOM overlay, a worker, a raw WebAudio graph; returns cancel()
api.onUnload(() => overlay.remove());
// timers that die with the module
const h = api.timers.setInterval(refresh, 500);
api.timers.clearInterval(h);
api.timers.setTimeout(fn, ms); api.timers.requestAnimationFrame(loop);
api.timers.pending(); // {timeouts, intervals, frames}
// a listener removed at unload — window, document, the canvas, anything; returns off()
const off = api.listen(window, 'keydown', onKey, true);
// a scene-root object that is YOURS: removed at unload, its GPU resources freed
api.scene().add(api.own(group)); // never for content inside objectsGroup
Installed (zip / URL) modules get this for free for the common cases: their bare
setTimeout / setInterval / requestAnimationFrame (and the clears) and the listeners they add
to window or document are tracked and stopped at unload. Two edges: window.setTimeout(...)
or a listener on any other target is not tracked — use api.timers / api.listen; and a module
that declares one of those timer names itself at top level loads without the tracking.
Keep state inside register()
The browser never unloads an imported file's top-level scope, so module-level variables,
caches and DOM survive an unload and the next load starts from them. State declared inside
register() is fresh every time; module-level state you must reset belongs in
api.onUnload.
Models: api.loadModel
Load a .glb / .gltf through the app's own loader instead of bundling one — the scene's
THREE, with Draco, Meshopt and KTX2 decoding (the KTX2 transcoder is fetched only for a file
that needs it), parsed once per URL and shared by every module that asks:
const enemy = await api.loadModel('assets/enemy.glb', { castShadow: false }); // packaged file or any URL
group.add(enemy.scene); // your own copy
const next = enemy.instance({ ownMaterials: true }); // another copy: own bones when skinned, own materials
group.add(next);
const mixer = new api.THREE.AnimationMixer(next);
mixer.clipAction(enemy.animations.find((c) => c.name === 'walk')).play();
enemy.info; // {meshes, triangles, materials, textures, skinned}
enemy.release(next); // forget one copy now
enemy.dispose(); // forget them all — unloading your module does this for you
Options, all optional:
| Option | |
|---|---|
lod |
absent / 'auto' = automatic levels (a pack item that ships lods uses those files); false = none; {ratios, distances, minTriangles} = tuned automatic levels; [{file, ratio}] = pre-built level files beside the model |
castShadow / receiveShadow |
for every mesh (absent = as the file says) |
collider |
'box' 'sphere' 'capsule' 'cylinder' 'cone' 'hull' — the physics shape a copy takes once it is scene content with physics |
ownMaterials |
every copy gets its own materials |
Everything a module loaded is released when it is switched off or unloaded (a copy you put in
objectsGroup stays — it is the scene's). A copy you drop without release() is noticed and
collected. A load still in flight at unload rejects, and so does a missing or unreadable file —
keep a fallback look. Feature-detect it (typeof api.loadModel === 'function'): 1.19 and older do
not have it.
The game kit: api.kit
The game kit — rules, round, levels, score, pickups, spawner, health, mover — is
the same for code as for the Kit: nodes. Call an action on any peer: the authority peer
applies it exactly once. Reads are replicated values; on<Event>(fn) fires on every peer and
returns off; registrations are torn down with your module.
const { round, levels, score, pickups, rules } = api.kit;
rules.set({ reach: 1.3, jump: 1.0 });
rules.onGrabRequest((req) => { if (req.name === 'Star' && !starFree) req.refuse('Build to the ring first'); });
levels.define({ id: 'mygame', list: [{ id: '1', label: 'Easy', par: { time: 60 } }, { id: '2', label: 'Hard' }] });
round.configure(3, 120, 'lose', 2); // 3 s intro, 2 min limit, lose on time, 2 s outro
round.onGo(() => api.announce('Go!'));
pickups.register({ id: gem.uuid, score: 10, respawn: 8, grants: { time: 5 } });
pickups.onCollected(({ by }) => api.playSound('coin'));
round.onWon(() => levels.complete(true, score.total()));
levels.select('1'); round.start();
| Piece | Actions | Reads | Events |
|---|---|---|---|
rules |
setReach(m) setJump(m) setBounds(min, max) clearRules() · set({reach, jump, bounds}) |
reach() jump() current() inside(p) clamp(p) checkGrab(req) |
refused (local) · onGrabRequest(fn) veto |
round |
configure(intro, limit, 'lose'\|'win', outro) start() restart() pause() resume() win(reason) lose(reason) extend(s) toMenu() |
phase() playing() elapsed() remaining() countdown() number() outcome() state() running() |
started go paused resumed won lost results menu |
levels |
select(id) next() complete(won, score, time, level?, detail?) setMode(m) · define({id, list, unlock?, stars?, store?, merge?}) |
current() currentLabel() index() starsOf(id) unlocked(id) totalStars() mode() table() progress() resumeLevel() |
selected completed unlockedNext |
score |
add(n, player?) set(n, player?) reset() · configure({autoReset}) useGame(id) |
total() mine() best() leader() of(id) leaderboard(n) results() |
scored newBest (local) |
pickups |
collect(id, score?, respawn?) resetPickups() · register({id, score, respawn, radius, grants}) |
available(id) taken() left() takenBy(id) |
collected respawned allCollected |
spawner |
spawn({kind, template, at, count, spread, hp, speed, removeAfter, tags, data, mover}) → ids · despawn(id) clear(kind) setTags setData |
count(kind) list(filter) get(id) |
spawned despawned emptied |
health |
damage(id, n) damageArea(at, r, n, kind) heal(id, n) revive(id) |
hp(id) max(id) fraction(id) alive(id) |
damaged healed died revived |
mover |
chase(kind, target) seek(id, target, {speed, reach}) arrive(…) patrol(id, path, loop) stop(id) halt(kind) knock(id, [vx, vy, vz]) setSpeed(id, s) |
state(id) |
stuck |
levelsfeeds the pause menu's level picker — do not also callapi.game.levels. Progress is per device;store: {get, set}keeps your own save key.scorecredits the asking peer unless a player is named — a shared pulse is asked by every peer, so name the player when it matters who.- A
chasetarget is an object uuid,'player','nearestPlayer','player:<id>'or an entity id. Health events carry{entity, amount?, by?, authority}— change game state only whereauthorityis true. - Entities you spawn leave with your module.
Behaviours
Game logic can also live in the scene as a behaviour: one small file in a
Behaviour node, run on the authority with replicated state, calling the same kit. Since 1.23 a
behaviour can have sockets and call a module's engine — see
For module and game authors. Each
behaviour is tracked like a module of its own, so deleting its node takes its listeners and
entities with it.
New in 1.37
api.quality.keepShadowsInVR({radius})→off()— your game budgets its shadow, so a headset keeps it: while the claim is held, the headset enters at full quality instead of the shadows-off floor, ONE directional light casts (the environment preset's rig sun stands down for your scene's own sun), and the shadow camera is fitted to a square ofradiusmetres (8–150, default 40) around the viewer. Adaptive quality still drops shadows if frames cannot hold. LOCAL; released when your module is disabled. Race holds it at 45 m. Feature-detect:api.quality.keepShadowsInVR?.({radius: 45}).
New in 1.18
api.quality— the quality governor's view of this machine:level,max,labels(the steps in force),vr, andonChange(fn)→off, called asfn(level, {max, labels, reason, vr})on every change. LOCAL: drop your own extras on a struggling device, never change shared state from it.api.vrPanel(group)— make a group (your VR menu, level bar, buttons) a VR panel: drawn over the scene so a floor or a wall never hides it, and a place the controller beam ends with its dot. Returns the undo (also run when your module is disabled). Feature-detect:api.vrPanel?.(group).api.locomotion—{boundedTeleport: true, worldGrab: true}on a core that understands the two scene-data fields below; an older core has no object.api.lod(object, opts)— automatic levels of detail for meshes you build yourself:opts={ratios, distances, minTriangles}(ratios default[0.5, 0.25, 0.1]of the triangles; distances in world radii of the mesh, default[8, 20, 50]). Returns{meshes, ready, remove}; local, never replicated, released with the module. Skinned and morphing meshes are skipped;mesh.userData.lod = falsekeeps one out.
Scene data a game can publish on its scene group (userData.play), alongside the existing interaction, grounded
and simOnPlay:
| Field | Meaning |
|---|---|
reach |
grab reach in metres from the player's body (absent = no limit) — the Limit grab reach row |
locomotion.worldGrab |
true gives VR grips Edit's world gestures (move, turn, scale the scene) in Interact and Play |
locomotion.teleport + play.bounds {min, max} |
teleport in Interact/Play lands only on walkable ground inside play.bounds (else the content's box), never through a wall |
New in 1.19
api.inScene()— false once a scene switch LEFT your module behind (the person chose Keep when opening a scene that does not use you). Core already keeps your levels, help, settings rows, Restart, music and spawn out of the new game; stand your own drawing and listening down while it reads false (a gun in the hand, a HUD of your own).api.behavior—list()(the functional pack items in the scene: doors, lids, levers, with their type and whether they are open),state(uuid)andtrigger(uuid, open?)(open, close or toggle one — replicated like a click).api.claimInput('sticks')— claim BOTH VR thumbsticks (move, turn, teleport stand down) to readinput().axesyourself; returns true on a core that knows the scope, so feature-detect it.api.lod(object)handles gainforce(n)(pin a level;nullback to automatic) andlevels().api.musicis owned by your module:stop()stops only your track, and unloading your module stops it.
Lifecycle
- Core modules load at boot unless disabled in the manager; user modules load
after them. Enabling registers live. Modules disable, update and dev-reload
live — everything
register(api)added is genuinely torn down and re-registered (see the manager page's Dev mode). Since 1.20 that includes core modules, which no longer need a reload to disable; see Unloading. - Peers exchange
{id, version}lists on connect and toast on mismatch. The session still works, but that module's behavior may differ between peers — treat "same modules everywhere" as part of the session contract. registerruns during page boot (also under SSR prerendering for core modules): guardwindow/documentaccess, do scene work lazily.
Testing your module
Open two browser windows to the same dev server, connect them, and check:
- [ ] Everything a user can do through your module looks identical in the second window.
- [ ] A window that connects after you did something catches up (state sync or derivable-from-scene).
- [ ] No
Math.random()without a broadcast seed; no accumulation in effects. - [ ] Receiving a message never re-broadcasts it.
- [ ]
idand nodetypes unique; version bumped on behavior changes.
The repo's e2e suites (tests/e2e/, npm run e2e) show how to drive all of
this headlessly — dungeon.test.cjs and piano-pong.test.cjs are module
examples.