Scripting & tasks
- One-off code — a snippet you run once to make something happen (spawn a scene, query state, kick off a job). It executes, returns a value, and is gone.
- Persistent behaviour — code that lives on an entity and runs every frame or reacts to events. That's a component (the components guide covers authoring them). When you want something to keep happening, you author a component.
Both run in the same single-threaded Luau VM. Two things follow from that single thread: where your state lives, and how long-running work shares the one thread.
State: where it lives
Globals are the engine's surface — asset, entity, task, log, and the rest are entries on the global table you read and call. Each execute call runs in a fresh environment, so a bare top-level assignment (x = 5) lives only for that call — gone by the next. The global table itself is read-only, so _G.x = … raises an error. State that has to outlive a call lives in one of two places instead, matching the two kinds of code above.
One-off code keeps state in _SCRATCH. Every execute session carries a scratch table, _SCRATCH — always present, and surviving from one execute call to the next. It's how you iterate without rebuilding state each run — accumulate a result, hold onto a handle, carry a counter across calls:
-- one execute call:
_SCRATCH.runs = (_SCRATCH.runs or 0) + 1
-- a later, separate execute call — it's still there:
return _SCRATCH.runs -- 2, 3, 4, … across calls
_SCRATCH is your execute session's working state — transient, not saved into the world. Loaded code (components, modules) keeps its own state instead.
Persistent behaviour keeps state in module-local locals. A loaded module holds its state in ordinary locals at the top of the file — they live for as long as the module stays loaded:
local M = {}
local hits = 0 -- persists across calls while the module is loaded
function M.hit() hits = hits + 1; return hits end
return M
That state is shared by everything the module serves. A component's per-entity state — a separate value for each entity carrying it — is part of the component's lifecycle, covered in the components guide.
State that has to survive an edit lives in modules.state(). A local at the top of a module is an upvalue of the chunk that declared it. Editing the file hot-reloads it, which runs that chunk again, so the declaration runs again too and local hits = 0 is 0 once more — while every caller keeps calling through the same module table, because the table's identity is preserved. A module that gates on such a local (if not built then return end) therefore goes quiet after an edit and says nothing about it, and a module that tracked what it spawned in one loses its only handle on those entities.
modules.state() is a table the engine holds rather than the chunk, so it is the same table before and after a reload — of this module, and of anything this module requires:
local M = {}
local s = modules.state() -- the same table across every reload
s.hits = s.hits or 0
local scratch = 0 -- back to 0 after each edit
function M.hit() s.hits += 1; return s.hits end
return M
Put in it what an edit must not undo: a built flag, the entities and particle systems a build owns, an unsubscribe list, a generation counter. A generation counter is worth reading twice — because both the outgoing chunk's closures and the new one's read the same table, a closure left on an event bus by the previous run can compare against the current generation and stand down, which a counter kept in a local cannot do (the stale closure reads the stale copy and always agrees with itself).
Pass a require path to read another module's table — modules.state("game.hud") — so a caller that drives a module reaches the state that module keeps across its own reloads.
The run that made something releases it, in modules.onUnload. Registered from a module body, the callback runs once, at the moment this run ends — immediately before the next run of that chunk replaces it, and when the module leaves the require cache — while this run's locals are still in scope, the one moment the code that spawned something can still name it:
local M = {}
local spawned = {}
modules.onUnload(function()
for _, e in ipairs(spawned) do entity.despawn(e) end
end)
return M
Each run registers its own, and the registration goes with the run that made it, so a teardown does not accumulate a copy per edit.
What another chunk can read. A local belongs to the chunk that declared it. execute runs its own chunk, so a module's local hits above — or a scene entrypoint's top-level local state — is outside its reach, and the name reads nil. What crosses between chunks is the table a module returns: require hands back the same loaded instance every time within one environment, so a field on that table is one value every chunk in that environment reads and writes.
execute is its own environment. It keeps a module registry separate from the one loaded code (components, scene entrypoints) uses, so require("m") from execute returns a different instance from the one a component holds — same file, separate state. Every execute call shares one registry with the others, so state set in one call is there in the next; what it does not see is state a component set, and vice versa. A module read from execute that looks freshly initialised is showing you its execute-side instance, not evidence that the scene never ran. To reach the scene's state, read it through the entity or component that owns it.
-- run.module/init.luau
local M = {}
M.state = { score = 0, biome = "grass" } -- another chunk can read this
local seed = 12345 -- stays private to this chunk
return M
-- any later execute call:
return require("run").state.score
A scene or component that keeps its run state on the module table is therefore inspectable while it runs — read the fields directly between frames. The table is the observation surface, so choose what belongs on it and leave the rest local.
Editing the module re-runs its body into the table it already had: the table keeps its identity and its members are replaced, so a component that required it before the edit and an execute that requires it after are looking at one object. What the re-run resets is the module's own local state — so a listener list or an owner id a caller wrote into a bare local is empty on the far side while the caller keeps running. modules.state() above is where such state belongs; a component that must re-register into somebody else's module gets onModuleReload(path), covered in the components guide.
One cooperative thread — long work yields
There's one script thread, so a loop that runs too long holds up the frame. Anything that spans time — waiting, polling, animating, bulk work — is done with task, which runs your code as a coroutine that yields the thread back to the engine and resumes later.
task.spawn(function()
print("starting")
task.wait(2) -- yield for ~2s of real time, without blocking the frame
print("two seconds later")
end)
task.wait yields and resumes later, so it belongs inside a spawned task — somewhere there's a coroutine to yield. The everyday persistent pattern is task.loop, which runs a function once per frame and keeps going even when one iteration errors:
task.loop(function(dt)
updateMyThing(dt) -- runs each frame; dt = seconds since last run
end)
Work that doesn't finish now
Some calls hand back a promise instead of a result. await yields until it resolves and gives you the value (again, inside a task):
task.spawn(function()
local guid = await(world.create("My World"))
print("created", guid)
end)
Waiting a number of frames
Some things are ready a fixed number of frames later rather than a fixed number of seconds later — a mutation the next frame applies, work whose result lands a known number of frames after it is dispatched. task.waitFrames(n) waits that many, and time.frameCount() is the number it counts: the frame the engine is on, 0 when the engine starts and one higher for each frame it begins.
task.spawn(function()
local before = time.frameCount()
task.waitFrames(8)
print(time.frameCount() - before) -- 8, always
end)
task.wait() with no duration is the same thing for one frame.
What that count measures is frames the engine ran. The renderer draws a frame only when something is consuming one, so frames drawn is its own, smaller count — time.drawnFrameCount(). When what you are waiting for is something the renderer produced (a capture, a camera observation, a readback, a per-frame cost), wait on that count instead, with task.waitDrawnFrames(n). It asks the renderer to keep drawing for as long as the wait lasts, so the frames it waits for are frames that happen.
local before = time.drawnFrameCount()
task.waitDrawnFrames(2)
print(time.drawnFrameCount() - before) -- 2 frames that actually drew
Work that would otherwise run too long in one pass — spawning thousands of entities, mutating a huge set — is split into chunks that yield between them with task.batched, so it never stalls the frame.
The rest of the task surface — delaying, deferring, cancelling, inspecting a running task, a loop that keeps running while the editor pauses — is one call away: lsp.methods("task") lists it and lsp.describe("task/<name>") explains each (discovering the engine this way is its own guide).
When something doesn't work
A component that silently never runs, a weapon that never equips, a value that comes back nil — the reason is usually an error the engine already caught and logged, but that didn't surface in your execute result. A throw swallowed by a pcall, an awake() that errored and disabled its component, a data value that violates its contract, an unresolved require — each logs a warning or error the moment it happens.
Reach for the problems tool in the debug toolbox first. It returns the errors and warnings logged since your last call, oldest first, so the root-cause error reads ahead of the cascade it triggered.
Each call advances a read-cursor, so a second call reports only what is new. level narrows it to errors; all gives a cursor-free overview of the recent problems. Behind it sits the full log ring — every level, searchable — which the logs toolbox reads: search for a filtered query (by severity, subsystem, text, the script or entity that logged a line, or a time window), tail for the most recent lines, errors for recent failures as a snapshot, and summary for the shape of what is there. That ring is also readable as
plain files — /zero/runtime/logs/engine, /zero/runtime/logs/errors and
/zero/runtime/logs/script — for when a grep for a message you can name is
quicker than a query.
Where to go next
Component lifecycle, public fields, and reacting to events are in the components guide — that's where persistent behaviour belongs. For the live shape of any namespace, lsp.methods and lsp.describe are the source of truth. The model to carry: one cooperative thread; state lives in _SCRATCH for a session or a module local for a behaviour; and task is how you live on that thread over time.