Saida Engine
Saida Engine · Developer guide

Scripting Saida Engine

Everything you need to go from an empty node to a shipped mechanic, using the editor and JavaScript. Gameplay code runs on QuickJS inside a ScriptBehaviour, reloads while the game is running, and can only touch what the engine hands it.

LanguageJavaScript. .js for a classic script, .mjs for an ES module.
Unit of codeOne ScriptBehaviour component on one node, with its own QuickJS context.
IterationHot reload is on by default and transactional: a broken save keeps the old script.
SandboxNo network, no filesystem, no OS. storage is the only persistence.

Every signature on this page is transcribed from the engine source, and every example is adapted from a project that ships in the SaidaEngine repository — Pong3D, WitnessGame, VerticalSlice and GTAClone.

#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

GlobalWhat it is
nodeThe node this script is attached to: transform, groups, animation, character and vehicle control.
treeThe scene tree: change scene, pause, quit, and find other nodes by group, id or autoload name.
timeThe frame clock and the timer queue: delta, elapsed, wait, every, tween.
inputActions, axes, sticks, mouse, binding and profiles.
physicsRaycasts and sphere overlaps against the physics world.
audioPlay an alias declared by the project.
assetsStream an asset and inspect the loader budgets.
storageThe only persistence a script has: progression slots and preferences.
consolelog, warn and error, straight into the engine log.
props / exportPropertyInspector 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.

  1. Open your project from the Hub, or launch the editor with SaidaEngine.exe --project /path/to/game.saidaproj.
  2. Create scripts/spinner.js in the project folder. Scripts live inside the project: the engine refuses any path that leaves it.
  3. Select a node in the Scene Tree panel.
  4. In the Inspector, press Add Component and pick ScriptBehaviour.
  5. Set its Script field to scripts/spinner.js — a project-relative path — then press Play in the menu bar.
scripts/spinner.js
// 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:

scenes/hub.scene (fragment)
{
  "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.

HookWhen it runsNotes
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 asClassic scriptES module
Hooks areGlobal functionsNamed exports
Reachable by NodeRef.call()Global functionsNamed exports
import / exportNot availableAvailable, project-relative
scripts/door.js
// 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 */ }
scripts/game_state.mjs
// 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:

  1. The new file is parsed and evaluated into a fresh context.
  2. If it fails, nothing is swapped: the previous script keeps running and the log says keeping previous script after reload failure.
  3. If it succeeds, the old context gets its onDestroy(), its timers are cancelled and its node.on(...) subscriptions are dropped.
  4. 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.

JavaScript
// 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));
}
Adapted from VerticalSlice/scripts/platform.js
RuleDetail
Typesnumber, boolean and string. Anything else is refused with a warning.
When to callAt the top level, on every load. The declaration is what keeps the property alive.
PruningA property that is not re-exported after a reload is dropped, and its saved value with it.
Changing typeRe-exporting a name with a different type resets the value to the new default.
PrecedenceThe 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:

CallReturns
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.
JavaScript
// 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");
}
Adapted from Pong3D/scripts/pong.js

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.

JavaScript
// 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
}
Adapted from GTAClone/scripts/driver.js

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.

ChannelUse it whenCrosses contexts
Signals node.on / node.emitSomething happened and you do not care who is listening.Yes
Calls NodeRef.callYou need an answer from one specific script.Yes, through JSON
Blackboard setData / getDataSeveral behaviours share a small typed state on one node.Yes
Scene wiring — the connections blockThe 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'.

SignalEmitted byPayload
bodyEnteredArea nodeThe other node's name (string)
bodyExitedArea nodeThe other node's name (string)
changedBlackboardThe key that was written (string)
diedHealthNone
stateChangedStateMachineThe new state name (string)
animationEventAnimatorThe event name (string)
sequenceEventSequenceDirectorThe event name (string)
sequenceFinishedSequenceDirectorNone
fullRotationRotatorNone
finishedParticleSystemNone
startedScenarioRunnerNone
stepChangedScenarioRunnerThe current step (string)
finished / failedScenarioRunnerThe result or the error (string)
JavaScript
// 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();
    });
}
WitnessGame/scripts/pickup.js

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.

