Log inGet started

The engine

Updated October 18, 2026

A live, shared world

Every world is a multi-user session. Everything you do is live — your edits sync to everyone else in the world as you make them, and you see theirs. There is no "save" step: with a world bound, writes are durable the moment you make them (more on persistence next). The world's filesystem is the source of truth; "what you see is what exists."

Publishing a playable version for players is a separate, deliberate act (commit/push) — covered in the worlds guide. Saving and publishing are different things.

What persists

Durability comes from the world holding /zero/source, so the first question is which world that is. world.guid() answers it: a guid when one is bound, nil on a bare local boot. With no world, /zero/source is a session buffer — a write is taken, reads back exactly as written for the rest of the run, and ends with the process. The engine says so on every such write, in the notice your tool call carries back; world.create / world.swap bind a world and end it.

With a world bound, the filesystem boundary is the whole story:

  • /zero/source/... — durable. Anything you write here persists (it's stored in the backend and synced to everyone). This is where all authored content lives. No save step.
  • /zero/runtime/... — live runtime state. Never persists; it's rebuilt as the world runs.
  • /source/tmp — local scratch that does not sync to other clients. Use it for throwaway working state.

So "will this survive a reload / be seen by others?" has a simple answer: yes if a world is bound and it's under /zero/source (outside tmp), no otherwise.

Edit vs play

engine.mode is "edit" or "play", and you flip it directly:

engine.mode                 -- "edit" or "play"
engine.mode = "play"        -- enter play
engine.mode = "edit"        -- back to edit
engine.setMode("play", { strict = false })            -- enter play, waiving the script gate for this call
engine.onModeChange(function(newMode, oldMode) end)   -- react to flips
  • Edit is where you author. Entering it stops the gameplay clock, so component update(dt) / fixedUpdate(dt) go idle and editorUpdate(dt) takes over — set engine.paused = false here and all three tick together (see the pause section below). Writes to /zero/source are durable.
  • Play is where gameplay runs. update / fixedUpdate tick.

Entering play doesn't just "start ticking": the engine snapshots the edit state, re-initialises a fresh play world (so components re-run awake/start in play), and a write to /zero/source lands on the play shadow: live in the session, and synced to the world as it lands where the world syncs the path. Leaving play through a guarded exit discards that play world and restores the edit snapshot, so a shadowed write is put back to its pre-play copy unless it was kept; a restart before that exit keeps it, and survivesRestart on the write's report says so. (The exact component lifecycle across the flip is in the components guide.)

Play belongs to the callers in the play session

Several agents drive one engine, and play belongs to the ones taking part in it. The play session is the caller that entered play and every caller that writes engine.mode = "play" (or calls the play tool) while it runs. engine.playSession names them and says whether you are one:

engine.playSession   -- { agents = { { key, agent } }, takesPart } in play, nil in edit
  • Taking part. Your /source writes land on the play shadow, and engine.mode = "edit" (or the edit tool) takes YOU out of the session. While other callers are still in it, the engine keeps playing for them: the exit answers for your own work alone (the source files you edited in play and the entities you tuned), engine.discardPlayChanges() throws away exactly that, engine.setMode("edit") answers leftPlaySession = true with mode still "play", and your writes land on canonical source from then on. The last caller to leave takes the engine to edit, and its exit answers for everything the session left behind.
  • Not taking part. You author beside the session: your /source writes land on canonical source as they do in edit, and no exit from play judges them. Leaving play is the session's to take, so your engine.mode = "edit" is refused, naming the session's callers; engine.waitForEdit(reason) tells them you are waiting, and engine.mode = "play" joins. Ending their session for them is a deliberate act: engine.setMode("edit", { endSession = true }) takes the engine to edit for every caller and answers for everything the session holds, and engine.discardPlayChanges() arms its discard. The simulation is one for the whole engine, so the entities, the cameras, the clock and every capture are the session's play world: an entity you change through the ECS goes back to what the scene holds when the session ends, and the engine says so in a notice the first time you tune one. The run-state block a capture carries answers for you: playSession.takesPart reads false and its note names the session, so a camera the session's game spawned is not mistaken for your edit view.
  • Alone. A session of one caller is the whole engine's play: its exit takes the engine to edit, as does a flip no caller is named for (the editor's Stop button, a component). A caller whose connection ended no longer holds the session open, and the next exit judges what it left.

