#How the pieces fit
Four words cover the whole model: a scene holds nodes, a node holds behaviours, one of those behaviours runs your file.
A node is a thing in the scene with a name, a transform, children and a set of groups. A behaviouris a component attached to a node, in the spirit of a Unity MonoBehaviour or a Godot script. The engine ships a dozen of them in C++ — Character, Vehicle, Animator, Health, Blackboard — and one that runs JavaScript: ScriptBehaviour.
Point a ScriptBehaviour at a file in your project and the engine gives that file its own QuickJS context, installs a fixed set of globals into it, and calls the lifecycle functions it finds. That is the entire contract.
What a script can reach
| Global | What it is |
|---|---|
node | The node this script is attached to: transform, groups, animation, character and vehicle control. |
tree | The scene tree: change scene, pause, quit, and find other nodes by group, id or autoload name. |
time | The frame clock and the timer queue: delta, elapsed, wait, every, tween. |
input | Actions, axes, sticks, mouse, binding and profiles. |
physics | Raycasts and sphere overlaps against the physics world. |
audio | Play an alias declared by the project. |
assets | Stream an asset and inspect the loader budgets. |
storage | The only persistence a script has: progression slots and preferences. |
console | log, warn and error, straight into the engine log. |
props / exportProperty | Inspector fields owned by this script instance. |
That list is exhaustive by design. There is no window, no fetch, no require, no setTimeout. See Sandbox and limits for why, and for what to use instead.
#Your first script
Five clicks in the editor and one file on disk.
- Open your project from the Hub, or launch the editor with
SaidaEngine.exe --project /path/to/game.saidaproj. - Create
scripts/spinner.jsin the project folder. Scripts live inside the project: the engine refuses any path that leaves it. - Select a node in the Scene Tree panel.
- In the Inspector, press Add Component and pick ScriptBehaviour.
- Set its Script field to
scripts/spinner.js— a project-relative path — then press Play in the menu bar.
// scripts/spinner.js — the smallest useful script.
// Attach it to any node, then press Play.
exportProperty("degreesPerSecond", 90.0);
let angle = 0.0;
function onReady() {
console.log("[Spinner] " + node.getName() + " is up");
}
function onUpdate(dt) {
angle += props.degreesPerSecond * dt * 0.017453292; // degrees -> radians
// A yaw quaternion: rotate around Y by `angle`.
node.setRotation(0.0, Math.sin(angle * 0.5), 0.0, Math.cos(angle * 0.5));
}The ScriptBehaviour inspector also gives you a Reload button, a Hot Reload checkbox, a Loaded / Not loaded indicator, and a Script Properties section listing whatever the script exported on its last load.
What it looks like on disk
A ScriptBehaviour is serialized into the .scene with its path, its hot-reload flag and the current value of every exported property:
{
"type": "Area",
"name": "DoorToArena",
"enabled": true,
"behaviours": [
{
"type": "ScriptBehaviour",
"enabled": true,
"script": "scripts/door.js",
"hotReload": true,
"properties": {
"targetScene": "scenes/arena.scene"
}
}
],
"groups": ["doors"]
}#The lifecycle
Five hooks, inspected once when the file loads, called by the node that owns them.
| Hook | When it runs | Notes |
|---|---|---|
onReady() | Once, after the node is in the tree and before its first update. | Runs again after a successful hot reload. |
onUpdate(dt) | Every frame, while the node and the behaviour are enabled. | dt is in seconds — the same value as time.delta(). |
onDestroy() | When the node is being destroyed, while it is still valid. | Also called on the outgoing context of a live hot reload. |
onEnable() | When enabled flips to true, after onReady. | Not called for the initial enabled state. |
onDisable() | When enabled flips to false, after onReady. | A disabled node stops running its behaviours entirely. |
.js or .mjs
The file extension decides how the engine parses your script, and therefore where it looks for the hooks. Nothing else changes.
| .js | .mjs | |
|---|---|---|
| Parsed as | Classic script | ES module |
| Hooks are | Global functions | Named exports |
Reachable by NodeRef.call() | Global functions | Named exports |
import / export | Not available | Available, project-relative |
// door.js — a classic script (.js): hooks are global functions.
function onReady() { /* the node is in the tree */ }
function onUpdate(dt) { /* every frame, dt in seconds */ }
function onDestroy() { /* the node is going away, still valid here */ }
function onEnable() { /* setEnabled(true) after onReady */ }
function onDisable() { /* setEnabled(false) after onReady */ }// game_state.mjs — a module (.mjs): hooks are named exports,
// and so is anything you want NodeRef.call() to reach.
export function onReady() { /* ... */ }
export function onUpdate(dt) { /* ... */ }
export function addScore(amount) {
return amount; // callable as someRef.call("addScore", 100)
}Hot reload
While the game runs, the engine polls the script file — and every module it actually imported — twice a second, staggered per script so a project with a hundred of them does not check them all on the same frame. When something changed:
- The new file is parsed and evaluated into a fresh context.
- If it fails, nothing is swapped: the previous script keeps running and the log says
keeping previous script after reload failure. - If it succeeds, the old context gets its
onDestroy(), its timers are cancelled and itsnode.on(...)subscriptions are dropped. - The new context gets
onReady().
Exported properties survive the swap; ordinary top-level variables do not. State you want to keep across a reload belongs in an exported property, a blackboard, or an autoload.
#Exported properties
The bridge between a script and the inspector, and the only script state the scene file remembers.
Call exportProperty(name, defaultValue) at the top level of your file. The value then lives on props.<name>, appears in the inspector under Script Properties, and is written into the scene.
// scripts/platform.js — a platform that shuttles along one axis.
exportProperty("axis", "x"); // "x" | "y" | "z"
exportProperty("span", 5.0); // metres either side of the rest position
exportProperty("period", 5.0); // seconds for a full there-and-back
let origin = null;
let phase = 0.0;
function onReady() {
const p = node.getPosition();
origin = { x: p.x, y: p.y, z: p.z };
}
function onUpdate(dt) {
if (origin === null) return;
const period = props.period > 0.05 ? props.period : 0.05;
phase += (dt / period) * Math.PI * 2.0;
const offset = Math.sin(phase) * props.span;
const axis = props.axis;
node.setPosition(origin.x + (axis === "x" ? offset : 0.0),
origin.y + (axis === "y" ? offset : 0.0),
origin.z + (axis === "z" ? offset : 0.0));
}| Rule | Detail |
|---|---|
| Types | number, boolean and string. Anything else is refused with a warning. |
| When to call | At the top level, on every load. The declaration is what keeps the property alive. |
| Pruning | A property that is not re-exported after a reload is dropped, and its saved value with it. |
| Changing type | Re-exporting a name with a different type resets the value to the new default. |
| Precedence | The saved scene value wins over the default. The default only applies the first time. |
#Finding other nodes
Groups are the sanctioned way to locate something. Names and paths are not.
A group is a tag on a node, editable in the Inspector under Groups and serialized with the node. Scripts look nodes up through them:
| Call | Returns |
|---|---|
tree.firstInGroup(name) | The first node in the group, or null. |
tree.nodesInGroup(name) | Every node in the group, in tree order. |
tree.nodeById(id) | Re-resolve a node id kept from an earlier frame. |
tree.autoload(name) | The project autoload registered under that name. |
physics.raycast(...) / physics.overlapSphere(...) | Whatever the query hit. |
// Fail loudly at startup when the scene is missing something the script
// cannot work without. Pong3D does exactly this for its five nodes.
function requireGroup(group) {
const found = tree.firstInGroup(group);
if (found === null) throw new Error("Pong: no node in group '" + group + "'");
return found;
}
function onReady() {
ball = requireGroup("ball");
playerPaddle = requireGroup("paddle_player");
scoreText = requireGroup("pong_score");
}NodeRef is a weak reference
Everything above hands back a NodeRef, which resolves its target by node id every time you use it. That makes it safe to store, and it makes valid() meaningful: it is the only method that answers false once the target is gone. Every other method throws a ReferenceError.
// A reference kept across frames can outlive its node. valid() is the only
// call that answers instead of throwing, so it goes first.
function onUpdate() {
if (car !== null && !car.valid()) {
car = null;
report("the car went away");
return;
}
// ... safe to use `car` from here
}queueFree()invalidates references to the entire removed subtree, and it is deferred — the node survives to the end of the frame. Any callback or timer that captured a reference must check valid() before using it.
#Behaviour to behaviour
Four channels, each with a job. Picking the right one is most of the design work.
| Channel | Use it when | Crosses contexts |
|---|---|---|
Signals — node.on / node.emit | Something happened and you do not care who is listening. | Yes |
Calls — NodeRef.call | You need an answer from one specific script. | Yes, through JSON |
Blackboard — setData / getData | Several behaviours share a small typed state on one node. | Yes |
Scene wiring — the connections block | The link is level design, not logic. No script involved at all. | N/A |
Signals
Signals are declared in C++ through the engine's reflection system, so a script subscribes to and emits signals that already exist — it cannot invent one. When you call node.on(name, handler), the engine looks for that signal on the node's own type first, then on each behaviour attached to it. An unknown name returns false and logs [JS] node.on: no signal 'X' on node 'Y'.
| Signal | Emitted by | Payload |
|---|---|---|
bodyEntered | Area node | The other node's name (string) |
bodyExited | Area node | The other node's name (string) |
changed | Blackboard | The key that was written (string) |
died | Health | None |
stateChanged | StateMachine | The new state name (string) |
animationEvent | Animator | The event name (string) |
sequenceEvent | SequenceDirector | The event name (string) |
sequenceFinished | SequenceDirector | None |
fullRotation | Rotator | None |
finished | ParticleSystem | None |
started | ScenarioRunner | None |
stepChanged | ScenarioRunner | The current step (string) |
finished / failed | ScenarioRunner | The result or the error (string) |
// scripts/pickup.js — a relic on an Area node.
// The Area emits "bodyEntered" with the other node's NAME.
exportProperty("points", 1);
let collected = false;
function onReady() {
const gameState = tree.autoload("GameState");
if (gameState === null) throw new Error("GameState autoload is missing");
node.on("bodyEntered", function (who) {
if (collected || who !== "Player") return;
collected = true;
audio.play("pickup");
const total = gameState.call("addRelics", props.points);
console.log("[Pickup] " + node.getName() + " collected — relics=" + total);
node.queueFree();
});
}Handlers are owned by the context that registered them, so a hot reload or a destroyed node disconnects them automatically. There is no off() to remember. Use someRef.on(...) to listen to a signal on a different node.
Cross-script calls
ref.call(exportName, ...args) reaches into the ScriptBehaviourof another node and calls one of its functions — a global function for a .js file, a named export for a .mjs one. It is the only channel that hands a value back to the caller.
// scripts/target.js — a practice target. `takeDamage` is a plain global
// function: that is all it takes to be callable from another script.
exportProperty("value", 100);
let gameState = null;
let broken = false;
// Called from the player's shot, across contexts.
function takeDamage(amount) {
if (broken) return 0;
broken = true;
audio.play("target_break");
if (gameState !== null) gameState.call("targetDown");
time.wait(0.7, function () { node.queueFree(); });
return Number(amount) || 0;
}
function onReady() {
gameState = tree.autoload("GameState");
}// scripts/player.js (excerpt) — the shot is hitscan: one raycast decides the
// hit immediately, and a visible bolt then flies to the point it already hit.
const hit = physics.raycast({ x: ox, y: oy, z: oz }, dir, props.range,
{ ignoreSelf: true });
if (hit === null) return;
const struck = hit.node; // a NodeRef, or null
const point = { x: hit.point.x, y: hit.point.y, z: hit.point.z };
launchBolt(ox, oy, oz, dir, hit.distance, function () {
popImpact(point.x, point.y, point.z);
// What it hit can be gone by the time the bolt lands — an earlier bolt may
// have finished it — and every NodeRef method but valid() throws on a dead
// target.
if (struck === null || !struck.valid()) return;
if (struck.isInGroup("enemy")) {
audio.play("hit");
struck.call("takeDamage", props.damage);
}
});Blackboard
setData, getData and hasData read and write a Blackboard behaviour resolved on the node itself, else on the first descendant that has one. Values are number, boolean or string. Writing emits the changed signal with the key, and the StateMachinebehaviour reads the same store — which is what lets a script drive a state machine authored in the editor.
// A Blackboard behaviour on this node (or on a descendant) turns these three
// calls into shared, typed state that the StateMachine behaviour also reads.
function onReady() {
node.setData("alertLevel", 0);
node.setData("hasKey", false);
// "changed" is a reflected signal on Blackboard: the payload is the key.
node.on("changed", function (key) {
if (key === "alertLevel") console.log("alert -> " + node.getData(key));
});
}A target with no Blackboard answers false or null rather than throwing, like every other resolved-behaviour call.
Scene wiring, without a script
A scene file can carry its own signal-to-slot connections, in a connectionsarray on the scene root. Targets are re-resolved by node id at emit time, so a freed target simply becomes a no-op. This is the right tool when the link belongs to the level rather than to any logic — a hazard volume that damages whatever walks into it, a trigger that starts a particle system.
{
"schema": 2,
"version": 2,
"scene": {
"type": "Scene",
"name": "Arena",
"children": [ "..." ],
"connections": [
{
"from": 13224595248972440569,
"signal": "bodyEntered",
"to": 14116644317509485334,
"slot": "damage"
}
]
}
}| Slot | On | Effect |
|---|---|---|
damage(amount) | Health | Reduce health; dies at zero. |
kill() | Health | Instant death. |
fire(trigger) | StateMachine | Queue a one-shot trigger. |
goTo(state) | StateMachine | Force a state. |
setNumber / setBool / setString | Blackboard | Write a typed value. |
play() / stop() | SequenceDirector | Drive an .sseq sequence. |
play() / stop() / burst() | ParticleSystem | Drive an emitter. |
reset() | Rotator | Back to the starting rotation. |
start() / stop() / pause() / resume() | ScenarioRunner | Drive an authored scenario. |
#Autoloads
The layer that outlives the scene: one node, one name, always there.
An autoload is a node the engine spawns when the game starts and keeps on the World, which survives tree.changeScene(). It is declared once in the project file — or in the editor, under Settings → Autoloads — as a name and a value:
| Value ends with | What is spawned |
|---|---|
.scene | A node instantiated from that prefab. Use it when the autoload needs a hierarchy. |
.js or .mjs | A node carrying a ScriptBehaviour bound to that script. The common case. |
| anything else | A node carrying that registered behaviour type, e.g. Blackboard. |
{
"schema": 1,
"version": 1,
"name": "Witness Game",
"engineVersion": "0.1.0",
"mainScene": "scenes/hub.scene",
"audio": {
"aliases": {
"pickup": "assets/audio/pickup.ogg",
"save": "assets/audio/save.ogg"
}
},
"autoloads": {
"GameState": "scripts/game_state.mjs"
}
}From then on, tree.autoload("GameState") returns a NodeRef to it from anywhere, and NodeRef.call() is how the rest of the game talks to it. An autoload script is an ordinary ScriptBehaviour: same hooks, same globals, same hot reload.
// scripts/game_state.mjs — persistent autoload for the witness game.
// Other scripts reach it via tree.autoload("GameState") then NodeRef.call();
// they never touch the save slot directly.
const SLOT = "witness";
function readState() {
const raw = storage.load(SLOT);
if (raw === null) return { relics: 0, saves: 0 };
try {
const parsed = JSON.parse(raw);
return { relics: Number(parsed.relics) || 0, saves: Number(parsed.saves) || 0 };
} catch (_) {
return { relics: 0, saves: 0 };
}
}
let state = readState();
function persist() {
return storage.save(SLOT, JSON.stringify(state));
}
export function getRelics() {
return state.relics;
}
export function addRelics(amount) {
state.relics += Number(amount) || 0;
persist();
return state.relics;
}
export function saveProgress() {
state.saves += 1;
persist();
return { relics: state.relics, saves: state.saves };
}
export function onReady() {
if (!storage.has(SLOT)) persist();
}// scripts/hud.js — attached to a UITextNode. It reads the GameState autoload
// from a different QuickJS context; only the autoload knows about storage.
let last = null;
let gameState = null;
function refresh() {
const relics = Number(gameState.call("getRelics")) || 0;
if (relics === last) return;
last = relics;
node.setText("Relics: " + relics);
}
function onReady() {
gameState = tree.autoload("GameState");
if (gameState === null) throw new Error("GameState autoload is missing");
refresh();
time.every(0.25, refresh);
}// scripts/game_state.mjs (excerpt) — an autoload starts before the scene's
// own nodes exist, so the HUD cannot be resolved once and kept forever.
let hud = null;
function resolveHud() {
if (hud !== null && hud.score.valid()) return hud;
const scoreText = tree.firstInGroup("hud_score");
if (scoreText === null) return null;
hud = { score: scoreText, health: tree.firstInGroup("hud_health") };
return hud;
}
export function onReady() {
// The HUD belongs to the scene, which is not up yet when an autoload runs.
time.every(0.2, refresh);
}Why this pattern earns its keep
Score, health, phase, save slots and the HUD all belong to one owner. Both shipped sample games converge on the same shape: a single GameState autoload is the only script that touches storage and the only one that writes the HUD; pickups, doors, triggers and enemies just call named functions on it. Nothing else in the project needs to know what a save file looks like.
#Time, timers and tweens
Scoped to the behaviour that created them, so cleanup is not your problem.
There is no setTimeout. The time object owns scheduling, and every timer it hands out is owned by the behaviour that asked for it: when the node dies, the script reloads, or the scene changes, the timer goes with it. A callback can also cancel itself.
| Call | Behaviour |
|---|---|
time.delta() | Seconds since the previous frame. |
time.elapsed() | Seconds since the run started. |
time.wait(seconds, fn) | One-shot. Returns a timer id. |
time.every(interval, fn) | Repeating. The interval must be greater than zero. |
time.tween(duration, fn, easing?) | Calls fn(value) each frame with the eased progress. |
time.cancel(id) | Cancel one of this script's timers. |
The easings are linear (the default), inQuad, outQuad, inOutQuad and outBack. Anything else throws a RangeError. Note that outBack overshoots, so the value handed to your callback can exceed 1.
// scripts/player.js (excerpt) — a muzzle flash that switches itself off, and
// a pooled impact that parks itself out of sight afterwards.
function fire() {
if (muzzle !== null) {
muzzle.setEnabled(true);
time.wait(0.06, function () { muzzle.setEnabled(false); });
}
}
function popImpact(x, y, z) {
const fx = impacts[impactCursor % impacts.length];
impactCursor += 1;
fx.setPosition(x, y, z);
fx.setEnabled(true);
time.wait(0.25, function () {
fx.setEnabled(false);
fx.setPosition(0.0, -200.0, 0.0);
});
}
// A tween hands its callback the eased value, 0 -> 1.
time.tween(0.4, function (t) { node.setPosition(0.0, t * 2.0, 0.0); }, "outBack");#Input
You read actions, never keys. Bindings are data, and a game may rewrite them at runtime.
An action aggregates every control bound to it across keyboard, mouse, gamepad and touch, so releasing one device does not end an action another one is still holding. The engine installs these defaults before your project touches anything:
| Action | Keyboard / mouse | Gamepad |
|---|---|---|
MoveForward / MoveBackward | W / S | Left stick Y |
MoveLeft / MoveRight | A / D | Left stick X |
MoveUp / MoveDown | E / Q | — |
LookLeft / LookRight / LookUp / LookDown | — | Right stick |
Jump | Space | A |
Sprint | Left Shift | Left thumb |
Fire | Left mouse button | Right trigger |
Aim | Right mouse button | Left trigger |
// scripts/player.js (excerpt) — Fire already sits on the left mouse button in
// the engine defaults, so the profile is ADDED to rather than replaced:
// replacing it would drop every movement binding this game still relies on.
function onReady() {
input.bindKey("Restart", "R");
}
function onUpdate(dt) {
if (input.justPressed("Restart")) {
tree.reloadScene();
return;
}
if (input.isHeld("Fire") && fireCooldown <= 0.0) fire();
}// scripts/pong.js (excerpt) — applyProfile REPLACES the engine defaults, so the
// standard movement actions are restated here rather than lost.
const INPUT_PROFILE = {
schema: 1,
name: "pong3d",
bindings: [
{ action: "PaddleLeft", context: "Global", device: "keyboard", control: "A" },
{ action: "PaddleRight", context: "Global", device: "keyboard", control: "D" },
{ action: "Serve", context: "Global", device: "keyboard", control: "Space" },
{ action: "PaddleLeft", context: "Global", device: "gamepad-axis", control: "LeftX", scale: -1.0 },
{ action: "MoveForward", context: "Global", device: "keyboard", control: "W" },
{ action: "MoveBackward", context: "Global", device: "keyboard", control: "S" }
]
};
function onReady() {
input.applyProfile(JSON.stringify(INPUT_PROFILE));
}
function onUpdate(dt) {
const direction = input.axis("PaddleLeft", "PaddleRight");
playerX = playerX + direction * props.playerSpeed * dt;
}For a remapping screen, the round trip is input.exportProfile(name) → storage.prefs.save(slot, json) at save time, and storage.prefs.load(slot) → input.applyProfile(json) at boot. The document is fully validated before anything is replaced, so an unknown control or an out-of-range deadzone leaves the live profile untouched.
input.lastActiveDevice() returns "none", "keyboard-mouse", "gamepad" or "touch"and is what adaptive prompts read — see the prompt recipe. input.inject() and input.injectDevice() exist for tests and CI, not for gameplay.
#Physics queries
Two queries, both fail-soft: with no physics world they answer nothing rather than throwing.
// scripts/driver.js (excerpt) — the nearest vehicle whose COLLIDER the player
// can reach, not whose origin is close: a van is four metres long, so measuring
// to its centre would let someone open it from inside its own bonnet.
function reachableCar(p) {
const me = p.getPosition();
const hits = physics.overlapSphere(me, REACH);
let best = null;
let bestDistance = 0.0;
for (let i = 0; i < hits.length; i += 1) {
const h = hits[i];
if (!h.valid() || !h.isInGroup("vehicle")) continue;
const d = distance(me, h.getPosition());
if (best === null || d < bestDistance) { best = h; bestDistance = d; }
}
return best;
}physics.raycast(origin, direction, maxDistance, opts?) returns null or { point, normal, distance, node }, where node is a NodeRef (or null when the body owns no node). physics.overlapSphere(center, radius, opts?) returns an array of NodeRef. Both accept { hitSensors?: boolean, ignoreSelf?: boolean }; sensors are excluded by default and the caller's own body is ignored by default.
Because overlapSpheretests the real collider, a four-metre van costs nothing extra to reach for — measuring to a node origin instead would let a player open a van from inside its own bonnet and refuse them at the back doors.
// scripts/player.js (excerpt) — the camera ray has to start PAST the player:
// ignoreSelf does not cover a CharacterBody's inner body, so a ray from the
// camera would report the player's own capsule at nearly zero distance.
const p = node.getPosition();
const ahead = (p.x - cam.x) * dir.x + (p.y - cam.y) * dir.y + (p.z - cam.z) * dir.z;
const clearance = Math.max(0.0, ahead) + 0.8;
const origin = {
x: cam.x + dir.x * clearance,
y: cam.y + dir.y * clearance,
z: cam.z + dir.z * clearance
};
const seen = physics.raycast(origin, dir, props.range, { ignoreSelf: true });#Character and vehicle
Drive the native solvers instead of reimplementing them, or suspend one and take the wheel.
Every gameplay call on node resolves the same way: the behaviour on this node, else the first one found in the subtree below it. A controller script therefore works whether it sits on the body itself or on a wrapper node above it, and a node with no such behaviour answers false or null rather than throwing.
Character
characterMove(x, y) is the stick, in camera space, set every frame like real input. characterJump() goes through the whole ruleset the Characterbehaviour was tuned with — jump buffer, coyote time, jump chain, jump count — while characterLaunch() and characterImpulse() bypass all of it, which is what a special move is built from. characterState() gives you the whole controller in one object instead of nine calls:
// scripts/player.js (excerpt) — footsteps and landing, read from the
// controller's own published state rather than re-derived from positions.
function onUpdate(dt) {
const state = node.characterState();
if (state === null) return;
if (state.grounded && state.speed > 1.2) {
stepTimer -= dt * (state.speed / 5.0);
if (stepTimer <= 0.0) {
stepTimer = 0.36;
audio.play(stepFlip ? "step_a" : "step_b");
}
}
if (!state.grounded) {
wasAirborne = true;
} else if (wasAirborne) {
wasAirborne = false;
audio.play("land");
}
}When your intent does not fit the solver — an AI that thinks in world space while characterMove is camera-relative — suspend it with characterSolver(false) and write setVelocity() yourself. The Characterbehaviour keeps picking idle and run clips from the body's real velocity, so the animation still works.
// scripts/enemy.js (excerpt) — the engine's Character behaviour owns the
// animation and the capsule; this script owns the intent. That intent is in
// world space while setMoveInput is camera-relative, so the solver is suspended
// and the script integrates the body itself.
function onReady() {
player = tree.firstInGroup("player");
// This script writes the velocity directly, so the built-in solver must not
// also be writing it — they would fight every frame.
node.characterSolver(false);
}
function onUpdate(dt) {
const grounded = node.isOnFloor();
if (grounded && verticalSpeed < 0.0) verticalSpeed = 0.0;
verticalSpeed -= props.gravity * dt;
const a = node.getPosition();
const b = player.getPosition();
let dx = b.x - a.x;
let dz = b.z - a.z;
const flat = Math.sqrt(dx * dx + dz * dz);
if (flat > 1e-3) {
dx /= flat;
dz /= flat;
node.characterFace(dx, dz, false);
}
node.setVelocity(dx * props.speed, verticalSpeed, dz * props.speed);
}Vehicle
The raycast vehicle is reached the same way: vehicleDrive(throttle, steer), vehicleBrake(v), vehicleHandbrake(on), vehicleInput(enabled) and vehicleState(). These five are the only gameplay calls that also exist on NodeRef, and that is deliberate: a driver is not usually the car's own script. A player picks one car out of several within reach and drives that one; a script per car would put every parked car in the city in a race to answer the same key press.
vehicleInput(false) is the handover switch. A character and a vehicle read the samemovement actions, so exactly one of them may be listening — otherwise a parked car steers itself off the kerb whenever the player walks. Turning it off also releases whatever the keyboard was last holding, so a car cannot change hands mid-throttle.
// scripts/driver.js (excerpt) — one driver script holds at most one car.
// A character and a vehicle read the SAME movement actions, so exactly one of
// them may be listening: 32 parked cars would otherwise race to answer the same
// key press.
function onReady() {
input.bindKey("Interact", "F");
const all = tree.nodesInGroup("vehicle");
for (let i = 0; i < all.length; i += 1) {
all[i].vehicleInput(false);
all[i].vehicleHandbrake(true);
}
}
function enter(p, target) {
car = target;
// The camera follows a GROUP, so handing the car that membership is what
// carries the view across.
p.removeFromGroup("camera_target");
car.addToGroup("camera_target");
p.setVelocity(0.0, 0.0, 0.0);
p.setEnabled(false);
car.vehicleHandbrake(false);
}
function onUpdate() {
if (car === null) return;
// The very actions the character would have read, spent on the car instead.
const move = input.vector("MoveLeft", "MoveRight", "MoveBackward", "MoveForward");
car.vehicleDrive(move.y, move.x);
car.vehicleHandbrake(input.isHeld("Jump"));
}#Animation and audio
Play a clip directly, or feed the parameters an authored graph transitions on.
| Call | Reaches | Effect |
|---|---|---|
playClip(name, loop?, crossfade?) | Animator | Play a clip. crossfade defaults to 0.2 s. |
currentClip() | Animator | The clip currently playing, or null. |
setAnimFloat / setAnimBool / setAnimTrigger | Animator | Write animation parameters, which drive the transitions of a .sgraph. |
playSequence() / stopSequence() | SequenceDirector | Drive an authored .sseq sequence. |
audio.play(alias) | AudioManager | Play a project audio alias. |
Three reflected signals close the loop back into script: animationEvent on the Animator, sequenceEvent and sequenceFinished on the SequenceDirector. Subscribe to them with node.on(...) to hang a footstep sound, a hit frame or a cutscene ending off the animation itself instead of a timer.
audio.play takes an alias, never a file path. Aliases are declared in the .saidaproj under audio.aliases, which keeps the file layout out of gameplay code.
#Saving progress
Opaque string slots in two namespaces. The game owns the format; the engine owns the durability.
storage.* holds progression, storage.prefs.*holds preferences, and the two are completely separate: erasing a save never touches the settings. Both expose the same six calls — save, load, has, remove, info, list.
// scripts/game_state.mjs (excerpt) — the engine stores an opaque string, so
// the game owns its own format.
const SLOT = "verdance";
function readBest() {
const raw = storage.load(SLOT);
if (raw === null) return 0;
try {
return Number(JSON.parse(raw).best) || 0;
} catch (_) {
return 0;
}
}
function persistBest() {
if (score > best) best = score;
if (!storage.save(SLOT, JSON.stringify({ best: best }))) {
const failure = storage.lastError();
console.warn("[GameState] save failed: " + failure.status + " " + failure.message);
}
}
// Visibility is synchronous; durability is not. Await the flush before quitting.
async function saveAndQuit() {
persistBest();
await storage.flush();
tree.quit();
}| Rule | Detail |
|---|---|
| Slot names | [A-Za-z0-9_-], 1 to 64 characters. |
| Quotas | 1 MiB per slot, 16 MiB per namespace, 256 slots. An overshoot fails false. |
| Errors | storage.lastError() returns { status, message }, with status among invalid_slot, quota_exceeded, not_found, corrupt and io_error. |
| Rejected saves | A save whose envelope is not the current schema is refused, not guessed at: load returns null and sets lastError. |
| Visibility | Synchronous. A load right after a save returns the new value. |
| Durability | Asynchronous. await storage.flush() resolves true once pending writes are durable, false on failure, and never rejects. |
| Location | A packaged game writes under the OS user data folder, never next to its executable. The Web player uses IndexedDB. |
| Versioning | storage.save(slot, json, dataVersion) records your schema number, readable later through storage.info(slot) without parsing the payload. |
#Streaming assets
Ask on one frame, poll on a later one. Nothing here blocks and nothing returns a promise.
// Nothing here blocks, and nothing returns a promise: ask on one frame, poll
// the handle on a later one.
let doorMesh = null;
function onReady() {
doorMesh = assets.load("assets/models/vault_door.glb", "high");
}
function onUpdate() {
if (doorMesh === null) return;
if (doorMesh.ready()) {
console.log("vault door ready, " + doorMesh.size() + " bytes");
doorMesh = null;
} else if (doorMesh.failed()) {
console.error("vault door failed: " + doorMesh.error());
doorMesh = null;
}
}assets.load(path, priority) takes a project-relative path and one of "low", "normal", "high" or "critical". The handle it returns answers state(), ready(), failed(), error(), size() and id(), and release() drops the reference so the loader may evict the asset again.
assets.stats() reports the loader in flight and at rest — live, queued, loading, ready, failed, failedTotal, streamedFetches, residentBytes, budgetBytes, plus the GPU residency counters. It is the honest way to build a loading screen that reflects real work rather than a fake progress bar.
#Modules
Share helpers between scripts with ordinary ES imports, inside the project only.
// scripts/lib/math2d.mjs
export function clamp(value, low, high) {
if (value < low) return low;
if (value > high) return high;
return value;
}
// scripts/turret.mjs — the specifier is project-relative and resolved next to
// the importing file. It may not leave the project root.
import { clamp } from "lib/math2d.mjs";
export function onUpdate(dt) {
yaw = clamp(yaw + dt, -1.2, 1.2);
}| Rule | Detail |
|---|---|
| Specifiers | Project-relative only. Absolute paths, drive letters and URLs are refused. |
| Resolution | Relative to the importing file's own directory. The entry script resolves from the project root. |
| Extensions | .js and .mjs only. |
| Boundary | An import that resolves outside the project root is refused, symlinks included. |
| Hot reload | Modules that were actually imported are watched too, so editing a shared helper reloads its dependents. |
#UI scripting
A WebCanvas document is HTML, CSS and JavaScript — with a deliberately small DOM.
A WebCanvasNode renders an HTML/CSS document through RmlUi and gives its script a different context from a ScriptBehaviour: it has document and tree, and nothing else. No node, no time, no input, and no ambient browser API either — there is no window, no fetch, no global timer.
// ui/main_menu.js — a WebCanvas document script. This context has `document`
// and `tree`, and nothing else: no node, no time, no input.
const playButton = document.getElementById("play-button");
const statusCopy = document.getElementById("status-copy");
let launchRequested = false;
function beginJourney() {
if (launchRequested) return;
launchRequested = true;
statusCopy.textContent = "OPENING THE WILDS";
if (!tree.changeScene("scenes/verdance.scene")) {
launchRequested = false;
statusCopy.textContent = "THE WILDS COULD NOT BE OPENED";
}
}
playButton.addEventListener("click", beginJourney);
document.getElementById("quit-button").addEventListener("click", function () {
tree.quit();
});| Surface | Available |
|---|---|
document | getElementById, querySelector, querySelectorAll, body, documentElement, setText, setHTML, reload. |
element | addEventListener, removeEventListener, click, focus, blur, querySelector, querySelectorAll, getBoundingClientRect. |
element properties | id, textContent, innerHTML, innerRML, classList, style, offsetLeft/Top/Width/Height, clientWidth/Height. |
classList | add, remove, toggle, contains. |
style | setProperty, removeProperty. |
tree | changeScene, reloadScene, quit, setPaused, and pausedas a property getter — not a function, unlike the script context. |
Documents hot-reload transactionally, exactly like scripts: a document that fails to load leaves the previous one on screen. For a simple heads-up display you can also stay in the scene tree and drive UITextNode nodes with node.setText(), which is what both sample games do.
#API reference
Every global the engine installs, and every method on it.
node
The node this ScriptBehaviour is attached to. Positions and rotations are the node's own transform, so on a child they are relative to the parent, not to the world.
| Signature | Notes |
|---|---|
node.getName() → string | The node's name. |
node.setName(name) → boolean | Rename the node. |
node.getPosition() → {x, y, z} | Local position. |
node.setPosition(x, y, z) | node.setPosition({x, y, z}) → boolean | Write the local position. Both call shapes are accepted everywhere a vector is taken. |
node.translate(x, y, z) → boolean | Add to the local position. |
node.getRotation() → {x, y, z, w} | Local rotation, as a quaternion. |
node.setRotation(x, y, z, w) → boolean | Write the local rotation. A non-finite or zero-length quaternion is refused (returns false) rather than repaired. |
node.setEnabled(enabled) → boolean | Enable or disable the node. A disabled node stops running its behaviours. |
node.queueFree() → boolean | Deferred destruction. Every NodeRef pointing into the removed subtree becomes invalid. |
node.addToGroup(name) → boolean | Join a group at runtime. |
node.removeFromGroup(name) → boolean | Leave a group. |
node.isInGroup(name) → boolean | Group membership test. |
node.setText(text) → boolean | UITextNode only; false on any other node. |
node.getText() → string | null | UITextNode only. |
node.worldBounds() → {min, max, size, center, meshes, triangles} | null | World-space bounds of the whole subtree. null when nothing in it draws. |
node.meshBounds() → {…} | null | Same shape, for this node's own mesh only. |
node.setVelocity(x, y, z) → boolean | Velocity the engine integrates during the physics step. Resolves the node's CharacterBody, else the first one below it; false without one. |
node.getVelocity() → {x, y, z} | Reads the same velocity; zero without a CharacterBody. |
node.isOnFloor() → boolean | Floor state published by the last physics step. |
node.isOnSteepSlope() → boolean | Standing on ground steeper than the body's max slope. |
node.groundNormal() → {x, y, z} | Contact normal; world up when there is no contact. |
node.characterMove(x, y) → boolean | Per-frame stick intent in camera space, for the Character behaviour. |
node.characterSprint(on) → boolean | Sprint intent for this frame. |
node.characterJump() → boolean | Jump through the full ruleset (buffer, coyote, chain, jump count). |
node.characterReleaseJump() → boolean | Jump released: applies the cutoff multiplier if still rising. |
node.characterLaunch(x, y, z) → boolean | Set the take-off velocity outright. |
node.characterImpulse(x, y, z) → boolean | Add an impulse to the controller. |
node.characterFace(x, z, instant?) → boolean | Face a world direction; instant skips the configured easing. |
node.characterSolver(enabled) → boolean | Suspend the built-in solver so the script owns velocity and facing until it resumes. |
node.characterState() → {…} | null | grounded, jumping, skidding, sprinting, speed, airTime, jumpsUsed, jumpChainIndex, facingYaw, wantedX, wantedZ. null without a controller. |
node.vehicleDrive(throttle, steer) → boolean | Both axes at once, for the raycast vehicle. |
node.vehicleBrake(value) → boolean | Brake pressure. |
node.vehicleHandbrake(on) → boolean | Handbrake. |
node.vehicleInput(enabled) → boolean | Hands the wheel between the shared movement actions and the script. Turning it off also releases whatever was held. |
node.vehicleState() → {…} | null | speed, forwardSpeed, grounded, wheelsOnGround, throttle, steer, handbrake, readsInput. |
node.playClip(name, loop?, crossfade?) → boolean | Animator on the node or the first one below it. crossfade defaults to 0.2 s. |
node.currentClip() → string | null | Name of the clip currently playing. |
node.setAnimFloat(name, value) → boolean | Animation parameter; drives the transitions of a .sgraph. |
node.setAnimBool(name, value) → boolean | Boolean animation parameter. |
node.setAnimTrigger(name) → boolean | One-shot animation trigger. |
node.playSequence() → boolean | SequenceDirector on the node or below it. |
node.stopSequence() → boolean | Stop the sequence. |
node.setData(key, value) → boolean | Blackboard write. number, boolean or string only; anything else throws a TypeError. |
node.getData(key, fallback?) → number | boolean | string | null | Blackboard read. |
node.hasData(key) → boolean | Blackboard membership test. |
node.on(signal, handler) → boolean | Subscribe to a reflected signal on this node or one of its behaviours. false when the signal does not exist. |
node.emit(signal, ...args) → boolean | Emit a reflected signal. Arguments must be JSON-compatible. |
NodeRef
What tree.firstInGroup, tree.nodesInGroup, tree.nodeById, tree.autoload and physics queries hand back: a weak reference resolved by node id. It has the same transform, group, text, animation and blackboard surface as node, plus call(). It has no character* methods — drive your own character through node, and other people's vehicles through a NodeRef.
| Signature | Notes |
|---|---|
ref.id → BigInt | The stable node id this reference resolves through. |
ref.valid() → boolean | The only call that is safe after the target is freed. Every other one throws a ReferenceError. |
ref.call(exportName, ...args) → any | Call a function on a ScriptBehaviour of the target node, across QuickJS contexts. Arguments and the return value cross as JSON. |
ref.on(signal, handler) → boolean | Subscribe to a reflected signal on another node. |
ref.emit(signal, ...args) → boolean | Emit a reflected signal on another node. |
ref.getName / setName / getPosition / setPosition / translate / getRotation / setRotation | Transform and identity, identical to the node forms. |
ref.setVelocity / getVelocity / isOnFloor / isOnSteepSlope / groundNormal | Character body state, identical to the node forms. |
ref.vehicleDrive / vehicleBrake / vehicleHandbrake / vehicleInput / vehicleState | Vehicle control, identical to the node forms. This is how one driver script holds one car out of many. |
ref.setEnabled / queueFree / setText / getText / worldBounds / meshBounds | Activation, deferred removal, UI text and measurement. |
ref.addToGroup / removeFromGroup / isInGroup | Groups. |
ref.playClip / currentClip / setAnimFloat / setAnimBool / setAnimTrigger / playSequence / stopSequence | Animation and sequences. |
ref.setData / getData / hasData | Blackboard. |
time
The frame clock and the timer queue. Every timer is owned by the behaviour that created it, so it is cancelled when the node dies or the script reloads. There is no setTimeout.
| Signature | Notes |
|---|---|
time.delta() → number | Seconds since the previous frame. onUpdate receives the same value as its argument. |
time.elapsed() → number | Seconds since the run started. |
time.wait(seconds, callback) → timerId | One-shot. |
time.every(interval, callback) → timerId | Repeating. The interval must be greater than zero. |
time.tween(duration, callback, easing?) → timerId | The callback receives the eased value each frame. easing is "linear" (default), "inQuad", "outQuad", "inOutQuad" or "outBack". |
time.cancel(timerId) → boolean | Cancel a timer created by this script. false when the id is unknown. |
input
Actions, never raw keys. An action aggregates every binding across keyboard, mouse, gamepad and touch, so releasing one device does not end an action another one is holding.
| Signature | Notes |
|---|---|
input.isHeld(action) → boolean | The action is currently active. |
input.justPressed(action) → boolean | Rising edge, this frame. |
input.justReleased(action) → boolean | Falling edge, this frame. |
input.strength(action) → number | Analog force, 0 to 1. |
input.axis(negativeAction, positiveAction) → number | One axis from two actions, −1 to 1. |
input.vector(left, right, down, up) → {x, y} | A stick from four actions. |
input.mousePosition() → {x, y} | Cursor position in pixels. |
input.mouseDelta() → {x, y} | Cursor movement since the previous frame. |
input.lastActiveDevice() → string | "none", "keyboard-mouse", "gamepad" or "touch". What adaptive prompts read. |
input.bindKey(action, control, context?) → boolean | Add a keyboard control to an action, keeping the existing ones. |
input.bindGamepadButton(action, control, context?) → boolean | Add a pad button. |
input.bindGamepadAxis(action, control, scale?, deadzone?, context?) → boolean | Add a pad axis. scale within ±10, deadzone within [0, 0.99]. |
input.rebindKey(action, control, context?) → boolean | Replace the action's controls with this key. |
input.rebindMouse(action, control, context?) → boolean | Replace with a mouse button. |
input.rebindGamepadButton(action, control, context?) → boolean | Replace with a pad button. |
input.rebindGamepadAxis(action, control, scale?, deadzone?, context?) → boolean | Replace with a pad axis. |
input.rebindTouch(action, gesture, minX, minY, maxX, maxY, minDistance?, context?) → boolean | Bind a touch gesture to a normalised [0, 1] screen zone. |
input.exportProfile(name?) → string | Serialise the live bindings as schema-1 JSON. Persist it in storage.prefs. |
input.applyProfile(json) → boolean | Validate the whole document, then replace every binding. It replaces — a profile that omits the movement actions loses them. |
input.rumble(low, high, durationMs) → boolean | Web only; desktop returns false. Magnitudes in [0, 1], duration up to 5000 ms. |
input.stopRumble() → boolean | Stop the current effect. |
input.inject(action, strength?) → boolean | Tests and CI only: drive an action with no device attached. |
input.injectDevice(deviceName) → boolean | Tests and CI only: simulate device activity for lastActiveDevice. |
tree
The scene tree: transitions, lookup and the persistent autoload layer.
| Signature | Notes |
|---|---|
tree.changeScene(path) → boolean | Queue a deferred switch of the current sub-scene. true means the request was queued, not that the destination loaded — keep a retry path for a transition you cannot lose. |
tree.reloadScene() → boolean | Reload the current sub-scene. |
tree.quit() → boolean | Ask the runtime to exit. |
tree.setPaused(paused) → boolean | Pause or resume. |
tree.paused() → boolean | Current pause state. |
tree.autoload(name) → NodeRef | null | The autoload node registered under that name in the project. |
tree.firstInGroup(name) → NodeRef | null | First node in a group. |
tree.nodesInGroup(name) → NodeRef[] | Every node in a group, in tree order. |
tree.nodeById(id) → NodeRef | null | Re-resolve a node id, e.g. one kept from ref.id. |
physics
Scene queries against the Jolt world. They fail soft: with no physics world (edit mode, no bodies) they answer null or an empty list, never an exception.
| Signature | Notes |
|---|---|
physics.available() → boolean | The platform exposes the physics capability. The world itself is created lazily in Play. |
physics.raycast(origin, direction, maxDistance, opts?) → {point, normal, distance, node} | null | origin and direction are {x, y, z}. node is a NodeRef, or null when the body owns no node. |
physics.overlapSphere(center, radius, opts?) → NodeRef[] | Every body whose real shape overlaps the sphere. |
opts = {hitSensors?: boolean, ignoreSelf?: boolean} | Sensors are excluded by default; the caller's own body (node or nearest ancestor) is ignored by default. |
audio
Aliases declared in the project file, so a script never names a file on disk.
| Signature | Notes |
|---|---|
audio.play(alias) → boolean | Play an alias from the .saidaproj "audio.aliases" map. false when the alias is unknown. |
assets
Streaming requests. Nothing here blocks and nothing returns a promise: you ask, then poll the handle on a later frame.
| Signature | Notes |
|---|---|
assets.load(path, priority?) → AssetHandle | Project-relative path. priority is "low", "normal" (default), "high" or "critical". |
handle.state() → string | "queued", "loading", "ready" or "failed". |
handle.ready() → boolean | Loaded and usable. |
handle.failed() → boolean | The request failed; handle.error() says why. |
handle.error() → string | Failure message, empty when there is none. |
handle.size() → number | Bytes held by the handle. |
handle.id() → BigInt | The AssetID. |
handle.release() → undefined | Drop the reference so the loader may evict the asset. |
assets.stats() → {…} | live, queued, loading, ready, failed, failedTotal, streamedFetches, residentBytes, budgetBytes, gpuResidentBytes, gpuBudgetBytes, gpuEvictedCount, gpuEvictedBytes. |
assets.setGpuBudget(bytes) → boolean | Move the GPU residency budget at runtime. |
storage
Two independent namespaces of opaque string slots: storage.* holds progression, storage.prefs.* holds preferences. Erasing a save never touches the settings. The game does its own JSON.stringify / JSON.parse; the engine stores the string and adds a versioned envelope around it.
| Signature | Notes |
|---|---|
storage.save(slot, jsonString, dataVersion?) → boolean | Atomic write. dataVersion is the game's own schema number and defaults to 0. |
storage.load(slot) → string | null | null when absent, and also when the envelope is rejected — check storage.lastError(). |
storage.has(slot) → boolean | Slot existence. |
storage.remove(slot) → boolean | true when a slot was actually removed. |
storage.info(slot) → {kind, bytes, savedAt, dataVersion, schema} | null | Metadata without reading the payload — what a save-slot menu needs. |
storage.list() → string[] | Every slot in the namespace. |
storage.lastError() → {status, message} | null | Last non-throwing failure. status is "invalid_slot", "quota_exceeded", "not_found", "corrupt" or "io_error". |
storage.flush() → Promise<boolean> | Resolves true once pending saves and prefs are durable, false on failure. Never rejects. Visibility is already synchronous; this is about durability. |
storage.prefs.save / load / has / remove / info / list | The same six calls, on the preferences namespace. |
Script globals
What a ScriptBehaviour gets on top of the engine capabilities.
| Signature | Notes |
|---|---|
exportProperty(name, defaultValue) → boolean | Declare an inspector field. number, boolean and string only. Call it at the top level, on every load. |
props.<name> | The current value of an exported property, kept in sync with the inspector and the saved scene. |
console.log(...) / console.warn(...) / console.error(...) | Writes to the engine log. |
onReady() / onUpdate(dt) / onDestroy() / onEnable() / onDisable() | The lifecycle hooks. Global functions in a .js file, named exports in a .mjs one. |
#Sandbox and limits
A script has no authority it was not explicitly given. That is a design decision, not an oversight.
The scripting runtime is capability-based: the engine installs a fixed list of globals into a bare QuickJS context and nothing else exists. A downloaded project, an AI-generated script and a hand-written one all run under the same ceiling.
| Not available | What to use instead |
|---|---|
fetch, XMLHttpRequest, WebSocket | Nothing. A script has no network access at all. |
Filesystem, std, os | storage and storage.prefs. |
Process and environment access | Nothing. quickjs-libc is not linked in. |
setTimeout, setInterval | time.wait, time.every, time.tween. |
window, document | Neither exists in a script context. document exists only inside a WebCanvas document. |
| Budget | Value | What happens at the limit |
|---|---|---|
| Execution time per entry | 100 ms | The call is interrupted. A hostile or looping script can freeze its own frame, not the process. |
| Microtask drain | 1024 jobs | The chain is stopped and the log says pending job budget exceeded. |
| Memory | 64 MiB for the whole runtime | Allocation fails inside QuickJS. |
| Stack | 1 MiB on desktop, 256 KiB on Web | Deep recursion throws instead of crashing. |
Script paths and module imports are confined to the project root, only .js and .mjsare accepted, and traversals, absolute paths and outbound symlinks are refused. The list of installed globals is locked by a test that diffs the script context against a bare QuickJS one, so an authority that appears or disappears fails the engine's test suite.
#Recipes
Small, complete patterns lifted from the games that ship with the engine.
A trigger volume that fires once
An Area node with a script whose only job is to call one named function on the game state. Which function is an exported property, so the same file becomes every progression trigger in the level without a line of new code.
// scripts/trigger.js — a one-shot volume. The first time the player enters it,
// one named function on the autoload is called. Level progression then lives in
// the scene's layout rather than in a distance check somewhere else.
exportProperty("call", "");
let gameState = null;
let fired = false;
function onReady() {
gameState = tree.autoload("GameState");
if (gameState === null) throw new Error("GameState autoload is missing");
node.on("bodyEntered", function (who) {
if (fired || who !== "Player" || props.call === "") return;
fired = true;
gameState.call(props.call);
});
}A doorway that teleports
Interiors are sealed rooms built below the city rather than carved out of solid building models, so entering one is a teleport. The volume is one-way and clears the character's momentum, which would otherwise carry them straight back through the door they arrived in.
// scripts/door.js — a doorway between the street and an interior cell.
// The volume is one-way: it moves the player to its destination and then
// ignores them until they have left, so standing in a doorway does not bounce
// them back and forth.
exportProperty("toX", 0.0);
exportProperty("toY", 0.0);
exportProperty("toZ", 0.0);
let inside = false;
function onReady() {
node.on("bodyEntered", function (who) {
if (who !== "Player" || inside) return;
const player = tree.firstInGroup("player");
if (player === null) return;
inside = true;
player.setPosition(props.toX, props.toY, props.toZ);
// The character keeps its momentum across the move, which would carry it
// straight back through the door it arrived in.
player.setVelocity(0.0, 0.0, 0.0);
});
node.on("bodyExited", function (who) {
if (who === "Player") inside = false;
});
}A moving platform that carries its rider
A StaticBody does not carry anything standing on it, so the platform hands its own frame delta to the player when they are within reach of the deck.
// scripts/platform.js (excerpt) — a StaticBody does not carry a rider, so the
// platform hands its own frame delta to whoever is standing on it. Otherwise
// the player slides off the moment it starts moving.
const delta = offset - previous;
previous = offset;
if (player === null || Math.abs(delta) < 1e-6) return;
const here = node.getPosition();
const p = player.getPosition();
const dx = p.x - here.x;
const dz = p.z - here.z;
const above = p.y - here.y;
if (dx * dx + dz * dz > props.carryRadius * props.carryRadius) return;
if (above < 0.0 || above > props.carryHeight) return;
player.setPosition(p.x + (axis === "x" ? delta : 0.0),
p.y + (axis === "y" ? delta : 0.0),
p.z + (axis === "z" ? delta : 0.0));A prompt that follows the device
The label tracks whichever device the player last actually used, and falls back to the keyboard before any activity.
// scripts/prompt.js — an adaptive prompt attached to a UITextNode. The label
// follows the last active device; before any activity ("none"), the keyboard
// prompt is the default.
const LABELS = {
"keyboard-mouse": "Move: WASD",
"gamepad": "Move: Left Stick",
"touch": "Move: Swipe"
};
const DEFAULT_LABEL = LABELS["keyboard-mouse"];
let last = null;
function refresh() {
const device = String(input.lastActiveDevice());
if (device === last) return;
last = device;
node.setText(LABELS[device] || DEFAULT_LABEL);
}
function onReady() {
refresh();
time.every(0.1, refresh);
}#Troubleshooting
The engine log tells you exactly what went wrong. Here is how to read it.
| What you see | What it means | Fix |
|---|---|---|
no recognized lifecycle hook in ... | The file loaded but none of the five hooks was found. | In a .mjs the hooks must be exported; in a .js they must be plain global functions. |
lifecycle hook 'X' must be a function | The name exists but holds something else. | Usually a variable shadowing the hook, or an arrow function assigned after use. |
project script not found: ... | The Script field points at a file that is not there. | Paths are project-relative. Check the case on a case-sensitive filesystem. |
rejected script path '...' | The path escapes the project root, or is not a .js/.mjs file. | Move the script inside the project. |
keeping previous script after reload failure | Your edit did not compile; the old version is still running. | The parse error is logged just above this line. |
[JS] node.on: no signal 'X' on node 'Y' | No reflected signal by that name on the node or its behaviours. | Check the signal tableand that the behaviour is really attached — bodyEntered needs an Area node. |
NodeRef target no longer exists | A ReferenceError from using a freed reference. | Call valid() first. queueFree invalidates the whole subtree. |
NodeRef target has no callable export 'X' | The target node has no ScriptBehaviour exposing that name. | Check the spelling, and that a .mjs actually exports it. |
pending job budget exceeded (1024) | A runaway promise or microtask chain was stopped. | Look for a self-resolving promise loop inside a hook. |
tree.autoload requires a mounted SceneTree | The call ran outside Play. | Autoloads only exist while the game is running. |
module import escapes the project root | An import resolved outside the project. | Use a project-relative specifier and keep shared code inside the project. |
| Nothing at all in the log | The node or the behaviour is disabled. | A disabled node stops running its behaviours; check the Inspector checkbox. |