Start here
Start here
Your first script, the hooks the engine calls, and the globals every script has.
Part of the scripting guide · every call, as a reference
Contents
- 1. A first script
- 2. Lifecycle:
start,update,fixedUpdate - 6. Globals:
params,time,dt,log - 12. Recipe: a walkable first-person character
- 13. Bundled example scripts
1. A first script
-- spin.lua — slowly rotate the node it's attached to.
defaults = { speed = 45 } -- tunables (also editable in the Inspector)
function update(node, dt)
node.yaw = node.yaw + math.rad(params.speed) * dt
end
Attach it by dragging the .lua from Assets onto a node, dropping it on the
Inspector's Scripting section, or Inspector → Scripting → + Add Script.
Press F1 to Play.
Compound assignment operators work: += -= *= /= %= ^= ..=.
node.yaw += math.rad(params.speed) * dt
2. Lifecycle: start, update, fixedUpdate
function start(node) -- optional; runs once when Play begins
end
function update(node, dt) -- runs every frame while playing
end
function fixedUpdate(node, dt) -- runs every GAMEPLAY TICK (60 Hz, constant dt)
end
function lateUpdate(node, dt) -- runs after physics each frame (the camera pass)
end
function onDestroy(node) -- runs once, when the node goes
end
Each attached script keeps its own state across frames — assign a variable in
start (or at the top level) and read it back in update.
Which one do I use? The split is simple:
| Hook | Cadence | Put here |
|---|---|---|
update |
every rendered frame (variable dt) |
cosmetic motion, UI-ish logic |
fixedUpdate |
every gameplay tick (constant dt, 60 Hz) |
movement, gameplay rules, velocity/physics writes |
lateUpdate |
every rendered frame, AFTER physics | cameras & followers — anything that tracks another node |
Why lateUpdate for cameras: the engine's frame order is scripts →
animation → physics → interpolated transform writeback → lateUpdate. A
camera positioned in update reads its target's pose from before this
frame's physics — one frame stale, a follow error of velocity × dt that
turns frame-time noise into visible movement jitter. In lateUpdate the
target's pose is final for the frame, so the follow is exact. The stock
third_person_camera.lua does this.
onDestroy runs once when the script's node goes: destroy() on it or on
anything above it, a scene.unload of its layer, or a scene switch it is not
persistent through. The node is still there when it runs, so it is the place to
destroy() anything the script made outside its own subtree: a helper node it
parented to the world, say, which would otherwise be left behind. It does not
run when the script is switched off or when you press Stop. A node deleted by
hand during Play is told afterwards instead: its fields read nil by then,
while the script's own variables are all still there.
fixedUpdate runs on the same fixed clock physics steps on, right before each
physics tick — so gameplay code behaves identically at 30 fps and 240 fps, and
input.pressed(...) edges are delivered per tick there (a press between two
ticks is never lost). It's also the cadence multiplayer prediction will replay,
so code you put in fixedUpdate today is already netcode-shaped.
Inside
fixedUpdate, theinputAPI reads the tick's input snapshot; insideupdate, the frame's. Both work everywhere — only the timing window differs.
6. Globals: params, time, dt, log
| Global | Meaning |
|---|---|
params |
This instance's tunables — a table seeded from defaults, so params.speed works out of the box. The Inspector overrides individual values per node. Each script instance has its own: a function of another script called through findScript reads that script's params, which is how to read another script's tunables (function speed() return params.speed end, then findScript("mover").speed()). |
time |
Seconds since Play started. The same in every script, whichever script's function is running: it is set for all of them before any script runs in a pass. update and lateUpdate read the frame clock; fixedUpdate reads the tick clock, which counts the ticks run plus any time a long frame banked and the catch-up cap dropped, so within a frame the two agree to within one tick (a deadline set in one pass can be checked in the other). A frame runs update first and then its ticks, and those ticks cover the time since the previous frame: each reads a later time than the last frame's update and no later than this frame's, the last one within a tick of it. So a tick right after update can read slightly less than the update before it — at 20 fps, update reads 6.100 and the three ticks after it about 6.067, 6.083 and 6.100. After a long frame (a hitch or a load) the ticks the catch-up cap allows are the most recent ones, so they still end at update's time. A deadline set in update as time + delay is seen by the first tick at or past it; one set at exactly time is seen by that frame's last tick. There is no separate unscaled clock: pausing stops both, and app.wallClock() is the wall. |
dt |
Seconds since the last frame (also the 2nd arg to update). Like time, the pass's, in every script. |
node.carry |
Not a global but read the same way as dt: how far, in world units, the world under the node was carried since the last pass, as a vec3. A body on a planet moving on its rails rides that planet's frame (the one its sphere of influence puts it in, the same frame the engine carries dynamic bodies in); node.carry is how far that frame moved, over this tick in fixedUpdate and over the ticks since the last frame pass in update and lateUpdate. It is zero when nothing moves. A body the engine moves itself (a dynamic body, an assembly) is carried for you. A body your script positions (a kinematic tyre set on its mount each tick) is not: add node.carry to the position you give it, the way you multiply a speed by dt. Place a kinematic body on a moving world in fixedUpdate: the world moves at the start of each tick, so a node placed in update or lateUpdate is placed in the world as it was before that move, and its body stands one carry behind it for the whole tick, where it blocks nothing and raycasts at the node miss it. The Console says so once per body; parenting the body to the world it stands on also works. Recentring the floating origin is not a carry: positions are world coordinates, which a recentre does not change. Rails bodies do not turn yet, so a carry is a move; a turning frame would add a rotation. |
log("…") |
Print to the engine Console |
print(anything, …) |
Console print that understands the whole engine: tables render deeply (nested, sorted keys, short arrays inline, cycle-safe), node handles print as node "Player" (#4) at vec3(…), component/script handles by what they point at, vectors via their components. Multi-line output folds into a collapsible block in the Console. |
The full Lua standard library (math, string, table, …) is available.
defaults→params: every key you put indefaultsis readable asparams.<key>. Declaringdefaultsis what makes a value tweakable per-node in the Inspector; if you don't override it there,params.<key>is just the default.
String params
A plain string default becomes an Inspector text field on each instance — so two portals share one script but carry different destinations:
-- portal.lua
defaults = { destination = "hub" } -- each portal's Inspector shows a text box
function onTriggerEnter(node, other, hit)
if other:hasTag("player") then scene.load(params.destination) end
end
Numbers and strings follow the same rules (seeding, live Inspector sync, the
two-way behavior below). A string that looks like noderef() output is a
reference param, not a string — those keep their picker.
Describing your tunables to the Inspector
defaults says what your tunables are. --@ comments say how they should be
presented — and the Inspector then draws a designed panel instead of a stack of
anonymous drag values, in declaration order:
defaults = {
--@header Movement
-- How fast you walk on flat ground. <- a plain comment is the tooltip
--@range 0 20 --@units m/s
walk = 4.5,
--@desc Blend between the walk and run animations.
--@slider 0 1 --@step 0.05
blend = 0.35,
--@header Assist
--@options Off|On|Auto
assist = 1, -- a NUMBER + options → dropdown, value = the index
--@options walk|run|sprint
gait = "walk", -- a STRING + options → dropdown of those strings
invert = false, -- a boolean default → a checkbox, no annotation needed
--@color
tint = "#ff8800", -- a swatch; the script still reads the hex string
--@hidden
debugScale = 1.0, -- kept out of the Inspector entirely
}
| Annotation | Effect |
|---|---|
--@header Text |
A section rule above this row (underscores render as spaces). |
--@desc Text |
The row's tooltip. Repeat the line to build a paragraph. |
| (a plain comment above the key) | Used as the tooltip when there's no --@desc — so scripts that already document their tunables get hover text for free. |
--@range min max |
Clamps the value and bounds the drag. |
--@slider min max |
Draws a slider instead of a drag value. |
--@step n |
Drag speed / slider granularity. |
--@units m/s |
Suffix shown after the number. |
--@options a|b|c |
A dropdown. On a string param the value is the label; on a number it's the index (0, 1, 2 …). |
--@color |
A colour swatch over a #rrggbb string param. |
--@multiline |
A text box instead of a single-line field. |
--@hidden |
Don't show this tunable at all. |
--@about Text |
Describes the script (write it above defaults). |
--@editorButton Label fn |
A button that runs fn(node) in edit mode. |
They're comments: nothing changes at runtime, deleting them breaks nothing, a misspelled one is ignored rather than fatal, and several can share a line.
Booleans are real booleans. A flag = false default round-trips as a boolean,
so if params.flag then means what it says — it's carried as 0/1 between the
Inspector and the script, and converted back on the way in (every number is truthy
in Lua, so a leaked 0 would have been permanently true).
params is two-way
Writing a declared tunable persists — the next frame reads your value back,
the Inspector shows it update live during Play, and other scripts see it
through a handle. Stop reverts it with the rest of the play session. So state
you'd otherwise keep in a local can live in params when you want it visible
and tweakable:
defaults = { distance = 6.0 }
function lateUpdate(node, dt)
params.distance = params.distance - input.scroll() -- sticks, shows live
end
- Only declared keys persist (present in
defaults, or already stored on the node). Assigning an undeclared key works for the current frame but is not saved — declare it if you want it kept. - Reference params (
noderef()& friends) never round-trip — they stay wired by the Inspector. - Inspector edits during Play flow the other way instantly, so you can tune a value the script is also reading. If the script writes the same key every frame, its write wins — write only when changing (like the scroll above).
12. Recipe: a walkable first-person character
No glue code required:
- Add a Camera node and mark it Active.
- Give it a Rigidbody, shape = Capsule.
- Attach
first_person.lua.
Press Play — you are the capsule. It moves under physics and the camera rides along, so you walk the world in first person:
- hold Right Mouse — free-look (yaw + pitch)
- W A S D — move along the ground, relative to where you face
- Space — jump (when grounded)
- Shift — run · hold C — crouch
It works on normal Down gravity and Radial (planet) gravity — drop a Gravity Volume → Radial node at a planet's center and you can run all the way around it.
A minimal controller that shows the velocity loop:
defaults = { speed = 6, jump = 7 }
function update(node, dt)
local f = (input.key("w") and 1 or 0) - (input.key("s") and 1 or 0)
local vy = node.vy -- keep gravity/jump
if node.grounded and input.pressed("space") then vy = params.jump end
node.vx = -math.sin(node.yaw) * f * params.speed
node.vz = -math.cos(node.yaw) * f * params.speed
node.vy = vy
end
13. Bundled example scripts
Every project ships these under scripts/ — open one for a working start:
| Script | What it does |
|---|---|
first_person.lua |
First-person character (attach to an active Camera with a capsule Rigidbody: free-look, run, crouch, jump; planet-aware; slope-forgiving jump via a downward ground probe) |
third_person.lua |
Third-person character body (capsule Rigidbody + a child named Model for the visuals; camera-relative movement, auto-turns, drives Idle/Walk/Run/Jump — matches the controller's real state names, e.g. Idle.001; slope-forgiving jump) |
third_person_camera.lua |
Orbit camera for the third-person body (mouse orbits, scroll zooms, zoom all the way in for first-person freelook; raycasts so walls never clip the view) |
freelook.lua |
Free-fly camera (right-mouse look, WASD, Shift to boost) |
rts_camera.lua |
Isometric strategy camera (WASD/edge-of-screen panning, wheel zoom, Q/E rotate about the focus point, optional follow + map bounds) |
rts_unit.lua |
A commandable unit: moveTo(x, y, z) / stop() / isMoving(), physics-driven if the node has a Rigidbody, transform-driven if it doesn't, with a selection ring |
rts_commander.lua |
The mouse half of an RTS: click / drag-box to select (Shift adds), right-click the ground to send the selection there in a loose formation |
rotate.lua |
Spin a node about Y |
pulsate.lua |
Animate scale over time |
float.lua |
Bob up and down |