Lua ScriptingBeginner

Lua scripting overview

Use lifecycle callbacks, input, gameObject, scene, multiplayer, and gameStorage safely.

Object-script lifecycle

function init()
    -- Runs once when simulation starts.
end

function update(deltaTime)
    -- Runs once per simulation frame.
end

function destroy()
    -- Runs when simulation stops or the scene changes.
end

Optional callbacks include onCollisionEnter, onCollisionExit, onTriggerEnter, onTriggerExit, onClick for scripted GUI objects, and onAnimationFinished(name), called once when a clip the object played with loop=false reaches its end (see Animation playback API).

Which objects run scripts

Every object type runs init(), update() and destroy(). There is no type filter and no requirement that the object have a physics body, a mesh, or a visible presence — an empty with no collider ticks exactly like a cube does. If a script appears not to run, the cause is almost never the object's type.

Objectinit / update / destroyCollision and trigger callbacks
Cube, sphere, capsule, mesh, character, and other physical objectsYesYes, after registerForCollisions()
empty (no collider)YesNo — no body, so no contacts exist to report
cameraYes, but see the warning belowNo
GUI objects (guiLabel, guiButton, guiPanel, guiImage)YesNo, but they receive onClick
Scene scriptYes, and runs after all object scripts each frameNo

onCollisionEnter, onCollisionExit, onTriggerEnter and onTriggerExit require gameObject.registerForCollisions() in that object's own init(). Registering on one object does not enroll any other. An object with no collider can never receive them however it is registered — update() still runs, only the contact callbacks are unavailable. An overlap with a trigger volume calls onTriggerEnter/onTriggerExit; if the script defines neither, it falls back to onCollisionEnter/onCollisionExit, so an older collision-only script keeps working when its object is made a sensor.

Cameras with scripts stop following

A camera object runs its script like anything else, but attaching a script switches off the engine's built-in camera controller for that camera. A first_person or third_person camera with a scriptId silently stops following its target. See Cameras and follow modes.

One script per object

An object has exactly one scriptId. There is no way to attach several scripts to one object, so behaviour that spans concerns is split across cooperating objects, or hosted on the scene script, and coordinated through object lookups or variables. See Cross-object interactions.

The scene global differs between object and scene scripts

Both kinds of script get a scene global, but they are not the same object. Every object script can call loadScene, restart, pauseGame, resumeGame, getActiveCamera, setActiveCamera, getVar, setVar, findPath, and getNearestNavPoint. A scene script carries all of that plus the lookup, spawning, gravity, logging, and scene-audio calls below.

scene.getObjectByName is not the lookup you want in an object script

Object lookup exists in both contexts, but under different globals. In an object script it is on gameObject — gameObject.getObjectByName("Player"). Only a scene script also has it on scene. Reaching for scene.getObjectByName in an object script raises "attempt to call a nil value (field 'getObjectByName')" and stops the rest of that callback, which is a confusing failure because the call is real and documented, just under the other global.

These are on gameObject and available to every object script: getObject, getObjectByName, getObjectsByNamePrefix, getObjectsByType, positional playSound / stopSound / isSoundPlaying, and destroy() on any handle you have resolved.

What genuinely requires a scene script is a shorter list than it looks:

  • createObject — spawning is scene-script-only. Destroying is not: every object handle carries its own destroy(), so an object script can remove anything it can resolve, and gameObject.destroy() removes itself.
  • getGravity and setGravity.
  • log.

Music and non-positional sound are not on that list: scene.playMusic, stopMusic, playSound, stopAllSounds, setMusicVolume and setSfxVolume are on an object script's scene too. An object script's gameObject.playSound plays at that object instead.

Prefer resolving a handle once in init() and caching it over routing a value through a variable. You then read the live object rather than a value someone else has to remember to update. Reserve variables for state with no owning object, such as a score or a shield total.

