Log inGet started

Scripting & tasks

Updated October 20, 2026
  • 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.

A snippet grown big enough to keep is written to a file and run with execute { file = "/zero/source/tmp/probe.luau" }, which runs the file's bytes as an execute chunk — scratch table and all. Open such a file with --!execute so the checker reads it the way the call runs it:

--!execute
_SCRATCH.runs = (_SCRATCH.runs or 0) + 1
return _SCRATCH.runs

The line is a declaration about the whole file, so it sits above the first statement. A component or a scene entrypoint is loaded by the engine rather than run by execute, and there _SCRATCH resolves to nil — those files hold their state in locals, and the checker says so.

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 simulation time, without blocking the frame
  print("two seconds later")
end)

Yielding spreads work across frames, and every slice of it still runs on the engine thread. A computation that holds the thread for tens or hundreds of milliseconds at a time (a generator, a solver, a search) runs on a worker thread instead, and the frames go on while it runs: jobs.dispatch(module, entry, args) answers a promise of one result, and a kind = "worker" job hands its module an input every frame and applies each result as it lands. core/performance covers both, under "Serial work that holds a frame", and covers how a job shows progress and partial results to the game while it runs (jobs.shared tables and queues).

The values after the function are its arguments, nils included, for every way a task starts: task.spawn(fn, a, b), task.spawnSystem(fn, a, b) and task.defer(fn, a, b) call fn(a, b), and task.delay(seconds, fn, a, b) calls it once the wait is over. task.spawn runs fn at once, up to its first yield; task.defer runs it at the end of the frame.

A VM holds at most 50000 live tasks: waiting, suspended, or deferred and not yet run. task.active() reads that count, and a task stops counting once it returns or is cancelled. Every start is admitted against it, so a fan-out of hundreds of task.defer or task.spawn calls in one frame is fine, and a start past the ceiling raises an error naming the count, the states the live tasks are in and the source holding the most of them.

A script that runs past its budget without yielding is halted by the watchdog, and the error names the budget it outran. A coroutine the halt reaches is dead from then on, with the halt as its error: the one running the loop, and each one that resumed it through coroutine.resume or a coroutine.wrap function. coroutine.status reads dead, coroutine.resume and task.wake refuse it as dead, coroutine.close hands the halt back, and a task the halt ended reads failed in task.status.

A halt in a component's code ends that component's whole script, whichever of its hooks, ticks, tasks or callbacks was running: the component is disabled, with the halt as the reason getLuauVmDataTable().script_details reports for it, none of its hooks is called again, and every task it runs is stopped where it waits, each reading cancelled with component:<instance> halt as what cancelled it. Turning it back on is refused; reloading its script or adding the component again starts a new life.

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)

What ends a task

A task belongs to what started it, and ends when that does. You don't have to cancel a loop by hand when the thing that runs it goes away:

  • A component instance owns the tasks its own code starts: from awake, update, onCallback or any other hook, from its public methods and the functions it defines, and from a callback it registered (an input handler, a signal connection, a watcher), whichever code later fires that callback. This holds whoever calls the code: a game script, another component or an execute call that asks a station to startCraft() gets a craft that ends with the station, and so does a module function the method calls on its way to task.spawn. When the method is called from a task scope's work or from a scene's entrypoint, the craft ends with that scope or that load as well, as all work started there does. Destroying the instance, hot-reloading its component, or leaving play (which tears the play world down) cancels them. A component that another script adds, a scene's entrypoint included, is still the owner of what its own hooks start: those tasks end with the component, not with the code that added it.
  • A scene's entrypoint owns the tasks it starts: from its body, from onLoad / onEditLoad / update, from localPlayerReady / playerJoined / playerLeft / onCallback, and from callbacks it registered. Each load of the layer is its own owner, so layers.reload, layers.unload, a scene swap, a mode flip or a world swap ends the tasks that load started, and a reload runs one set, never a stack of them.
  • A task scope owns the work started in it (see below).
  • A task spawned by another task belongs to what that one belongs to.

Three kinds of task outlive the code that started them, by design: a task.spawnSystem task (a loop that must keep running whatever launched it), a task a module's body spawns from its own code (the module belongs to every caller that requires it), and a task an execute call starts outside the cases above. These end by returning or by task.cancel(handle); the last is also listed under its call's exec:<chunk> scope, and scopes.release of that tag cancels it.