Every write no caller is named for (a component, the engine's own work) lands on the shadow. Any other agent on the engine is authoring beside the session, so its writes, and the writes of the tasks its calls spawned (a generation finishing, an import it runs, a shell command it runs through bash and the background jobs that command starts), land on canonical source as they would in edit mode, and the play-exit somebody else takes leaves them where they are. What the engine does on its own after such a write is the simulation's: the automatic import of a loose file waits for play to end or pause. A caller is one connection to the engine, and the agents one agent session runs (its sub-agents) share that connection: each of them that passes its own name in the as argument every tool takes, on every call, is a caller of its own, with its own play session, play-shadow writes, roster name, shell session and notices, while the work all of them write is staged and committed as the connection's. Agents sharing a connection that do not name themselves count as one caller. vfs.durability(path, { pending = true }) answers for the caller asking.

engine.discardPlayChanges() arms that discard for a flip the safeguard refused: it returns paths, the source files the flip would throw away as it stands, and keep, the routes that keep any of them first, and posts a notice naming them. The exit then throws away what it answers for as it stands when it runs, your own edits while other callers stay in play and every edit the shadow holds when it ends the session, and names each path it took in a notice and the engine log. The arm is the caller's own, and a call no caller is named for cannot be told apart from any caller, so the exit that ends the session also takes a discard armed for the whole session from the other side of that line: an exit no caller is named for takes the one a named caller armed, and a named caller's exit takes the one a call no caller was named for armed. An agent whose transport names it on some calls (execute) and not on others (edit) thus arms and leaves as one caller. A write made with { durable = true } lands on canonical source, and the discard keeps it.

Keeping one does not cost you play. vfs.promotePlayShadow(path) — or a list of paths — takes them onto canonical source with the session still running, and vfs.write(path, bytes, { durable = true }) states the destination up front so the write skips the shadow entirely. vfs.durability(path) answers where any write's bytes went and names both routes; the core/vfs guide has the model, the whole set a folder write shadows, and what each route costs the rest of the session.

Entering play is also gated on the scripts you answer for being healthy: while a script in the world's own content carries an error-severity diagnostic, engine.mode = "play" raises instead of flipping — refusing to enter play — N script error(s) in your own content: followed by the offending path:line — message entries. The world's own content is every script under /zero/source except library content under /source/libs and /source/deps, session scratch under /source/tmp and this machine's own content under /source/local (the shell's /tmp and ~): the same files zm.push checks, so a half-written scratch script there, or in your home folder under /zero/home, holds up neither play nor a push. Run lsp.checkAll({ scope = "user" }) to list them yourself, fix them, and flip again; lsp.check(path) checks any one file, scratch included. The gate is calibrated by the lsp.strict world setting: "strict" (the default) blocks on any error, "soft" blocks only structural errors (syntax, unresolved require, broken lifecycle-callback signatures), and "off" logs a warning and proceeds. Returning to edit is never gated — you can always get back to fix the error. Neither override carries to publishing: zm.push refuses unconditionally while user-script errors exist, whoever wrote them (see the development guide).

You answer for what you wrote and for the world you opened — the content already there when you bound it, including your own from an earlier session. A file a neighbour wrote while you were already running is reported rather than gated: the flip proceeds and the diagnostics go to the engine log. Theirs to finish, since you hold a copy and correcting it would write over an edit still in flight. Read the log after a flip to see what rode along: /zero/runtime/logs/engine reads it as a file, and the logs toolbox's search tool narrows it to the lines another session's content raised.

To take one flip past a diagnostic without changing the gate for anyone else, pass the strict waiver on the call:

local report = engine.setMode("play", { strict = false })
for _, d in ipairs(report.bypassed) do
    print(("entered play past %s:%d — %s"):format(d.path, d.line, d.message))
end

engine.setMode is engine.mode = ... with the gate under your control for that one call: the world's lsp.strict_mode is untouched, so every other session and every later session of the world keeps the gate it had, and report.bypassed (mirrored into the engine log) names exactly what the call went past. lsp.setStrictMode is the other lever and a different one — it writes lsp.strict_mode into the world settings, which every session reads.

Paused vs running

Pause is a separate axis from edit/play. engine.paused gates the gameplay tick:

engine.paused = true        -- freeze gameplay
engine.timeScale = 0.5      -- or slow it down (1.0 real, 0.0 frozen, 2.0 double)

