Script
Write a small piece of code — the escape hatch when no built-in node fits. A Script node works two ways:
- As an effect (the default): code that runs every frame on the connected object.
- With typed sockets (since 1.20): a small function with named, typed inputs and outputs — a value other nodes can read, like a Math or Compare node you wrote yourself.
Output: effect (wire into an Object Selector), or the outputs you declare.
Inputs
| Handle | Type | Meaning |
|---|---|---|
| a, b, c | number | optional wired values, readable in code as data.a, data.b, data.c |
A node with typed sockets has the inputs you declare instead.
Parameters
| Parameter | Where |
|---|---|
| code | Edit code on the card (or a double-click) opens it in the code workspace |
| inputs / outputs | Typed sockets… above the code in the workspace |
| input values | the ⓘ Params tab of the node editor's side panel: the value each typed input uses while nothing is wired into it |
Your code receives:
object— the connected THREE object,base— its logical resting transform{pos, rot, scale, visible}(restored before every frame — apply offsets from it),data— this node's data, including wired a/b/c,time— synced seconds.
Errors show on the card.
Edit code opens the code workspace
Since 1.23 Edit code — or a double-click on the node — opens the script as a tab in the code workspace, the same place behaviours, script files and module code open. Type, then press Ctrl+S (or Save): code that does not parse is not applied, the banner names the line, and the node keeps running its last good version. Tick Live on the tab to apply your edits as you type instead, the way the old Script panel did.
From the same tab, Save as file keeps the code as a .js file in your Explorer that several nodes can
run, and Use file… runs one you already have. A Script node that a module ships bound to its own file
opens read-only, with Make editable copy — see Main graph & node properties.
Practical example
- Add a Script node and an Object Selector targeting your cube; wire Script → Object Selector.
- Click Edit code and enter:
object.position.y = base.pos[1] + Math.sin(time * 2) * 0.5; - The cube bobs — identically on every peer, because the code is a pure function of
baseandtime.
Warning
Keep scripts deterministic: no Math.random(), no accumulating (rotation.y += …). Compute everything from base, data and time, or peers will drift apart.
Runaway and slow scripts
A script cannot freeze the app:
- A loop that never ends is stopped after a million iterations in one run, and the card shows a Script loop limit error.
- A script that is slow every frame — more than about 8 ms, for half a second in a row — is paused, with a paused: too slow badge on the card, so the rest of the scene keeps its frame rate. Editing its code starts it again.
If a scene's scripts hang so badly that the editor never gets a frame, open it in safe mode.
Typed sockets
Press Typed sockets… above the code in the code workspace to declare your own sockets. + input and + output add one; give each a name and a type.
| Types | |
|---|---|
| Inputs | number, boolean, vector3, color, object, event |
| Outputs | number, boolean, vector3, color, object, any |
- Read an input as
inputs.<name>. Every input arrives in one shape for its type: a number,true/false,[x, y, z], a colour string; an object input is{uuid, name, position}. An unwired input reads as its type's zero (nullfor an object). - Fill the outputs by returning them:
return { allow: …, gap: … }. A node with outputs is a value node — other nodes read its sockets, and the card shows each output's live value. It is a pure function of its inputs andtime, so it gets noobjectto move. - A node with inputs but no outputs is still an effect: it drives its object as before, with
inputsbesidedata. - An any output passes what you return through unchanged — text for a HUD Text's
formatsocket, for example. - The helpers
dist(a, b),lerpandclampare there for you, and so is theapiobject. - An unwired input uses the value set for it in the ⓘ Params tab.
- Renaming or removing a socket removes the wires that went into it. Back to a, b, c returns the node to the plain effect.
// inputs: hand (vector3), piece (object), reach (number) — outputs: allow (boolean), gap (number)
if (!inputs.piece) return { allow: false, gap: 0 };
const gap = dist(inputs.hand, inputs.piece.position);
return { allow: gap <= inputs.reach, gap };
Outputs carry values only — a script cannot fire an event.
Sockets follow the code
Start reading inputs.speed, or return { hit: … }, and when you save the code the node grows that
socket (as a number; change its type in the socket list). Sockets are never removed for you, because a
wire may hang on one — remove them yourself in the socket list. This works for scripts with typed sockets;
a plain a/b/c script keeps its three inputs.
The api object
A script with typed sockets also gets api:
| Call | Returns | Where |
|---|---|---|
api.object(uuidOrName) |
{uuid, name, position} or null |
any script |
api.raycast(from, dir, max?) |
the first object hit, {uuid, name, point, distance}, or null (max defaults to 100 m) |
any script |
api.keys() |
the keys held on this device, e.g. ['KeyW', 'Space'] |
scripts without outputs (effects) |
api.spawn('/create sphere') |
true when the object is created |
scripts without outputs (effects) |
api.keys()is local input: each player holds different keys, so it is only for things that one player drives (a paddle whose position then reaches everyone). A value script that calls it is refused.api.spawn()takes a/create …command. Every player runs every effect script, so only the room's host creates the object, once, for everyone. It is limited to 4 a second and 50 per script (editing the code resets the count).
// inputs: from (vector3) — outputs: hit (boolean), gap (number)
const ray = api.raycast(inputs.from, [0, -1, 0], 20);
return { hit: !!ray, gap: ray ? ray.distance : 20 };
The lint
A script with typed sockets runs on every peer and must give every peer the same answer, so code that cannot is refused with its line, before it runs:
| Not allowed | Instead |
|---|---|
Math.random, crypto randomness |
wire a Random node into an input |
Date.now, new Date(), performance.now |
read time (the synced clock) |
the page: document, window, navigator, globalThis, fetch, eval, new Function, import()… |
reach the scene through your inputs |
storage: localStorage, sessionStorage, indexedDB |
— this device's state is not the game's |
timers: setTimeout, setInterval, requestAnimationFrame, queueMicrotask |
compute from time |
a loop with no way out (while (true) with no break, return or throw) |
give it an exit |
A plain a/b/c script is not held to the lint — it keeps running as it always did — but the code workspace shows the same findings as advice.