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.
The one rule that matters
A module runs on every peer. There is no 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
api.registerFrameTask((time) => { /* every frame, synced seconds */ });
api.registerMenu('Open my panel', () => { /* button on your manager card */ });
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)
});
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)
Lifecycle
- Core modules load at boot unless disabled in the manager; user modules load after them. Enabling registers live; disabling applies on reload (the registries are additive — there is no unregister).
- 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.