A module's body releases what it spawned in modules.onUnload (above). The body of a source the engine runs itself is run again each time the engine reads the source: a scene's build.luau on every rebuild, a .sceneModule or procgen entry script on every read, an editor panel's script on every registration. A task that body starts belongs to nothing, so each run starts another beside the last. Keep its handle in modules.state(), the table every run of the source shares, and cancel the previous run's before starting the next:

local state = modules.state()
if state.loop then task.cancel(state.loop) end
state.loop = task.spawn(function()
  while true do task.wait() end
end)

Each live task's record lists what ends it: /runtime/tasks/<handle>/summary.json carries owners, such as component:<instance>, scene:<guid>#<load> or scope:<n>, and an empty list for a task only its own return or a task.cancel ends. scopes.list() lists every live task by the scope it follows, with its task.status as state.

A cancelled task's record says what cancelled it. A task's own directory, /runtime/tasks/<handle>/, reads its current record from spawn until the history lets it go, the same record /runtime/tasks/any/<handle>/ serves (an ended one is also under /runtime/tasks/cancelled/<handle>/ and its sibling scopes); the /runtime/tasks listing itself names live tasks alone. cancelled_by in a cancelled task's summary.json reads task.cancel, component:<instance> hot-reload, component:<instance> destroy (the component was removed, its entity despawned, or a mode flip tore down the world it lived in), component:<instance> replace (a new script took its instance id), component:<instance> release (scopes.release of its scope), component:<instance> rewake (it had stood down on its awake() and an asset one of its fields names changed, so its lifecycle runs again), or <owner> ended for the end of a run, such as scene:<guid>#<load> ended. A task that was not cancelled has none. When the end of an instance of one of your world's components cancels tasks, the engine also posts an [info] event naming the component, the instance, the entity, which end it was, how many tasks ended and their handles (the <handle> of each record); it arrives with the response to the first tool call you make once the end has happened, which is often the edit_file or execute that caused it.

So a timer a component starts with task.spawn(function() task.wait(5) ... end) fires only if the instance's life lasts the five seconds. An edit to the component's source reloads it and starts a new life: the old timer is cancelled and awake runs again, starting a new one from zero. A delay that has to span reloads keeps its deadline in a public field (a reload keeps the values the instance wrote to its public fields) and is checked from update. Work that has to outlive the instance altogether belongs in task.spawnSystem, which ends only by returning or by task.cancel.

Work that ends together: task scopes

When several pieces of work start together and none may outlive the thing that started them, start them in a task scope (@builtin::modules.scopes, the scopes global):

task.spawn(function()
  scopes.run(function(s)
    s:spawn("terrain", function() task.wait(2) end)
    s:spawn("props", function() task.wait(1) end)
  end)
  -- here both are finished or cancelled, and their cleanup has run
end)

scopes.run returns once every piece of work started in the scope is over, and returns what its function returned. The first error any of it raises cancels the rest (each cancelled task unwinds, so its cleanup runs) and is raised again at the scopes.run call, as it was raised; s.onError = function(name, err) end observes every error as it happens, and the VM-wide task.onError is not told. scopes.run waits, so it is called from a task. An error in a scope nothing waits for (scopes.open, no s:join() pending) and no onError observes is reported as any task's error is.

To start work that must end with an entity, without waiting for it, open a scope tied to that entity:

local s = scopes.open({ entity = npc })
s:spawn("patrol", function() while true do task.wait(1) end end)
-- despawning npc cancels the patrol; so does s:cancel()