Global APIs

  • gameObject: the object the script is attached to, plus object lookup and per-object audio.
  • scene: scene transitions, the active camera and scene variables for object scripts; the full scene API for scene scripts. See above.
  • input: shared keyboard, pointer, and mobile control state.
  • gameStorage: values persisted for the current game in the browser. In a multiplayer room only GUI, camera, and presentation scripts may use it.
  • multiplayer: rooms, actions, combat, shared state, and events. See below.
  • print(...): writes to the browser's developer console, prefixed with where it came from — [object Player] for an object script, [scene] for the scene script.

Input fields

The shared input bus includes moveForward, moveBackward, moveLeft, moveRight, lookUp, lookDown, lookLeft, lookRight, jump, primaryFire, and crouch. moveAxisX and moveAxisY report horizontal and forward/backward movement from -1 to 1, for smoother control than the booleans give. moveSprint is true while the movement stick is pushed to its rim.

In a multiplayer room a control script also gets input.aimYaw and input.aimPitch: the player's aim from the command being simulated, in world-space radians. Yaw is measured from +Z toward +X, and positive pitch looks up. They are the same on the room server and in the player's own prediction replay, so turn a character from them rather than from a camera. Both are nil in solo play and on input with no multiplayer command, so check before using them.

Do not add your own threshold to the movement axes

moveAxisX and moveAxisY already carry the player's own dead zone and response curve by the time a script reads them. Re-gating them with something like if math.abs(input.moveAxisX) > 0.15 then tests each axis on its own, which throws away the smaller component of every diagonal: pushing forward-and-slightly-left moves the character straight forward, and fine steering on touch becomes impossible. Read the axes as given, or test their combined magnitude rather than each axis separately.

Look

Read lookDeltaX and lookDeltaY. They contain total per-frame look, already scaled by the player's sensitivity and invert settings. One delta unit is about 0.172°, so 180° is roughly 1047 units. lookAxisX/lookAxisY expose held arrow-key or look-stick deflection, but the engine already integrates that axis into the deltas; reading both applies held input twice.

A first_person or first_person_absolute camera consumes the total deltas. first_person_absolute (First Person (Absolute Aim)) keeps its yaw in world space, so a script that turns the body toward the view does not turn the camera a second time; first_person adds look to the body's rotation. A third_person boom consumes only pointer look — mouse movement or touch drag — because arrow keys and the touch look stick are intended to turn the followed character through its script; the boom then follows the character's rotation.

Three things produce look, and not all of them exist on every device:

  • Arrow keys — always, on a keyboard.
  • Mouse — only when the scene enables Desktop mouse look in its player controls. It is off by default, because pointer lock hides the cursor and stops DOM clicks reaching guiButton elements. The canvas click that captures the mouse triggers no action; later left clicks trigger primaryFire unless an authored Mouse0 binding overrides it. A mouse contributes one delta unit per CSS pixel of movement.
  • Touch look stick, or a drag anywhere that is not the movement stick or an action button — only on a touch device with mobile controls enabled. A touch drag is normalized against the viewport width rather than counted in pixels, so a swipe turns the same amount on a phone and on a tablet; a drag across the full width turns 320°. Vertical runs at 0.78x the horizontal rate.

A held stick or arrow key contributes 600 delta units per second at full deflection, or about 103° per second. There is no gamepad support — nothing in the runtime reads the Gamepad API.

Actions

Authored action buttons are exposed through input.actions.<id>. Action IDs are arbitrary stable snake_case identifiers chosen in the scene's player controls configuration, so scripts can define actions such as input.actions.reload, input.actions.crouch, or input.actions.weapon_swap. The action IDs jump, primary_fire, and crouch are also mirrored to input.jump, input.primaryFire, and input.crouch. crouch is held rather than pressed, so pass it straight to setCrouched(input.crouch).

A key appears in input.actions only once that button has been pressed, so an untouched action reads nil rather than false. if input.actions.reload then is safe; comparing against false is not.

Each action needs a route on the devices you support. A button may name up to four desktop bindings using KeyboardEvent.code values plus Mouse0/Mouse1/Mouse2. Without authored keys, non-reserved actions fall back to Digit1 through Digit9 by author position. jump and primary_fire retain Space and left mouse as compatibility bindings but may name explicit alternatives. The current maximum is six authored actions.