JavaScript
// 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");
}
Adapted from VerticalSlice/scripts/target.js
JavaScript
// 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);
    }
});
Adapted from VerticalSlice/scripts/player.js

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.

JavaScript
// 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.

scenes/level.scene (fragment)
{
  "schema": 2,
  "version": 2,
  "scene": {
    "type": "Scene",
    "name": "Arena",
    "children": [ "..." ],

    "connections": [
      {
        "from": 13224595248972440569,
        "signal": "bodyEntered",
        "to": 14116644317509485334,
        "slot": "damage"
      }
    ]
  }
}
SlotOnEffect
damage(amount)HealthReduce health; dies at zero.
kill()HealthInstant death.
fire(trigger)StateMachineQueue a one-shot trigger.
goTo(state)StateMachineForce a state.
setNumber / setBool / setStringBlackboardWrite a typed value.
play() / stop()SequenceDirectorDrive an .sseq sequence.
play() / stop() / burst()ParticleSystemDrive an emitter.
reset()RotatorBack to the starting rotation.
start() / stop() / pause() / resume()ScenarioRunnerDrive 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 withWhat is spawned
.sceneA node instantiated from that prefab. Use it when the autoload needs a hierarchy.
.js or .mjsA node carrying a ScriptBehaviour bound to that script. The common case.
anything elseA node carrying that registered behaviour type, e.g. Blackboard.
WitnessGame.saidaproj
{
  "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.

JavaScript
// 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();
}
WitnessGame/scripts/game_state.mjs
JavaScript
// 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);
}
WitnessGame/scripts/hud.js
JavaScript
// 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);
}
Adapted from VerticalSlice/scripts/game_state.mjs

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.

CallBehaviour
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.

JavaScript
// 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");
Adapted from VerticalSlice/scripts/player.js

#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:

ActionKeyboard / mouseGamepad
MoveForward / MoveBackwardW / SLeft stick Y
MoveLeft / MoveRightA / DLeft stick X
MoveUp / MoveDownE / Q
LookLeft / LookRight / LookUp / LookDownRight stick
JumpSpaceA
SprintLeft ShiftLeft thumb
FireLeft mouse buttonRight trigger
AimRight mouse buttonLeft trigger
JavaScript
// 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();
}
Adapted from VerticalSlice/scripts/player.js
JavaScript
// 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;
}
Adapted from Pong3D/scripts/pong.js

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.

JavaScript
// 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;
}
Adapted from GTAClone/scripts/driver.js

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.

JavaScript
// 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 });
Adapted from VerticalSlice/scripts/player.js

#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:

JavaScript
// 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");
    }
}
Adapted from VerticalSlice/scripts/player.js

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.

JavaScript
// 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);
}
Adapted from VerticalSlice/scripts/enemy.js

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.

JavaScript
// 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"));
}
Adapted from GTAClone/scripts/driver.js

#Animation and audio

Play a clip directly, or feed the parameters an authored graph transitions on.

CallReachesEffect
playClip(name, loop?, crossfade?)AnimatorPlay a clip. crossfade defaults to 0.2 s.
currentClip()AnimatorThe clip currently playing, or null.
setAnimFloat / setAnimBool / setAnimTriggerAnimatorWrite animation parameters, which drive the transitions of a .sgraph.
playSequence() / stopSequence()SequenceDirectorDrive an authored .sseq sequence.
audio.play(alias)AudioManagerPlay 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.

JavaScript
// 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();
}
Adapted from VerticalSlice/scripts/game_state.mjs
RuleDetail
Slot names[A-Za-z0-9_-], 1 to 64 characters.
Quotas1 MiB per slot, 16 MiB per namespace, 256 slots. An overshoot fails false.
Errorsstorage.lastError() returns { status, message }, with status among invalid_slot, quota_exceeded, not_found, corrupt and io_error.
Rejected savesA save whose envelope is not the current schema is refused, not guessed at: load returns null and sets lastError.
VisibilitySynchronous. A load right after a save returns the new value.
DurabilityAsynchronous. await storage.flush() resolves true once pending writes are durable, false on failure, and never rejects.
LocationA packaged game writes under the OS user data folder, never next to its executable. The Web player uses IndexedDB.
Versioningstorage.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.

