---
title: "Scripting & tasks"
description: "Two kinds of code run in Zero, and it's worth knowing which you're writing:"
section: "Core"
slug: "core-scripting-and-tasks"
canonical: "https://origozero.ai/docs/core-scripting-and-tasks"
updated: "2026-09-06T10:26:33.453111008+00:00"
tags: ["documentation", "guide"]
---

# 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:

```lua
-- 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 `local`s.** 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:

```lua
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:

```lua
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:

```lua
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.

```lua
-- 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
```

```lua
-- 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.

```lua
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:

```lua
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):

```lua
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.

```lua
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.

```lua
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.