When paused, update(dt) / fixedUpdate(dt) stop — but editorUpdate(dt) keeps firing, so the authoring loop stays live. engine.timeScale scales time without fully stopping it. Both are orthogonal to engine.mode: edit/play and paused/running are independent, which is why the pair has four states and not two. A mode flip resets the flag to that mode's default (edit paused, play running), so the two axes look like one until something writes engine.paused — and in edit with the clock running, update and editorUpdate fire together. core/components has the table.

Who holds the run state

One process runs one simulation, and several agent sessions drive it at once. The pause is decided for everybody by whoever writes it, and the mode by the callers in the play session (above). Both name the caller that flipped them:

engine.pauseOwner   -- who asked for the standing pause: { id, agent, name, heldSeconds } or nil
engine.modeOwner    -- who flipped the engine out of its boot mode: { id, agent, name, heldSeconds } or nil

id is the caller key vfs.currentAuthor().id reads, so the run state and the play-shadow set name the same caller. agent is the name the world's roster holds for that caller now, the name to ask it by: name is its account's username, which every agent of one account shares. pauseOwner is nil while the engine is running and nil when the standing pause is the mode's own rather than something somebody asked for. modeOwner names a caller only while it holds the mode: it is nil in the mode the engine booted into (the flip back to it ends the hold), once the caller that entered play has left the session (which may keep running for others; engine.playSession names them), and once that caller is no longer on the world's roster.

Every result an agent gets from a play-mode engine that is not stepping carries a runState block saying so, naming the holder. It is decided per call rather than from whether the state snapshot moved, because under a steady pause the snapshot never moves: state reports unchanged and the pause would go unmentioned. Every capture result carries the same block whatever the engine is doing, and the frame block a capture tool returns carries paused, gameplayReady and pausedBy beside frame and sceneTime: a frozen simulation and a still scene hand back the same rising frame, the same rising scene time and the same bytes, so a pair of captures taken to prove motion reports a difference of exactly zero either way.

The play and edit tools answer for both cells, because the state each one names covers both: play leaves the engine in play mode AND running, so it resumes a standing pause, and edit leaves it in edit mode AND at rest, so it takes one. Either tool reports the state it produced, { mode, paused, resumed, settled } for play and { mode, paused, froze, settled } for edit, where resumed and froze answer for the move the call made: a play call that met a stopped simulation and left it running answers resumed true whichever of the two cells it wrote to do it, and edit answers froze the same way. engine.mode moves the moment it is written, and the scene follows over the frames after it, as the flip unloads the root layer and loads it again for the new mode; both tools answer once that rebuild has ended, so settled is true and the entities the caller reads next are the new mode's. A call made from code that cannot yield answers settled false while the rebuild runs, and layers.rebuildInFlight() reads true until it ends. Through MCP, a rebuild that outlasts the call's inline budget answers with a handle to wait on: the flip has happened, and calling the tool again only waits on the same rebuild, so wait on the handle instead. The pause play will not lift is one another caller holds: it refuses and names them, since the frozen moment is what their session is working on, and engine.paused = false (paused: false through the pause tool) is the deliberate way past that.

