JavaScript in the Engine

The Engine's script API: the lifecycle, the coordinate traps, the three ways to make an object, and how to move one without walking through walls.

In one line#

Every entity can carry a JavaScript script. It runs in the browser, in your tab, against the live scene, and it is the canonical scripting API: the BASIC dialect, and the Python, C# and Rust views in the editor, all compile down to the same surface.

The exhaustive list is the API reference: 538 members in 50 namespaces, one page each, with a search. This page is what you need to know before reading it.

The lifecycle#

function start() {}                     // once, when the entity starts
function update(dt) {}                  // every frame, dt in seconds
function fixedUpdate(dt) {}             // fixed timestep, for physics
function onCollisionEnter(other) {}     // first frame of contact
function onCollisionStay(other) {}      // every frame while touching
function onCollisionExit(other) {}      // contact just ended
function onTriggerEnter(other) {}       // entered a trigger volume
function onTriggerExit(other) {}
function onMessage(msg, data, senderId) {}   // entity-to-entity messaging
function onAnimationEvent(name, data) {}
function onDestroy() {}
function main() {}                      // console-game entry, async allowed

deltaTime, time, entityId and entity are globals; getComponent(type) reads a component off the entity that owns the script.

Collisions and triggers fire in both 2D and 3D, and each side receives its own contact normal: other.contact.normal points from the other entity toward this one. That is what lets a script tell "I landed on it" from "it landed on me", a platformer stomp reads other.contact.normal.y > 0.5.

The two coordinate traps#

Both cost real debugging time, and neither fails loudly.

Mouse: page versus canvas#

input.mousePosition is in page coordinates. scene.pick() wants canvas coordinates. Passing one for the other does not miss, it hits a point offset by the viewport's position in the window, so a click-to-move hero walks somewhere plausible and wrong.

Call scene.pick() with no argument (it aims at the cursor), or use input.mouseViewport when you need the numbers.

Virtual controls are in canvas pixels#

input.addVirtualJoystick(id, x, y, size) and input.addVirtualButton(...) place their centres in the same space as mouseViewport. A joystick returns a normalised X/Y after its radial dead zone; a button exposes held, pressed-this-frame and released-this-frame separately. Their pointer is captured and consumed, so a touch on the control is not also reported as a raw canvas action.

The default joystick is unified. input.getJoystickX/Y() picks one complete vector from the on-screen stick, a physical gamepad, or the keyboard (WASD/arrows), whichever source is strongest. An idle connected pad therefore does not disable keyboard movement. Its Y axis follows 2D screen space: up is -1.

Three ways to make an object, and they are not interchangeable#

What it makesLives for
scene.createEntity(def)an authored entity: it appears in the Scene Graph and the Inspectorthe project, it is saved
game.spawn(type, pos)a bare primitive: no health, no AI, no behaviourthe play session
game.spawnFrom(template, pos, opts)a copy of a whole entity: components, physics body, tags, layer, scale and the compiled behaviour stackthe play session

game.spawn is right for debris and prototypes and useless for enemies: a spawner fed by it produces inert balls. The template spawnFrom copies is an ordinary entity you compose in the editor and switch off with the hierarchy's eye: no separate format, no second editor, so what you see is what appears.

// Once, at start: a missing template should say so, not fail every wave silently
if (!game.hasTemplate('Skeleton')) console.warn('no Skeleton template');

const mob = game.spawnFrom('Skeleton', { x, y, z }, { tag: 'Enemy' });

template accepts an id, a name or a tag. Copies made during play are play-scoped, so stop() removes them and the edit scene is never polluted.

Switching an entity off means hidden AND silent: node disabled, script stopped, physics body no longer responding. Hiding alone would leave an invisible enemy still hitting you. game.setEnabled(id, on) drives it from a script, the eye icon drives it from the hierarchy.

Moving something: push a body, do not teleport#

Writing transform.position every frame walks through walls, through other monsters and through the floor. Drive the velocity instead, but only when there is something to drive:

if (physics.hasBody) {
  const v = physics.getVelocity();
  physics.setVelocity({ x: vx, y: v.y, z: vz });   // pushes a real body
} else {
  transform.position.x += vx * dt;                 // legitimate for a flyer
}

physics.setVelocity without a body fills a field nobody integrates, and the entity stops dead. That is why physics.hasBody exists, and why every moving behaviour in the catalogue asks the question instead of assuming an answer. Stopping means cancelling the velocity: doing nothing leaves the body coasting.

Writing a position does move a physics body, now. Havok drives the transform, never the reverse, so assigning transform.position on a dynamic body used to be overwritten on the next step: every teleport failed silently on a physical entity. The position proxy re-syncs the body, the way the rotation proxy always did.

Pathfinding is a snapshot#

pathfinding.createGrid() raycasts a grid once, centred where it was called. Leaving that box returns null, and most callers then fall back to a straight line, through walls, without a word.

The grid re-centres on the requested trip by default. setAutoRecenter(false) opts out on a fixed world, where rebuilding costs a raycast pass for nothing.

console.log logs. Print draws.#

The console is console.log / warn / error, as anywhere else. The BASIC Print draws on the screen and is called every frame, the two are deliberately separate commands, and mixing them up is what fills a console with a frame loop.

Where a script runs#

In the editor and in a web build, scripts run in the browser. In a native desktop or mobile build, they run unchanged on an embedded QuickJS runtime, the same source, not a port.

Scripts are stored in the browser's own database while you edit, so an unsaved buffer survives a reload; they are saved into the project with everything else.

Limits and common snags#

  • scene.pick() with an argument wants canvas coordinates. See above; it is the single most common wrong-place bug.
  • game.spawn makes a primitive, not a character. Use spawnFrom.
  • A body is created for entities born mid-game. Physics used to be built only at Play, so anything spawned afterwards fell through the world silently while its hand-placed neighbours behaved.
  • Eight steps is the AI teammate's ceiling, not yours. A script loop has no such limit; a runaway while will hang the frame like anywhere else.
  • The other four language views are transpiled from this one. Editing a Python or Rust view and switching back goes through the stored JavaScript see the BASIC page for why.