The input bus is shared and singular. There is no per-player or per-device routing, and there is no API for reading arbitrary keyboard keys or mouse buttons from Lua, so multiple devices feed the same fields and authored actions. In a multiplayer room each player's input is sent to the room server, where the control script of the character or vehicle that player controls reads it as input.

Missing values

Bindings return Lua nil when nothing is found. Check a returned object before calling methods on it.

Physics calls refuse numbers that are not finite

Lua makes NaN and infinity easily — 0/0, 1/0, math.huge, or normalizing a zero-length vector. A call that would hand one to physics is skipped instead: setting a position, rotation or velocity, rotate, forces, torques and impulses, resetPhysicsState, setCharacterVelocity, moveTo, the sphere and box queries, vehicle and track inputs, constraint targets, scene.setGravity, and the geometry given to scene.createObject. A rotation quaternion with no length is skipped the same way. The rest of the callback still runs, and the skip is reported as a script error named rejected and the call, such as rejected setLinearVelocity(), saying which argument was bad. moveTo then returns false, a query returns no hits, and createObject returns nil.

Multiplayer Lua

Object and scene scripts receive a multiplayer global. In solo play multiplayer.isHost() returns true. In a multiplayer room the game is run by a room server, and every script runs in one of three places, which decides what it may call:

  • Authority — the room server only: the scene script, the control script of each character or vehicle bound to a player slot or spawned from a spawn template, and scripted objects declared "authority". Only these may change the game. isHost() is true here and nowhere else.
  • Presentation — every player's browser, never the room server: camera and GUI scripts, and scripted objects declared "presentation". Only these may touch GUI, sound, lights, the active camera, and gameStorage, or play a clip on a character.
  • Prediction — the controlling player's browser also replays their character's or vehicle's control script to show movement at once. A control script must therefore give the same result every time: no os.time, os.clock or os.date, and never branch on the result of fire, setController, hold, release, destroy, moveTo, followPath, spawn, despawn, setPrimary, or object creation.