A play session holds the simulation in play for every caller outside it, so a caller whose edit-mode work needs the engine in edit (an entity edit the session's end would undo) says so and the session hears it:

local done = engine.waitForEdit("promote my scene edits")
-- the run state on every response now names this caller as waiting
done()                      -- or reaching edit mode takes every wait off
engine.editWaiters()        -- what the session holding play reads

How fast time passes

The engine steps on its own. Its frame loop runs whether or not a tool call is in flight, so the real seconds between two of your calls are seconds the world spent running: in play with the clock running, a twelve second gap that nothing called into left a component holding twelve more seconds of update(dt), and two calls sent back to back with no wait between them were nearly three seconds apart. The durationMs on a tool result measures that one call and not the gap before it, so it is not the number to read elapsed gameplay from.

Two consequences, pointing opposite ways:

  • Wait for real seconds, then read. This works. Idle wall clock buys the same simulated seconds, in every state below where the gameplay clock is running.
  • Start it, then poll. This samples seconds apart rather than milliseconds. Enter play and read a position on the next call and a launched body has already crossed the course. To watch something mid-flight, sample it inside ONE call: with the clock running, read the value, task.waitFrames(n), read it again, and return the whole series from that call. Across calls, engine.timeScale = 0.1 slows the world so the next one arrives while the motion is still going, and engine.paused = true freezes it where it stands, so a read or a capture is of that exact moment. To check a result, read the settled end state with data (positions, rotations, raycasts) rather than sampling a series.

Which clock each callback is on:

stateupdate(dt) / fixedUpdate(dt)editorUpdate(dt)
edit, paused (edit's default)idleruns
edit, runningrunsruns
play, running (play's default)runsidle
play, pausedidleruns

engine.timeScale multiplies the seconds update(dt) and fixedUpdate(dt) receive and the seconds task.wait counts, so at 0.25 a second of wall clock hands a component a quarter of a second of dt and a task.wait(0.5) takes two seconds to come due.

A material animated from time (the time a shader's vertex() and fragment() read) runs on the world's seconds at that same rate: it slows with engine.timeScale and stops while a play is paused. Edit mode runs it whatever the pause, so the viewport shows water, grass and effects moving while you author.

Physics runs on that same clock at that same rate: a dynamic body covers the distance a real second of falling covers. So a body's travel between two of your calls is the travel those real seconds bought, and engine.paused and engine.timeScale are what hold it still enough to watch.

getTime(), time.time() (the same reading, under the namespace's name) and time.realtime() are a different quantity. All three read the wall clock, and none of them is scaled by engine.timeScale or stopped by engine.paused: across a paused span a component receives no dt at all while getTime() advances by the whole span. Drive gameplay and animation from the dt a callback receives or from task.wait, and keep getTime() for when something happened and time.realtime() for a wall-clock duration. time.frameCount() counts the frames the engine ran, and task.waitFrames(n) waits on exactly that counter.

Seconds the engine LOST are refused. A frame costing far more than the frames around it reads as a hitch rather than as the rate, and the world takes only a ceiling of it: four times the interval the engine has been holding, and never under 0.1 seconds. A script that blocked a single frame for two seconds gave getTime() and time.realtime() the whole two and gave update(dt) a tenth of a second, so an engine that stalls resumes where the world was instead of teleporting every animation and filling the physics accumulator in one step.

Every target behaves this way: a headless engine, a windowed one, one bound to a live world and one running in a browser all advance the world by the wall-clock seconds of a gap nothing called into.

Who is holding the clock

engine.timeScale is one number for the whole process. Several agents drive one engine at once, and the rate one of them sets for a hitstop is the rate every other one's entities, physics and task.wait deadlines run at. A run that computes a delta of its own from getTime() or time.realtime() is on the wall clock while everything around it is on the scaled one, so at 0.25 it moves four times as far per second as the world it is measuring. Multiply a self-computed delta by engine.timeScale, or read time.deltaTime() or the dt a callback receives, which already have it applied.

engine.timeScale                -- the rate in force
engine.timeScaleOwner           -- who it answers to: { id, name, scale, exclusive, held } or nil
engine.onTimeScaleChange(function(new, old, who) end)   -- every move of it, whoever made it

To run at a rate for a stretch, hold it rather than assigning it. A hold records who took it and refuses a plain engine.timeScale write while it stands, so the rate cannot move under a measurement without the measurement hearing about it:

local release = engine.holdTimeScale(0.08)              -- hitstop, attributed to this caller
release()                                               -- the previous rate is back

local pinned = engine.holdTimeScale(1, { owner = "duel-measurement", exclusive = true })
-- while that stands, another agent's engine.holdTimeScale and engine.timeScale = n
-- are refused with an error naming "duel-measurement"
engine.releaseTimeScale("duel-measurement")             -- hands it back by key, from any call

Holds nest and are counted: the innermost names the rate, and the rate that stood before the outermost is back once the last is released. The release is a closure, so a hold taken in one tool call and left to a later one is handed back by key instead. Every hold answers to a key even when the call states none, and engine.timeScaleOwner.id reads it, so a rate left pinned by a task that ended is always recoverable:

local who = engine.timeScaleOwner
if who ~= nil and who.held then
    engine.releaseTimeScale(who.id)                      -- takes that key's holds off the stack
end

World lifecycle

engine.profile        -- "editor" or "runtime" (read-only; the boot profile)
engine.worldLoaded    -- true once the world's entrypoint has finished loading
engine.onWorldReady(function() end)    -- content synced into the VFS
engine.onWorldLoaded(function() end)   -- the world entrypoint has run (latched: fires immediately if already loaded)

A world boots: content syncs (onWorldReady), then /source/.world_entrypoint.luau runs (onWorldLoaded), which loads the startup scene and seeds defaults.

Finding the rest

engine is an authored module — read it at modules/api/engine/engine.module. Publishing and the shared library are in the worlds guide; how components behave across edit/play is in the components guide; how a loaded world surfaces players and the camera is in the scenes guide.

  • documentation
  • guide