Everything a task in a scope starts belongs to the scope as well: a task.spawn, a job it registers, a scope it opens (so cancelling the outer scope reaches the inner scope's work). A scope is lived inside what the code opening it belongs to: opened from a component's hook it ends with the component (destroyed or hot-reloaded), from a scene's entrypoint with that load, from another scope's work with that scope. s:why() says what ended a scope first (a scopes.run whose entity despawned returns once its work is cancelled, and why names the entity), s:children() lists what it started with each one's task.status, s:join(name?) waits for one child or all of them, and s:cancel(name?) cancels one child or the whole scope.

Cleaning up when a task is cancelled

A cancel stops a task where it waits, and by default that is all: the code after the wait never runs, so a lock, an in-flight flag or a spawned helper the task claimed stays claimed. A task that has to let go of what it holds is cancelled so that it unwinds:

local busy = {}

local worker = task.spawn(function()
  busy.door = true                          -- the claim
  local ok, err = pcall(function()
    task.wait(10)                           -- the cancel is raised here
  end)
  busy.door = nil                           -- runs whether the wait finished or was cancelled
  if not ok and not task.isCancellation(err) then error(err) end
end)

task.cancel(worker, { unwind = true })

At the end of the first scheduler tick after the cancel, once that tick has resumed its tasks, the cancelled task is resumed once with a cancellation error raised at the call it is waiting in (task.wait, task.waitFrames, await, any yield). It passes through a pcall like any other error, so the cleanup after it runs, and then the task ends. task.isCancellation(err) tells the cancel apart from the waited-on work failing. Without a pcall, the cancellation ends the task quietly. A component's update and editorUpdate run before the frame's tick, so a cancel from either, like one from a task the tick resumes, is unwound in that same frame; a cancel from an execute call is unwound by the next tick.

When unwinding is part of what a task is rather than of one cancel, start it with task.spawnWith({ unwind = true }, fn). It then unwinds whenever it is cancelled, by task.cancel(handle) or by the end of what it belongs to (its component instance, its scene load), and so does every task it spawns, unless a start says { unwind = false }. A cancel that says { unwind = false } stops a task without the resume.

An unwind is one resume and nothing more:

  • A cleanup that waits again is stopped at that wait; the code after it does not run.
  • A task.spawn or task.defer from the cleanup belongs to the cancelled task, so it is cancelled before it runs. Work that must outlive the task goes to task.spawnSystem.
  • The unwinds of one frame share the execute budget. A cleanup that runs past it is halted and reported, and a task the frame reaches after the budget is spent is stopped without its unwind and reported. The engine goes on ticking.
  • A task that is running when it is cancelled keeps running until its next wait, where its unwind is delivered.
  • A task the watchdog halted has already failed, so task.cancel answers that there is nothing to cancel and no unwind runs. A coroutine the watchdog halted reads dead from the halt on, so an unwind that reaches one has nothing to resume and leaves it as it stands.
  • When the engine loads another world (world.swap), it tears down the scripting VM of the world it replaces, and every task in that VM stops without unwinding: the VM its cleanup would run in is gone.

Which clock a wait counts on

A duration is two different waits depending on the clock under it, and task gives you both.

task.wait(seconds) counts SIMULATION time: the clock engine.timeScale multiplies, the one update(dt) and the physics step already run on. So a timed sequence written with it keeps step with everything else in the world: set engine.timeScale = 0.25 for slow motion and the sequence slows with the motion around it. The arithmetic is exactly that: at scale 0.25 a task.wait(1) returns four seconds of wall clock later, at scale 4 a quarter of a second later, and at scale 0 it never comes due at all. A wait a held task started keeps the time it had left for as long as the task is held (see "When a task runs" below).

task.waitRealtime(seconds) counts WALL-CLOCK time — time.realtime()'s clock, which no time scale reaches. Reach for it when the duration is about the player or the machine rather than about the world: a timeout, a retry backoff, an interface transition, a test that has to finish. task.delayRealtime(seconds, fn) is the same clock for task.delay.

engine.timeScale = 0.25
task.spawn(function()
  task.wait(1)              -- 4 seconds of wall clock — a second of a world running quarter-speed
end)
task.spawn(function()
  task.waitRealtime(1)      -- 1 second of wall clock, at any scale
end)

Each returns the seconds that passed on the clock it counted, so task.wait's return is a simulation figure. To find out how long something really took, read time.realtime() either side of it.

At timeScale = 1 the two clocks run together, because the simulation clock is the wall clock that the engine's own stepping hands it. That stepping continues between your tool calls, so a wait spelled either way is measured against a world that kept running while nothing was calling into it. The engine guide's "How fast time passes" states which states run the gameplay clock and what the world does with a frame the engine lost.

When a task runs: pause and mode

Where a task is spawned decides which of the engine's states it runs in.

Spawned fromClassRunsHeld
a component hook other than editorUpdate (awake, start, onEnable, update, fixedUpdate, onCallback and the rest), or a gameplay taskgameplaywhile gameplay runswhile engine.paused is set
a component's editorUpdate, or an editor taskeditoron the frames the editor ticks: edit mode, and play while pausedwhile play runs
task.spawnSystem, a module, an entrypoint, execute, or a system tasksystemalwaysnever

Edit mode starts paused, so a task a component spawns from awake in the editor enters its body and then waits, held, until engine.paused = false (play mode starts unpaused). Work a component has to do in the editor belongs in editorUpdate, whose tasks run there, or in task.spawnSystem, whose tasks run everywhere: a server, a device link, a watcher. A component's gameplay and editor tasks end with the component instance; a system task does not.

A held task says so. Its /runtime/tasks/any/<handle>/summary.json record carries held_by ("gameplay pause" or "play running"), and a gameplay task that waits while gameplay is paused is reported in the log once for each component and line it waits at.

A waiting task says what it waits for. A task in a seconds or frames wait carries wait in the same record: kind ("seconds" or "frames"), clock ("simulation", the clock engine.timeScale multiplies, or "real" for a seconds wait; "engine", "drawn" or "settled" for a frames wait), deadline and now as readings of that clock, remaining, clock_state ("running" or "paused"), paused_by ("time scale 0", "gameplay pause", "play running", one entry per cause) and rate, the seconds of that clock that pass per wall-clock second. A task.wait(1) taken at engine.timeScale = 0 reads clock_state = "paused" and paused_by = { "time scale 0" } with its whole second remaining, and comes due once the scale is above zero again; a wait whose clock reads "running" is counting down.

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)

