This is the single entry point for writing an external module — one that
installs from a .zip or a URL, not one bundled into the app. It is written to
be read start-to-finish by a person or pasted whole into an AI assistant.
- Full API reference: the docs site's module-sdk
and module-package pages, plus
MODULES.mdin the core repo. - Worked examples in this repo, roughly in order of how much they ask of you:
_template(start here) ·door-keypad(replicated objects, a discrete event, a deterministic animation from one timestamp, late-joiner state) ·dungeon(the worked toolbox example:api.registerToolboxwith a#dungeon-panelDOM fallback behind a feature-detect, a node that IS the recipe, and a publisheduserData.playcontract other modules read) ·dungeon-realms(a game as an OVERLAY on another module: two modules cannot share code, so they share the scene — the Kit'suserData.play/userData.kitseam, read throughapi.scene()) ·tutorial-room(derived content: local geometry, replicated intent) ·sabers(per-frame pose streaming, VR and desktop from one code path) ·fps-player(input claims,possess, capability probing) ·flow-toolkit(flow nodes) ·untangle(a full replicated game) ·health(a number several peers can lower, converging with no authority: pulses counted by core's Counter, a player's own peer row, the first-sight rule) ·waves(a mechanic COMPOSED on another module's node types, the wave derived from counters, a run log every peer appends identically). - Missing something? DEVX-REQUESTS.md tracks known SDK gaps —
check it before you work around one. To file one, add
devx/<slug>.mdwithnumber: null(devx/README.md); the integrator numbers it at merge.
A folder with a manifest.json and one self-contained JavaScript file that
default-exports { id, name, version, description, register(api) }. The app
calls register(api) once, and everything your module does — objects, clicks,
flow nodes, netcode — is wired through that api object.
npm install # once, for the pack + test scripts
npm run new -- my-module "My Module" # scaffold from modules/_template
npm run pack -- my-module # -> my-module.zip at the repo rootInstall it: burger menu ▸ Modules ▸ User tab ▸ Install from zip. The module registers immediately — no reload. (Removing or disabling one does need a reload; SDK v1 registries have no unregister.)
A module is code running in the user's session, with the same reach as the app itself. There is no sandbox, no permission prompt beyond the install warning. Ship modules you would run yourself, and expect users to read the source of anything they install. Everything in this repo is MIT and reviewable.
The app stores your files in IndexedDB and imports the entry as a blob URL. A blob URL has no package resolution and no base path, so:
| In your entry file | Result |
|---|---|
import * as THREE from 'three' |
fails — no import map for bare specifiers |
import { helper } from './helper.js' |
fails — nothing to resolve ./ against |
const THREE = api.THREE |
✅ the app's own three, same version, no duplicate |
await import(api.assetUrl('extra.js')) |
works, but you rarely need it |
npm run pack fails the build if it finds a top-level import — that error is
this rule, not a bug.
Multi-file development is fine as long as you ship one bundled file. esbuild with no external deps:
npx esbuild src/index.js --bundle --format=esm --outfile=modules/my-module/module.jsKeep api as the only outside reference: bundling three would ship a second
copy of the library and your objects would fail instanceof checks against the
app's.
Anything in the zip that is not the entry becomes a blob URL:
const chime = new Audio(api.assetUrl('assets/chime.mp3'));
const map = new THREE.TextureLoader().load(api.assetUrl('assets/wood.png'));List asset paths in the manifest's files array — zip installs pick up every
file automatically, but URL installs only fetch what files names.
{
"id": "my-module",
"name": "My Module",
"version": "1.0.0",
"format": 1,
"description": "One line shown on the module card.",
"entry": "module.js",
"files": ["module.js", "assets/chime.mp3"]
}| Field | Contract |
|---|---|
id |
required, must equal the folder name and the id in your entry file, and be unique across every module a user might install — it routes your messages |
name, version |
required; version is what peers compare (see §7) |
format |
the manifest format this module targets. The app supports 1; a higher number makes the app ask the user to confirm before installing. Absent = 0 = installs silently |
entry |
defaults to module.js |
description |
shown on the card |
files |
used by URL installs; harmless but good practice for zips |
The zip must have manifest.json at its root, not inside a folder.
Compress-Archive nests the folder and produces "zip has no manifest.json at
its root" — use npm run pack.
Your module runs on every peer. There is no server. Whatever a user does must end up identical everywhere, and there are exactly three ways to get there.
Deterministic — the same inputs plus the synced clock produce the same
result on every peer, so nothing needs sending. Prefer this. A door that opens
from a broadcast at stamp, a puzzle generated from a seed, an effect that is a
pure function of (data, time).
api.registerFrameTask((time) => {
const age = time - openedAt; // openedAt came from ONE small message
door.position.y = base + Math.min(1, age / 0.6) * 2;
});Authoritative — one peer decides and broadcasts the result. Physics, dice, anything where "simulate it yourself" would drift. Only the initiator may mutate; everyone else forwards their input to it.
if (api.physics.isInitiator()) api.physics.applyImpulse(uuid, [0, 5, 0]);
else api.send({ op: 'push', uuid }); // and the initiator applies itNever mix them for one feature. A deterministic animation that also receives corrective positions will fight itself.
function open(uuid, at) { /* apply — no sending in here */ }
api.registerClickHandler((object) => {
const at = api.now();
open(object.uuid, at); // local
api.send({ op: 'open', uuid: object.uuid, at }); // peers
return true;
});
api.onMessage((data) => {
if (data.op === 'open') open(data.uuid, data.at); // apply, do NOT re-send
});Re-broadcasting from a receiver is an infinite loop in a mesh.
Someone connects after the door opened. The handshake asks every module for its state:
api.registerStateSync({
getState: () => ({ open: [...openUuids] }), // sent to the new peer
applyState: (state) => state?.open?.forEach(markOpen)
});If your state is derivable from the scene (you read object names and positions that already replicate), you may not need this at all — that is usually the better design.
api.now()is the synced clock in seconds. Stamp every replicated time with it, neverDate.now().Math.random()in anything replicated is a desync. Send a seed and generate from it — seemodules/untangle'smulberry32.- Don't accumulate (
object.rotation.y += x) in per-frame code: peers that dropped frames end up somewhere else. Compute from a base and a time.
| Travels | Does not travel |
|---|---|
api.send() payloads ({type:'module', moduleId, ...}) |
the module itself — every peer installs it separately |
registerStateSync state, on connect |
objects you add to api.scene() (scene root is local) |
Objects created through /create (registerPrimitive) |
local userData you set yourself |
The {id, version} list of loaded modules |
assets from your zip |
Installing a module does not install it for your peers. They exchange module id/version lists only; a peer without your module just sees whatever plain scene objects it created, and both sides get a toast about the mismatch.
api.objectsGroup()is the replicated scene root. Objects here sync to late joiners, appear in the object list, and can be moved or deleted by anyone. The clean way to create one isregisterPrimitive+ its/createcommand, which runs on every peer.api.scene()is the local scene root. Content here is yours: it never enters scene sync, never lands in someone's saved file, and never duplicates on connect. Rebuild it from your module state on each peer. Register the group name withapi.registerInteractiveGroup('my-module')to make it clickable, and clean it up inapi.onSceneClear().
Derived content (a generated dungeon, a game board) belongs in api.scene().
Things the user should be able to select, move and save belong in
objectsGroup().
Object userData you set locally is not on the peers' copy of that object.
The name is. So a module that behaves differently per object kind reads the
name that /create assigned:
api.registerPrimitive('Mykeypad', builder, { command: '/create Mykeypad' });
// later, on any peer:
if (object.name === 'Mykeypad') handleKeypad(object);Full signatures live in the docs site; this is the map.
| Group | Calls |
|---|---|
| Scene | scene(), objectsGroup(), registerPrimitive(name, builder, entry?), registerInteractiveGroup(name), registerSystemGroup(name), registerListedGroup(name, {label}) (§6), onSceneClear(fn) |
| Interaction | registerClickHandler(fn, {modes}) (desktop click and VR trigger; which editor modes — §6), pointerRay() (the crosshair ray in play under a pointer lock), registerFrameTask(time => …) |
| Flow | registerNodeGroup(group, components?), registerEffect(type, fn), registerNodeDefs(defs) |
| Netcode | send(payload), onMessage(fn), registerStateSync({getState, applyState}), peerId() |
| Input | registerBindings(list), input(), onInput(fn), claimInput(scope), releaseInput(scope) |
| Physics | physics.isInitiator(), applyImpulse, applyTorqueImpulse, setJointMotor, joints() |
| Knock | onHit(cb) — every knock this peer sees (its own hand's and every peer's hit) as {uuid, by, at, speed, point, linvel, angvel, probe, local}; returns the unsubscribe, torn down with the module · hitLog() — a COPY {last, recent} (last hit per live body, the last 32 in order; runtime state, a late joiner's starts empty) |
| Player | possess(uuid, {camera}), releasePossess(), selectedUuid() |
| UI | registerMenu(label, action), registerVRMenuEntry(entry), toast(text) |
| Misc | THREE, assetUrl(path), now(), sceneAssets() |
The worked example for the game SDK (api.game, api.peerVars, onHit, registerStateSync, registerToolbox) is football: rules as flow nodes, last-touch attribution evaluated BY EACH PEER from onHit (no module message for a touch), the physics initiator as the one goal authority, and each player's goals written to their OWN peerVars row.
Three that are easy to miss:
registerClickHandlercovers VR too. You do not write a second input path for the headset; the trigger dispatches through the same handler with the exact mesh that was hit. Returntrueto consume the click (no selection). On desktop it runs only in the modes you name (§6) — by default Interact and Play, never the editor's select click. It fires on the trigger'sselect— the RELEASE. For a DRAG in VR (press, carry while held, release) pollapi.vrHand(hand)in a frame task instead (both hands' world poses + trigger), consume core's trailing select in the handler, and pass{sweep: false}so a held trigger sweeping across your pieces does not click them — untangle'svrdrag.js.claimInput('keys' | 'locomotion')pauses the editor's own consumers so your WASD does not also fly the camera. Always release it when your mode ends, including on error paths.onHitfires on EVERY peer for every hit. Attribution derived from it is deterministic without a message of your own; a per-player counter must still bump only whenhit.by === api.peerId()(orhit.local), or every peer banks it.
The app has three places a click can come from, and your module decides where each of its handlers runs. (Core 1.17, roadmap 30: before it, every module click handler heard every EDITOR click, so a piano or a puzzle piece swallowed the select and could never be moved.)
| Mode | What a click does | Who hears it |
|---|---|---|
| Edit (default) | SELECTS; the gizmo moves the selection | only handlers registered with 'edit' — editor TOOLS |
Interact (key I, the Controls bar toggle) |
the play-style click with the cursor: nothing is selected, the scene reacts | handlers with 'interact', On Click nodes |
| Play | the crosshair tap, the VR trigger | handlers with 'play', On Click nodes |
api.registerClickHandler(fn, { modes: ['interact', 'play'] }); // a game piece: the default
api.registerClickHandler(fn, { modes: ['edit'] }); // an editor toolLeaving modes out means ['interact', 'play']. Say it anyway — a reader then sees
it was decided, and an older app simply ignores the option. VR's trigger has no editor
mode yet and still offers every handler.
- Is each click handler a game piece or an editor tool? A key that sounds, a
button that presses, a gem that collects, a portal, a claim: a game piece,
['interact', 'play']. A toolbox's "pick an object", a kit's pick-to-place: an editor tool,['edit']— it runs BEFORE the selection, so returningtruestill consumes the click. The one module here that runs in all three istutorial-room: its plinths are a checklist of EDITOR lessons. - Where does your content live? Things a user moves, saves and shares go in
objectsGroup()(a/createprimitive): they are listed in the scene tree, selected by a click and moved by the gizmo with no work from you — your job is only to keep your handler out of Edit. Derived content you rebuild from module state goes at the scene root (§4.6) and is READ-ONLY to the user. - Name your scene-root group for a person. Every group passed to
registerInteractiveGroup/registerSystemGroupis listed in the object list's Module content section;api.registerListedGroup?.(name, {label})gives the row a readable label ("Piano (module)", "Dungeon") instead of the id. Name the children you want listed (mesh.name = 'Key C4') — unnamed meshes are not rows. - Should an Edit click on it select it? Only groups registered with
registerInteractiveGroupare picked in the viewport: an Edit click on one selects a PROXY that frames it (the gizmo never attaches; the module owns it). AregisterSystemGroup-only group is listed and framed from the list, not picked.
The scaffold (modules/_template, what npm run new copies) shows all of it: the
beacon's handler with modes spelled out and the three modes explained, and the
scene-root recipe with registerListedGroup.
| Module | What | Why |
|---|---|---|
sabers |
the blades | a desk blade lies ALONG the pointer ray, so a click on it would be every click; listed (Sabers) and framed from the list |
avatar, flow-toolkit |
— | no scene content of their own (possession acts on your object; node definitions only) |
tests/modes-audit.test.cjs measures every module against this: listed, selected by
a real click in Edit, moved by a real gizmo drag (or read-only for scene-root
content), quiet in Edit, working in Interact and in Play.
- Bump
versioninmanifest.jsonand in the entry file's export — the entry's value is what peers compare. Keeping them equal is on you. - On connect, peers toast when a module is missing on one side or the versions differ. It is advisory: the session continues, but replicated behavior may not match. Treat a version bump as "my messages may have changed shape".
- Changing the shape of an
api.sendpayload orgetStateis a breaking change for anyone mid-session on the old version. Tolerate missing fields when you can (data.at ?? api.now()). - Modules do not pin an app version. If you need a capability that may be
absent, feature-detect it:
if (typeof api.pointerRay === 'function').
Serve the module folder over HTTP and point the app at it — then every save is one click (or zero) away from running, with no page reload:
cd modules/my-module
npx serve -l 8099 --cors . # any static server with CORS worksIn the app: Menu ▸ Modules ▸ User, paste http://localhost:8099 into the
install field, press Install. The card then carries a Dev URL row:
- Reload re-fetches the files and swaps the new code in live. Everything
register(api)added is torn down first — menu entries, nodes, effects, frame tasks, click handlers, input claims, your scene-root groups — so repeated reloads cannot stack up duplicates. - Auto polls the URL every ~2s and reloads whenever
module.jschanges. - A syntax error or a throw during
register()leaves the previous version running and toasts the reason; fix the file and reload again. - Objects your module created inside
objectsGroup()stay (they are shared user content); your own scene-root groups are removed and rebuilt by the new code.
Two caveats worth knowing:
- If a peer is connected, bumping
versionre-triggers their "module version differs" toast on every reload. That is correct — your dev copy really does differ — but keep the version stable while iterating and bump it when you ship. - When the static server goes away, Auto stops silently (the poll failure is deliberately quiet so restarting your server does not spam toasts). If Auto seems to stop picking up edits, check the server is still up.
Two windows, always. A module that looks perfect in one browser is untested. Run a second window against the same app, connect the peers, act in one and watch the other; then reload the second (late joiner) and check it catches up.
Automated test-flight. Every module in this repo ships a Playwright script that installs the real zip through the real manager and drives the app:
npm install
npx playwright install chromium # once
npm run pack -- --all
APP_URL=https://localhost:5188/ npm test # all test-flights
APP_URL=https://localhost:5188/ npm test -- keypad # oneAPP_URL points at any running app instance (a dev server, a lane worktree, or
the deployed site). See tests/README.md for the two-peer
recipe and the window.__stores debug hook the checks read.
- Two connected windows agree after every interaction.
- A peer that joins after the interaction catches up.
- No
Math.random()and noDate.now()in replicated paths. - Receivers never re-broadcast.
- Every
claimInputhas a matchingreleaseInput, including on error. -
api.onSceneClearremoves your scene-root content and resets state. -
npm run packsucceeds (no top-level imports, manifest matches folder). - Works with a mouse and with the VR trigger, if it is clickable.
- Every click handler names its
modes(§6), and an Edit click still selects (or, for scene-root content, lists and frames) everything you made.
A module that ships a Games-tab template (football, dungeon-realms, waves, untangle) owns a
<id>.def.json the core author script builds (npm run build:<id> emits it; core's
scripts/author-templates.cjs has THE DEF SCHEMA comment block with every field). Since
core's 30 author kit a def can say, all additive (absent = the old behaviour):
| Area | Fields |
|---|---|
| Primitives | box + bevel (rounded) · sphere · cylinder · cone · torus · capsule (r, h) · plane (faces +Z) · ring (r, inner) · icosahedron / dodecahedron · group / empty with children · camera (lookAt, fov) |
| Lights | light kind point / spot (angle, penumbra, target, castShadow, shadowMapSize) / directional (shadow frustum FITTED to the built meshes; fit: false) / hemisphere |
| Materials | color, roughness, metalness, emissive + emissiveIntensity, opacity, flatShading, side, toon, physical (or any of clearcoat, clearcoatRoughness, transmission, thickness, ior, sheen, iridescence, specularIntensity) |
| Flags | physics, shadow: false, pick: 'through' (select-through shells), origin (a door's hinge), anim: '<preset>' (door, drawer, elevator, turntable, pulse, fade — an AUTHORED clip, run it with a Play Animation node), particles: '<preset>' | {preset, ...overrides} (sparkles, fire, smoke, dust, confetti, sparks) |
| Sky | env: {preset: 'custom', base, exposure, background: {top, bottom}, fog, ground, sun: {color, intensity, dir}, hemi} |
| Card | view {pos, target} (the editor camera the file opens on) and thumb.camera (a camera object's name — the card renders through it with the scene's own look) |
The standard shell every Games-tab game meets: a Start screen with a mouse-clickable Start
(Enter / gamepad A too), a HUD, Pause on P (Resume / Restart / Quit), an over screen with a
restart, view + thumb.camera, shells pick: 'through', a real ground, exposure >= 0.9,
post AO -> AgX -> bloom -> SMAA, one of the kit's animation/particle presets where it reads.
What cost time in 30-visuals-mod (football, dungeon-realms, waves):
- The editor grid draws at y = 0. A floor whose TOP is exactly 0 z-fights it (the grid
showed through the football pitch in play). Put a floor's top a hair above (0.01-0.02 m) and
give the game a ground plane of its own: the custom sky's
grounddisc sits at -0.01, UNDER the grid, so it does not hide it in the editor. - Desktop play spawns at a fixed
[0, 2, 3]outside a dungeon (core's Player), whatever the def'sviewsays — keep that line of sight clear (football's lamp strip moved onto the crossbar for it). No api moves the play-mode player (DEVX #23). - Put the decoration in ONE top-level group (
Arena). A game's suite counts top-level objects; one group moves that count by exactly one, and a resize ("Fit pitch") moves or stretches the whole look in oneapi.moveObject. Nothing in it should be a body or carry a name another rule reads (waves reads every top-levelSpawn…object as a spawn point). - A
groupwithphysicsis ONE body whose collider fits the group's box (the collider spec measures children). That is how a waves enemy is a capsule figure with a glowing visor and still one dynamic body for the health/knock contract. - Glass:
physical+transmission,shadow: false(a ceiling that casts shadows puts the whole pitch in the floodlights' shade) andpick: 'through'; keep a lowopacitytoo if a toolbox recipe rebuilds the object (a recipe only knows colour/opacity). - A menu on core HUD screens beats module DOM. A screen with
input: 'menu'frees the pointer in play (clicks land), and core's HUD ring gives arrows/Enter/gamepad A for free. Drive module rules from its buttons through HUD Button (perPlayer) -> Delay -> your module node's number input (DEVX #22). Dungeon Realms moved its Start/victory menu there and itsdrmenunode'sshow: 'never'stands the old DOM card down. - HUD text ignored
alignunless it wrapped before core 1.17 (DEVX #28, fixed there:.hud-textwas a flex box). Lay a menu out left-aligned to its button column (or, on 1.17+, give a centred title a wide box). - Late knocks move last-hit stamps. A knock on a dead, hidden enemy still pulses its damage
counter, so an identity built from "the last hit" drifts by a millisecond between peers —
key a run on the round (
api.game.roundCutoff(), remembered while it runs), not on a hit. - An empty HUD list with
bg: 'transparent'draws nothing — the way to hide a leaderboard until it has rows (there is no per-element visibility node). - Play Animation acts on its trigger's VALUE edge. A module EVENT output (
fbevent) is a stamp: bridge it with a zero-second Delay, exactly like a HUD Button. - Particle emitters are capped (8 per scene). Put presets on a few objects that read — portals, a lantern — never one per torch; a torch flame glows (emissive > 1 + bloom) instead.
- A ceiling the editor never sees: one single-sided plane facing DOWN is culled from above (the editor, a card) and closes the sky from inside; show it only while playing. The Dungeon Kit's vault does this, and its capped point lights move to the torches nearest the player (the COUNT never changes, so nothing recompiles) — a few lights light every torch you pass.
- The default AO radius (1.5) smears over big flat planes (colour blotches on a stadium floor, a dungeon's tiles): 0.6-0.8 with intensity ~1.5 reads clean.
- Measure the look, do not describe it: the centre half of a 1540x774 play frame, Rec.709 luma, must read >= 0.25 on the GPU backend (the before/after numbers are in the lane handover).
What the Dungeon Kit / Dungeon Realms round-2 lane learned (user feedback from a Quest 3):
- Light a big level without lights. A few real point lights that follow ONLY in Play left
the whole dungeon dark in VR's Interact mode ("a single place where I see lights"). Three
layers now, cheapest first: bake each torch's light into the per-instance colour AND a
torchLightinstance attribute the material adds to its emissive (onBeforeCompile, one multiply per pixel; a flood fill over floor cells, so light turns a doorway but never passes a wall); an ADDITIVE halo quad on the wall behind each flame + a pool on the floor (two draw calls, and they glow in VR, where there is no bloom); the capped real lights on the torches nearest the VIEWER in every mode, fading out/in when they move (stepLightSlots). - Interact is a game view. VR's Play enters Interact (C1): gate game behaviour on
api.isPlaying() || api.editorMode() === 'interact', never onisPlaying()alone. - Collision comes from what core walks. Scene-root module geometry is not a physics body.
The dungeon walker reads the published raster (
userData.play.grid, dungeonPlay.walkable): stamp solid props' cells non-floor there (never the generator's own grid — it feeds the checksum), keep every spawn cell open, and publishcolliders(world AABBs) andlocomotion: {teleport: false, fly: false}for a physics capsule / the mode resolver. - Your coordinates are your group's LOCAL frame. Module groups sit under core's world
root, which a VR Edit grab moves and scales: convert
api.playerPosition()with the group'sworldToLocalbefore comparing it to your content, andlocalToWorldbefore handing a position tosetSpawn/playSound/effects.burst. - Feature-detect every new SDK call and prove your calls in the flight. Expose your
apion your debug hook; the flight replacessetSpawn,playSound,music,effects,hapticPattern,announcewith loggers and asserts exactly what the module sent (the picker's hands buzz, a peer's do not; a new floor =levelup+announce('Floor N')+setSpawn(..., {teleport: true})), on any core.
Things that cost real time while writing the modules in this repo. Add to this list when something bites you.
-
Blank card, no error. The entry threw during
register(). The app catches it, toasts "failed to load" and disables the module — open the console for the actual stack. -
"zip has no manifest.json at its root". The zip contains the module folder.
npm run packbuilds the right layout. -
Your change did not take. Fixed in the app: installing over a loaded module, updating, disabling and removing all apply live now, and a Dev URL card gives you Reload / Auto (see "Live reload while you build"). On an older build the zip was stored but the old code kept running until a page reload.
-
Peers do not see your objects. You added meshes to
api.scene()(local by design) instead of creating them through/create, or you moved an object inobjectsGroup()without telling anyone — a module cannot broadcast a plain object move; replicate the pose through your ownapi.sendop, or drive the object withapi.possess. -
The click handler never fires. You are in the editor's Edit mode: a game piece hears clicks in Interact (key
I) and Play, not Edit (§6). Or your content is at the scene root and you did notregisterInteractiveGroup(name)— onlyobjectsGroupis clickable by default. Also check you are walking up from the hit mesh to your root object; handlers receive the exact mesh, not the group. -
The animation drifts between peers. Accumulation,
Date.now(), or a frame-rate-dependent step. Recompute from(base, api.now())every frame. -
Selection steals your interaction. Return
truefrom the click handler. -
A drag never reaches your click handler. Core dispatches a module click on a short, STATIONARY pointerup, and until then OrbitControls orbits under your finger. A module that needs press-drag-release owns the gesture itself: listen on
windowin the CAPTURE phase, stop propagation only for a press on YOUR target, and aim with the crosshair under a pointer lock (untangle'sgesture.js+aim.js; DEVX #29, #31). -
api.onInputmissed the first seconds of keys. Fixed in the app: the subscription is synchronous now, so a listener registered inregister()is live from the first keypress. On an older build it went through an async import and a key pressed right after install did nothing while the same code worked later. Edge-detecting from the per-frame snapshot is still a fine pattern and works on every build (fps-playerdoes this):let was = false; api.registerFrameTask(() => { const down = api.input().codes.has('KeyJ'); if (down && !was) toggle(); was = down; });
-
Keyboard input stops while an app modal is open. A button on your module card that starts a keyboard-driven mode leaves the user in the Modules manager, where no key reaches you. Offer a key binding as well as the button.
-
api.input()fires while the user is typing in a panel. Claim the scope (claimInput('keys')) only while your mode is active, and ignore input when it is not. -
An EFFECT node pins its target's pose. The runtime re-seats an effect target's base pose every frame while the effect is active (in play), so a
registerEffectnode on an object that must move — a knocked crate, a walking enemy — fights every move, replicated or not (football's "no node may target the ball"). If your node only needs to know its object, make it aregisterValueNodewith an{inputs: {target: 'object'}}socket and wire the Object Selector IN; hide/show the object yourself and restore only what you hid (healthdoes this). -
Two clocks.
api.now()and every trigger-log stamp are seconds of day on the synced clock;api.game.roundCutoff()(the round'sstartedAt) is session milliseconds. Convert ((ms / 1000) % 86400) before comparing, and never compare either toperformance.now(). A joiner'sapi.now()also re-bases on connect — decide "did I witness this" by identity (the collectible's first-sight rule), never by clock. -
api.flow.nodeValueis ~6 Hz. The live values republish every 150 ms, so a Counter you just pulsed still reads the old count for a moment. Firing again "because the count has not moved" doubles the pulse; remember what you fired (waveskeeps a per-node expectation) or derive from the last sweep's numbers (health's kill credit). -
A second
installModuleon one peer needs the/^User/tab locator — after an install the tab reads "User (1)" and an exact match hangs (fixed inhelpers.cjs). -
A game that only starts from a DOM menu never starts in a headset. The HUD's screens do not draw in VR on a 1.17 core, so football's goals — which count only in a started match — never counted on a Quest ("the ball reaches the gate and nothing changes"). Give every game a start a player can reach with their hands: football kicks a match off on the first touch of the ball and seats an unseated player on the smaller team (30b).
-
A solo session's own hits carry
by: ''. With no peer id the knock stamps nobody, so a module that keys a touch onhit.bydrops every touch of a player alone; takehit.localas "me" (football 30b). -
There is no api to place a dynamic body (DEVX #39). Writing its pose with
api.moveObjectwhile the sim runs holds it where you put it (core's external-hold rule) and lets go, at rest, 250 ms after the last write — football parks the ball in the net and on the centre spot this way. An impulse given in the same frame as the write is eaten by the hold: nudge after it lets go. -
Capping
dtturns a slow frame rate into slow motion.Math.min(dt, 0.1)is the right way to stop a physics step tunnelling, but at 7fps (headless Chromium, a background tab) it means sim time advances at 0.7x — a jump that takes 0.9s on your machine takes 2s+ there. Tests must poll for the end state, never sleep for a computed duration.