JavaScript
// 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.

JavaScript
// 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);
}
RuleDetail
SpecifiersProject-relative only. Absolute paths, drive letters and URLs are refused.
ResolutionRelative to the importing file's own directory. The entry script resolves from the project root.
Extensions.js and .mjs only.
BoundaryAn import that resolves outside the project root is refused, symlinks included.
Hot reloadModules 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.

JavaScript
// 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();
});
Adapted from VerticalSlice/ui/main_menu.js
SurfaceAvailable
documentgetElementById, querySelector, querySelectorAll, body, documentElement, setText, setHTML, reload.
elementaddEventListener, removeEventListener, click, focus, blur, querySelector, querySelectorAll, getBoundingClientRect.
element propertiesid, textContent, innerHTML, innerRML, classList, style, offsetLeft/Top/Width/Height, clientWidth/Height.
classListadd, remove, toggle, contains.
stylesetProperty, removeProperty.
treechangeScene, 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.

SignatureNotes
node.getName() → stringThe node's name.
node.setName(name) → booleanRename the node.
node.getPosition() → {x, y, z}Local position.
node.setPosition(x, y, z) | node.setPosition({x, y, z}) → booleanWrite the local position. Both call shapes are accepted everywhere a vector is taken.
node.translate(x, y, z) → booleanAdd to the local position.
node.getRotation() → {x, y, z, w}Local rotation, as a quaternion.
node.setRotation(x, y, z, w) → booleanWrite the local rotation. A non-finite or zero-length quaternion is refused (returns false) rather than repaired.
node.setEnabled(enabled) → booleanEnable or disable the node. A disabled node stops running its behaviours.
node.queueFree() → booleanDeferred destruction. Every NodeRef pointing into the removed subtree becomes invalid.
node.addToGroup(name) → booleanJoin a group at runtime.
node.removeFromGroup(name) → booleanLeave a group.
node.isInGroup(name) → booleanGroup membership test.
node.setText(text) → booleanUITextNode only; false on any other node.
node.getText() → string | nullUITextNode only.
node.worldBounds() → {min, max, size, center, meshes, triangles} | nullWorld-space bounds of the whole subtree. null when nothing in it draws.
node.meshBounds() → {…} | nullSame shape, for this node's own mesh only.
node.setVelocity(x, y, z) → booleanVelocity 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() → booleanFloor state published by the last physics step.
node.isOnSteepSlope() → booleanStanding 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) → booleanPer-frame stick intent in camera space, for the Character behaviour.
node.characterSprint(on) → booleanSprint intent for this frame.
node.characterJump() → booleanJump through the full ruleset (buffer, coyote, chain, jump count).
node.characterReleaseJump() → booleanJump released: applies the cutoff multiplier if still rising.
node.characterLaunch(x, y, z) → booleanSet the take-off velocity outright.
node.characterImpulse(x, y, z) → booleanAdd an impulse to the controller.
node.characterFace(x, z, instant?) → booleanFace a world direction; instant skips the configured easing.
node.characterSolver(enabled) → booleanSuspend the built-in solver so the script owns velocity and facing until it resumes.
node.characterState() → {…} | nullgrounded, jumping, skidding, sprinting, speed, airTime, jumpsUsed, jumpChainIndex, facingYaw, wantedX, wantedZ. null without a controller.
node.vehicleDrive(throttle, steer) → booleanBoth axes at once, for the raycast vehicle.
node.vehicleBrake(value) → booleanBrake pressure.
node.vehicleHandbrake(on) → booleanHandbrake.
node.vehicleInput(enabled) → booleanHands the wheel between the shared movement actions and the script. Turning it off also releases whatever was held.
node.vehicleState() → {…} | nullspeed, forwardSpeed, grounded, wheelsOnGround, throttle, steer, handbrake, readsInput.
node.playClip(name, loop?, crossfade?) → booleanAnimator on the node or the first one below it. crossfade defaults to 0.2 s.
node.currentClip() → string | nullName of the clip currently playing.
node.setAnimFloat(name, value) → booleanAnimation parameter; drives the transitions of a .sgraph.
node.setAnimBool(name, value) → booleanBoolean animation parameter.
node.setAnimTrigger(name) → booleanOne-shot animation trigger.
node.playSequence() → booleanSequenceDirector on the node or below it.
node.stopSequence() → booleanStop the sequence.
node.setData(key, value) → booleanBlackboard write. number, boolean or string only; anything else throws a TypeError.
node.getData(key, fallback?) → number | boolean | string | nullBlackboard read.
node.hasData(key) → booleanBlackboard membership test.
node.on(signal, handler) → booleanSubscribe to a reflected signal on this node or one of its behaviours. false when the signal does not exist.
node.emit(signal, ...args) → booleanEmit 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.