A call from the wrong runtime raises an error that names it. Animation is the exception: every browser animates every character from its movement, so a control script's enableAutoAnimation and updateAutoAnimation keep working unchanged. The rest of the animation family, including the bone-socket calls such as attachToBone, quietly does nothing on the script's own object in an authority or prediction script, because the room server never poses a skeleton: attach a weapon to a hand from a presentation script, and never decide a hit from what a bone holds. Outside a room every script keeps the whole API.

  • multiplayer.sendAction(name, payload) asks the room server for an outcome, from a GUI button or a player's character or vehicle control script, and returns ok, reason. The scene script cannot send one: it is what receives them, in onPlayerAction(participant, name, payload), and it hears arrivals and departures through onPlayerJoined(participant) and onPlayerLeft(participant). An action the room server turned away never reaches onPlayerAction; it arrives in onPlayerActionRefused(participant, name, reason) instead, which is where to count refused shots. participant is always {id, playerSlot}.
  • multiplayer.fire(templateId) fires a release-frozen weapon from a control script; the room server resolves the shot at once. Call it for its effect only.
  • If a player's avatar state holds a numeric shield, hitscan and projectile damage drain it before health, and any overflow comes off health; only health decides alive. Nothing regenerates it for you: set and refill it from the scene script with getAvatarState and setAvatarState, keeping the other keys.
  • getGameState(), getParticipantState(id), getAvatarState(objectId), and getPrivateState([participantId]) return detached state snapshots in any script. setGameState(state), setParticipantState(id, state), setAvatarState(objectId, state), and setPrivateState(id, state) are for authority scripts.
  • multiplayer.getSceneRevision() counts the room's scene changes: 0 for the scene it started in and one more for each scene.loadScene or scene.restart. It is 0 outside a room.
  • multiplayer.sendEvent(name, payload[, participantId]) sends a declared presentation event from an authority script; presentation scripts receive it in onMultiplayerEvent(name, payload).
  • Presentation scripts may define onMultiplayerStateChanged(change), called after replicated state changes.
  • multiplayer.getPlayerSlot(objectId) returns the player slot an object is bound to, or nil; an object's multiplayer settings are not variables, and no script can change them. multiplayer.getController(objectId) and, from an authority script, multiplayer.setController(objectId, participantId) read and hand over control of a slotted or spawned character or vehicle. A player may control up to four objects, all driven by the same input; the camera follows their primary one. From an authority script multiplayer.setPrimary(participantId, objectId) chooses it, and in any script multiplayer.isPrimary(objectId) says whether an object is its controller's primary and multiplayer.getControlled(participantId) lists what a player controls, primary first. Every object a player controls sees the same press, so a control script that sends an action on a press should check multiplayer.isPrimary(gameObject.id) first.
  • From an authority script, multiplayer.spawn(template, x, y, z, options) copies a spawn template — an object whose multiplayer.spawnTemplate setting is on — into the room for every player and returns id, reason; options may set yaw, variables, respawnDelayMs, killHeight (each defaulting to the template's own setting) and controller. multiplayer.despawn(id[, { respawn = false }]) removes a copy. multiplayer.getTemplate(id) and multiplayer.getSpawnedObjects([template]) read what is live in any script. The scene script hears onObjectSpawned(object, template) and onObjectDespawned(id, template, reason, respawning). See "Spawn objects during play" in Build and test a multiplayer game.
  • From an authority script, multiplayer.hold(propId, holderId, x, y, z) has a slotted character or vehicle carry an authority prop at an offset in its own frame, and multiplayer.release(propId, vx, vy, vz) puts it back moving at that velocity — a throw, or a drop. multiplayer.getHolder(propId) names the holder or returns nil. See Build and test a multiplayer game.
  • From a presentation script, multiplayer.getPresetMessageOptions() lists the preset messages the local player may send and multiplayer.sendPresetMessage(key) sends one, returning ok, reason. Presentation scripts receive every player's message in onPresetMessage(message). See "Let players talk with preset messages" in Build and test a multiplayer game.
local wasFiring = false

function update(deltaTime)
  local firing = input.primaryFire == true
  if firing and not wasFiring then
    multiplayer.fire("rifle")
  end
  wasFiring = firing
end

Multiplayer boundaries

Returned state tables are detached; changing one does not publish it. A player's browser can read only its own private state. In a room only the scene script may call scene.loadScene and scene.restart, and only the state listed in their keep option survives the change. An object made with scene.createObject in a room exists only on the room server; copy a spawn template with multiplayer.spawn for one every player sees. If the room server is lost, the room ends. See Build and test a multiplayer game for the full rules.

What Lua a script gets

Scripts run Lua 5.4 with the standard libraries — string, table, math, os, and the rest. math.atan2 is kept as an alias of two-argument math.atan for older scripts. Each script has its own globals; one script cannot read another's variables except through getVar/setVar and the APIs above.

In single-player play and in the editor that is the whole story. On a multiplayer room's server, scripts run with limits:

  • io, package, require, dofile, loadfile, and debug are removed, and os keeps only time, clock, date, and difftime. load still works, for source text only. Because none of this applies in the browser, a script that uses io works in Preview and fails in a room — test multiplayer games in a room.
  • Any single callback — init, update, onPlayerAction and the rest — is stopped after about one second. Code at a script's top level has no such limit, so never loop there: a loop that does not finish stalls the room, and the room is ended.
  • A character's or vehicle's control script gets a math.random seeded per room, so the predicted copy in the player's browser draws the same numbers as the server. math.randomseed only reseeds that sequence. In a game whose scene script changes scene, the room server's other scripts get a math.random reseeded at every change, so a restarted round does not repeat the last one's numbers. os.time, os.clock, os.date, and os.difftime raise an error there; for elapsed time, add up the deltaTime that update is given.

Editor Lua is a separate world

Everything above describes Lua that is attached to an object or a scene and runs while a simulation is running. The editor also has a REPL for throwaway Lua that inspects or edits the scene you are authoring, opened with Cmd/Ctrl + Shift + L. The two do not share an API surface: REPL code gets a single editor global and has no gameObject, scene, input, or gameStorage, and nothing on this page is available there. See "Editor Lua REPL" in Editor basics.