await and task.await are the same call. A promise is a value: every task that awaits it gets its result, the ones already waiting when it settles and any that await it later, for as long as you hold the handle the call returned. Awaiting a promise that has already settled returns at once, in the same resume, with no yield. A rejected promise raises its error at the await (a string at the await's line, any other error value as itself), so pcall(function() return await(p) end) catches it; p:rejection() then reads why, { message, origin?, stack? }. A rejection is kept apart from any value: a promise resolved with a table of any shape delivers that table. A rejection nothing sees (no await receives it and nothing reads rejection()) is written to the error log once its handle is gone, so a future you hold and await later reports nothing.

The handle a promise-returning call hands back is a future: a value you keep. Put it in a table, return it, pass it to another task, and await it whenever you need the value. f:await() is the same call as await(f); f:state() reads "pending", "resolved", "rejected" or "cancelled" without taking the value, f:rejection() reads why a rejected one was rejected, and f:source() names the line that created it. Starting several calls before awaiting any runs them at the same time:

task.spawn(function()
  local pending = { http.get_json(urlA), http.get_json(urlB) }  -- both in flight
  local a, b = pending[1]:await(), pending[2]:await()
end)

A call's handle is already a future, so keeping it is all a later await needs, and f:state() answers whether it has arrived:

local pending = { todo = http.get_json(todoUrl) }   -- started once, held
local function isReady() return pending.todo:state() ~= "pending" end
local function get() return pending.todo:await() end  -- any number of tasks may wait here

@builtin::modules.future (local future = require("@builtin::modules.future")) makes futures of your own and adopts ids:

CallResult
future.new(onCancel?)(f, resolve, reject): a pending future and the two functions that settle it once, each answering true, or false and the reason; onCancel runs if the future is cancelled
future.resolved(value) / future.rejected(err)a future already resolved with value / rejected with err
future.wrap(promise)the future naming a promise, from its future or its id string
future.after(seconds)a future resolving with true after seconds of real time
future.spawn(fn, ...)runs fn(...) as a task; the future of what it returns or raises
future.all(list)every value, in the order given; the first rejection rejects it and cancels the rest
future.race(list)settles as the first of them settles; the rest are cancelled
future.any(list)the first value resolved; rejects with every error when none resolves
future.allSettled(list){ status, value?, error? } for each, once all have settled
future.retry(fn, attempts, delaySeconds?)calls fn until it succeeds, at most attempts times
future.fromEvent(event, predicate?)the next fire of a Signal or component event the predicate accepts; disconnects itself

Every future answers f:await(), f:state() ("pending", "resolved", "rejected", "cancelled"), f:rejection() ({ message, origin?, stack? } once rejected, else nil), f:source() (the line that created it) and f.handle (its id). It also chains, with no require: f:andThen(onResolve, onReject?) is a future of what the handler returns (a future it returns is awaited), f:catch(onReject) recovers from a rejection, f:finally(fn) runs fn however it settled, f:tap(fn) sees the value, f:timeout(seconds, reason?) rejects when real time runs out first, and f:cancel() ends it. In type annotations, Future<T> is a future whose await delivers a T, and PromiseHandle is the same value with no stated type; f:andThen(fn) checks as a Future of what fn returns.

Combine futures instead of awaiting them one after another:

local future = require("@builtin::modules.future")
task.spawn(function()
  local maps = future.all({ http.get_json(urlA), http.get_json(urlB) }):await()  -- both, in order
  local nearest = future.race({ future.spawn(searchNear), future.spawn(searchFar) }):await()
  local ok, err = pcall(function() return future.spawn(loadLevel):timeout(5):await() end)
end)

Cancelling reaches the work: f:cancel() (or a race another input won, a timeout that ran out, the first rejection of an all) cancels the task running a future.spawn or a handler, and everything that task started, each unwinding at its wait. A future chained from a cancelled one is cancelled too, and a future whose last chain or combinator let go of it is cancelled, so work nobody waits for stops. Awaiting a cancelled future raises the error task.isCancellation(err) recognises. A future an engine call handed back runs to its end; its cancel() answers false. The task running a handler belongs to what started the chain (a component instance, a scene load, a task scope), so its end cancels the chain too.

An await that nothing will ever answer raises instead of waiting: a string that names no promise (a file path, say, or an id typed by hand), the id of a promise the engine already released because nothing held its handle any more, or an extra argument such as an options table. The error names which one, at your await line.

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(). If you are reaching for a wait because you want to observe a MOMENT — when a function ran, when a value changed, what was in scope at a line — stop and attach a probe instead. A wait guesses the timing and blocks you while it guesses; a probe tells you when it happens and lets you carry on. core/observing covers it and the probe toolbox is the surface. Waits are for sequencing your own work, not for catching someone else's.

When what you are waiting for is something the renderer produced, 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. Which drawn frames it counts follows from what you are about to read:

  • A picture (a capture, a pixel readback, two renders compared) needs every pipeline the scene asked for in place. The bare task.waitDrawnFrames(n) counts a frame only once they are: a material's pipelines build off the frame thread, and a frame drawn before they land drew its placeholder, so a wait taken after creating a material returns with that material drawn as itself. While builds keep landing it holds, and it raises an error naming them once they stop moving.
  • A quantity every drawn frame republishes whatever pipelines it bound (a camera observation, a value the renderer reports each frame) is fresh on any frame the renderer draws. task.waitDrawnFrames(n, { settled = false }) counts every one of them, so a compile burst elsewhere in the scene holds it no longer than a frame takes to draw, where the settled wait would hold it for the whole burst.
local before = time.drawnFrameCount()
task.waitDrawnFrames(2)
print(time.drawnFrameCount() - before)   -- at least 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).

Random numbers you can replay

math.random is one stream per Luau VM, shared by every script that VM runs. math.randomseed(n) reseeds it, and the reseed is for all of them: a draw anywhere else in between moves the sequence, so it reproduces only while nothing else draws from it.

When a result must come out the same again (a procedural level, a seeded loot table, an AI decision that replays, a saved design that regenerates), make a seeded random number generator of your own, a deterministic RNG, with Random.new(seed). Each generator holds its own stream: the same seed gives the same sequence on native and in the browser, any number of them run side by side, and none of them draws from or reseeds math.random.

local rng = Random.new(1234)        -- same seed, same stream, every run
local roll = rng:nextInteger(1, 6)  -- integer in [1, 6], both ends included
local t = rng:nextNumber()          -- float in [0, 1)
local x = rng:nextNumber(-5, 5)     -- float in [-5, 5)

local saved = rng:state()           -- the position, as a string a save can hold
local resumed = Random.fromState(saved)   -- draws exactly what rng draws next
local branch = rng:clone()          -- an independent copy at the same position

Give each system its own generator, seeded from the level, the entity or the design it builds, so one system's draws never move another's. Random.new() with no seed takes its seed from the clock; record rng:state() when you need to reproduce that run.

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.

  • documentation
  • guide