SignatureNotes
ref.id → BigIntThe stable node id this reference resolves through.
ref.valid() → booleanThe only call that is safe after the target is freed. Every other one throws a ReferenceError.
ref.call(exportName, ...args) → anyCall a function on a ScriptBehaviour of the target node, across QuickJS contexts. Arguments and the return value cross as JSON.
ref.on(signal, handler) → booleanSubscribe to a reflected signal on another node.
ref.emit(signal, ...args) → booleanEmit a reflected signal on another node.
ref.getName / setName / getPosition / setPosition / translate / getRotation / setRotationTransform and identity, identical to the node forms.
ref.setVelocity / getVelocity / isOnFloor / isOnSteepSlope / groundNormalCharacter body state, identical to the node forms.
ref.vehicleDrive / vehicleBrake / vehicleHandbrake / vehicleInput / vehicleStateVehicle control, identical to the node forms. This is how one driver script holds one car out of many.
ref.setEnabled / queueFree / setText / getText / worldBounds / meshBoundsActivation, deferred removal, UI text and measurement.
ref.addToGroup / removeFromGroup / isInGroupGroups.
ref.playClip / currentClip / setAnimFloat / setAnimBool / setAnimTrigger / playSequence / stopSequenceAnimation and sequences.
ref.setData / getData / hasDataBlackboard.

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.

SignatureNotes
time.delta() → numberSeconds since the previous frame. onUpdate receives the same value as its argument.
time.elapsed() → numberSeconds since the run started.
time.wait(seconds, callback) → timerIdOne-shot.
time.every(interval, callback) → timerIdRepeating. The interval must be greater than zero.
time.tween(duration, callback, easing?) → timerIdThe callback receives the eased value each frame. easing is "linear" (default), "inQuad", "outQuad", "inOutQuad" or "outBack".
time.cancel(timerId) → booleanCancel 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.

SignatureNotes
input.isHeld(action) → booleanThe action is currently active.
input.justPressed(action) → booleanRising edge, this frame.
input.justReleased(action) → booleanFalling edge, this frame.
input.strength(action) → numberAnalog force, 0 to 1.
input.axis(negativeAction, positiveAction) → numberOne 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?) → booleanAdd a keyboard control to an action, keeping the existing ones.
input.bindGamepadButton(action, control, context?) → booleanAdd a pad button.
input.bindGamepadAxis(action, control, scale?, deadzone?, context?) → booleanAdd a pad axis. scale within ±10, deadzone within [0, 0.99].
input.rebindKey(action, control, context?) → booleanReplace the action's controls with this key.
input.rebindMouse(action, control, context?) → booleanReplace with a mouse button.
input.rebindGamepadButton(action, control, context?) → booleanReplace with a pad button.
input.rebindGamepadAxis(action, control, scale?, deadzone?, context?) → booleanReplace with a pad axis.
input.rebindTouch(action, gesture, minX, minY, maxX, maxY, minDistance?, context?) → booleanBind a touch gesture to a normalised [0, 1] screen zone.
input.exportProfile(name?) → stringSerialise the live bindings as schema-1 JSON. Persist it in storage.prefs.
input.applyProfile(json) → booleanValidate the whole document, then replace every binding. It replaces — a profile that omits the movement actions loses them.
input.rumble(low, high, durationMs) → booleanWeb only; desktop returns false. Magnitudes in [0, 1], duration up to 5000 ms.
input.stopRumble() → booleanStop the current effect.
input.inject(action, strength?) → booleanTests and CI only: drive an action with no device attached.
input.injectDevice(deviceName) → booleanTests and CI only: simulate device activity for lastActiveDevice.

