Scripting & tasks
Two kinds of code run in Zero, and it's worth knowing which you're writing:
- 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. (Editing a file hot-reloads it and re-runs the body, which starts these locals fresh — the hot-reload guide covers that.)
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()
updateMyThing() -- runs each frame
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)
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 debug.problems 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:
tools.use("debug", "problems") -- from inside a component or execute
-- or, over MCP: use_tool { toolbox = "debug", tool = "problems" }
Each call advances a read-cursor, so a second call reports only what is new. Pass { level = "error" } to narrow to errors, or { all = true } for a cursor-free overview of the recent problems. The full log ring — every level, searchable — is the logs.* namespace behind it (logs.query, logs.errors, logs.tail, logs.find).
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.