Skip to content

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:

  1. Deterministic from shared inputs — effects driven by node data (already replicated) and the synced clock (api.now()). Prefer this.
  2. Broadcast events — a discrete thing happened ("button pressed", "generate with seed 42"). api.send({...}) a small message, apply the same change locally and in api.onMessage. Never re-broadcast from a receiver.
  3. State sync for late joinersregisterStateSync hands 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.
  • register runs during page boot (also under SSR prerendering for core modules): guard window/document access, 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.
  • [ ] id and node types 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.