tree

The scene tree: transitions, lookup and the persistent autoload layer.

SignatureNotes
tree.changeScene(path) → booleanQueue 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() → booleanReload the current sub-scene.
tree.quit() → booleanAsk the runtime to exit.
tree.setPaused(paused) → booleanPause or resume.
tree.paused() → booleanCurrent pause state.
tree.autoload(name) → NodeRef | nullThe autoload node registered under that name in the project.
tree.firstInGroup(name) → NodeRef | nullFirst node in a group.
tree.nodesInGroup(name) → NodeRef[]Every node in a group, in tree order.
tree.nodeById(id) → NodeRef | nullRe-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.

SignatureNotes
physics.available() → booleanThe platform exposes the physics capability. The world itself is created lazily in Play.
physics.raycast(origin, direction, maxDistance, opts?) → {point, normal, distance, node} | nullorigin 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.

SignatureNotes
audio.play(alias) → booleanPlay 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.

SignatureNotes
assets.load(path, priority?) → AssetHandleProject-relative path. priority is "low", "normal" (default), "high" or "critical".
handle.state() → string"queued", "loading", "ready" or "failed".
handle.ready() → booleanLoaded and usable.
handle.failed() → booleanThe request failed; handle.error() says why.
handle.error() → stringFailure message, empty when there is none.
handle.size() → numberBytes held by the handle.
handle.id() → BigIntThe AssetID.
handle.release() → undefinedDrop 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) → booleanMove 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.

SignatureNotes
storage.save(slot, jsonString, dataVersion?) → booleanAtomic write. dataVersion is the game's own schema number and defaults to 0.
storage.load(slot) → string | nullnull when absent, and also when the envelope is rejected — check storage.lastError().
storage.has(slot) → booleanSlot existence.
storage.remove(slot) → booleantrue when a slot was actually removed.
storage.info(slot) → {kind, bytes, savedAt, dataVersion, schema} | nullMetadata without reading the payload — what a save-slot menu needs.
storage.list() → string[]Every slot in the namespace.
storage.lastError() → {status, message} | nullLast 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 / listThe same six calls, on the preferences namespace.

Script globals

What a ScriptBehaviour gets on top of the engine capabilities.

SignatureNotes
exportProperty(name, defaultValue) → booleanDeclare 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 availableWhat to use instead
fetch, XMLHttpRequest, WebSocketNothing. A script has no network access at all.
Filesystem, std, osstorage and storage.prefs.
Process and environment accessNothing. quickjs-libc is not linked in.
setTimeout, setIntervaltime.wait, time.every, time.tween.
window, documentNeither exists in a script context. document exists only inside a WebCanvas document.
BudgetValueWhat happens at the limit
Execution time per entry100 msThe call is interrupted. A hostile or looping script can freeze its own frame, not the process.
Microtask drain1024 jobsThe chain is stopped and the log says pending job budget exceeded.
Memory64 MiB for the whole runtimeAllocation fails inside QuickJS.
Stack1 MiB on desktop, 256 KiB on WebDeep 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.

JavaScript
// 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);
    });
}
VerticalSlice/scripts/trigger.js

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.

JavaScript
// 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;
    });
}
GTAClone/scripts/door.js

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.

JavaScript
// 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));
Adapted from VerticalSlice/scripts/platform.js

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.

JavaScript
// 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);
}
WitnessGame/scripts/prompt.js

#Troubleshooting

The engine log tells you exactly what went wrong. Here is how to read it.

What you seeWhat it meansFix
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 functionThe 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 failureYour 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 existsA 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 SceneTreeThe call ran outside Play.Autoloads only exist while the game is running.
module import escapes the project rootAn import resolved outside the project.Use a project-relative specifier and keep shared code inside the project.
Nothing at all in the logThe node or the behaviour is disabled.A disabled node stops running its behaviours; check the Inspector checkbox.