Log inGet started

Building a game — the play-mode authoring loop

Updated 6 September 2026

This guide walks that loop end to end: author in play, swap to edit, review, accept (or reject), and wire the player + camera. It assumes the world/scene/engine model from the core/scenes, core/engine, and core/worlds guides.

First, though: something already runs this whole loop. workflow.list names the jobs this world knows end to end, and making a game is one of them — game invents it, lays the skeleton, builds it in parts that run at the same time, plays it to an outcome, fixes what broke and publishes it, stopping to ask you for the pieces it cannot do itself. workflow.start --name game begins it; workflow.next takes what it is waiting on and workflow.answer hands the result back. Reach for that when the ask is "make me a game" — it is the difference between doing the job and running it. Read on when you want this loop in your own hands, or when you are answering one of its asks.

Why author in play

The engine runs in edit or play (engine.mode), and component lifecycle splits along that line:

  • awake / start run in both modes — they fire whenever a component initialises.
  • update / fixedUpdate are the gameplay tick: they run while the gameplay clock runs.
  • editorUpdate is the authoring tick: it runs while that clock is held.

Entering play starts the clock and entering edit stops it, so those two lines read as "play" and "edit" for as long as nobody touches engine.paused. The pause flag is a second, independent axis, and core/components has the four-state table it produces — including the edit-mode state where both hooks fire and a component declaring both ticks twice a frame.

So a character only moves in play: its Locomotion, controller, and animation all live in update. To see the game as a player will — the avatar striding, the camera following, an action firing on click — you must be in play. That is where you build: enter play, and construct the playable thing while it is actually playing.

engine.mode = "play" -- gameplay ticks; spawn and wire while it runs engine.mode = "edit" -- back to authoring (this triggers the review — below) engine.onModeChange(function(newMode, oldMode) end)

Everything you spawn in play is live but not yet part of the scene. It exists in the running world; it is not in the scene file. The swap back to edit is where you decide what becomes permanent.

Step 1 — build the playable thing (in play)

Spawn and compose exactly what you want the game to be. For a character that is the avatar system: a body bundle + an independent movement controller + an animation system, instantiated in one call.

engine.mode = "play"

-- Instantiate a character: body + standard humanoid controller + Locomotion.
local hero = asset.resolve("hero_av", "avatar"):instantiate()

-- Layer gameplay behaviour on top WITHOUT editing the systems it rides on.
-- AnimOverlay blends an action onto the running locomotion graph — an
-- upper-body attack on a control, lower body still walking.
hero.component.add("AnimOverlay", {
    clip = asset.resolve("@builtin::…animations.A_Jump_Idle_Masc…", "animation"),
    region = "upperBody",     -- mesh-independent: resolves through THIS body's rig
    control = "interact",     -- a control on `inputMap`: key, pad button and touch
})

-- Attach a prop to a known point WITHOUT knowing the per-mesh bone name.
local h = hero.component.get("Humanoid")
h:attach("righthand", sword.id)   -- role-keyed; works on any humanoid mesh

Drive it, press the control, move it around. This is a real play session: what you see is what a player would see. Iterate here freely — none of it is committed to the scene yet.

(The avatar / locomotion / retarget / socket surfaces are the topics/animation guide and the avatar asset type; this guide is about turning a play session into a scene.)

Don't have the body, prop, or environment yet? This step assumes hero_av already exists. When the thing your game needs doesn't — a character model, a prop, a vehicle, a scene — first look in the ZeroMind library; if nothing fits, generate it from a description: asset.resolve("mesh_gen", "service"):invoke({ prompt = "..." }) lands a real .glb you spawn, with anim_gen / audio_gen / pbr_gen / world_gen for animations, sound, materials, and environments. See generating-assets-and-content. Don't fall back to building the character or prop out of primitive shapes — that is a placeholder, not your game's content. A built-in demo may show you a mechanic (movement, a camera, a HUD) with throwaway geometry; take the mechanic and give it real, generated or library-sourced content.

Generation runs in the background, so you don't have to wait staring at an empty slot. Drop a quick stand-in where the asset goes, keep building, and swap the stand-in for the real model in place once the generation lands — the appearance toolbox's replaceWithContent tool removes the stand-in (and its subtree) and spawns the asset with the same position, rotation, scale, name, and parent (asset is the completed generation's own asset, as the services toolbox's status tool reports it, or any library asset). The scene stays playable the whole time and ends made of real content — you never accept a stand-in into it.

Step 2 — leave play, and review

When the game plays the way you want, swap back to edit. If you created anything during play, the engine freezes the moment (pauses) and refuses to discard it silently — leaving play would otherwise destroy live-only objects. Review what you made:

the sceneAuthoring toolbox's changes tool

changes pauses the engine at the exact instant of the call and renders a frozen review — it writes nothing. It reports:

  • Entities you spawned, clustered so one spawn batch reads as one decision, each with its components and parent.
  • Edits to existing scene entities, with field-level diffs.
  • The assets those changes reference — flagging play-created ones and reload-broken orphans (a live object pointing at something no file backs).
  • Source files edited during play.
  • Session post-process effects and UI screens.
  • The player + camera state — live truth vs what the scene config records.

Every line is numbered [N], and each [N] is addressable in the accept/reject calls by its number, by an entity name (or part of one), or by a group label: "assets", "fx", "ui", "edits", "others".

The engine stays paused at the reviewed moment until you accept or reject — the review is a still frame you act on.

Source edits during play — the play shadow

A /source write while play runs lands on the play shadow: the bytes are live in the session immediately, disk source is untouched, and a guarded play-exit discards them unless they were kept. vfs.playShadowPaths() lists what is currently held, and every write that lands bytes reports durablefalse for a shadowed one, with the routes to disk beside it, and true for one that reached source.

Applying an edit you want kept

When the write IS the authoring step — you are iterating on a module against the running session — say so on the write and skip the shadow entirely:

vfs.write("/zero/source/game/Vent.component/init.luau", src, { durable = true })

The bytes go to canonical source, the component reloads into the session that is still playing, the mode never changes and nothing is left to promote. It costs the rest of the session nothing — no pause, no review, no mode flip — and it raises with the reason when the bytes cannot become source (a frozen world version, a world that has not bound, a live session that dropped). The MCP write_file and edit_file tools take the same durable: true.

Keeping an edit already on the shadow

Three routes take a shadowed edit to disk. A session is shared — the mode, the clock and the review queue belong to the engine, not to one caller — so pick by what the route costs everyone else in it:

the wld toolbox's promoteAndSwitch tool, given "play" — every pending source edit, mode unchanged

writes through every pending /source edit and leaves the engine in the mode you name, freezing only for the length of the call.

engine.paused = true
vfs.promotePlayShadow("/zero/source/game/Vent.component/init.luau")
engine.paused = false

writes through one path and leaves every other pending edit shadowed — the choice when the rest of the shadow set is someone else's work. The promote needs the write lock released, so it runs inside a pause you take and hand back.

It takes one file, not a folder, and it raises when the promotion cannot happen — the path is not shadowed, play is still running, the workspace is read-only. So the line above is the whole call when you are promoting your own edit and want a failure to stop you. Promoting a set someone else may be writing to, where one refusal should not abandon the rest, catches each one and carries on:

for _, path in ipairs(vfs.playShadowPaths()) do
  local ok, err = pcall(vfs.promotePlayShadow, path)
  if not ok then log.warn(("could not promote %s: %s"):format(path, tostring(err))) end
end

changesacceptChanges (above) is the third: it reaches pending entity changes as well, and holds the whole session paused from the review until the verdict.

Step 3 — wire the player and camera

A scene is multiplayer: every connected user gets their own avatar, spawned from the scene's player config. A live avatar in your session is not that config yet — set it:

  • setPlayer takes an entity id, a name, a bundle name, or part of one, and makes it the player avatar — the body every joining user spawns as. A live entity is adopted into your player slot immediately (you are the local player); a bundle is spawned into the slot so you test exactly what joining players get. The config is stagedacceptChanges writes it, converting a live entity into a bundle when one doesn't already back it.
  • setCamera replaces the default (which follows the avatar) with your component. It attaches to the camera entity now; the config records on acceptChanges. The default needs no call — with a player set, the camera follows the avatar.

Both only stage config. Nothing is written until you accept.

Step 4 — accept what should become the scene

acceptChanges is the only thing that writes the scene.

  • acceptChanges with no argument — everything under review
  • acceptChanges with a selection — indices, entity names, or kinds

A partial accept leaves the rest live — the next changes() shows the remainder. The selection takes item numbers, entity names (or parts), and group labels.

Crucially, accepting does more than write entity records — it makes the scene self-contained. A scene must never point at something that vanishes when play ends, so accept promotes and freezes everything the accepted entities reference. The result tells you exactly what it converted:

{
  written          = "/zero/source/scenes/main.scene/scene.json",
  accepted         = { …entity ids… },
  entities         = 31,                  -- entity count in the written scene
  promotedAssets   = { … },  -- play-created /runtime assets copied into /source
  promotedEdits    = { … },  -- play-edited source files saved (group "edits")
  convertedMeshes  = { … },  -- orphan runtime meshes frozen to .mesh assets
  convertedMaterials = { … },-- orphan runtime materials frozen to .material assets
  convertedTextures  = { … },-- runtime GPU textures frozen to .texture assets
  convertedFx      = { … },  -- session post-process → .shader + a PostProcess entity
  convertedUi      = { … },  -- session UI screens → UiPanel entities
  convertedFeatures = { … }, -- session render features → .renderFeature + a RenderFeature entity
}

What each conversion means:

  • promotedAssets — assets you created during play land in a copy-on-write /runtime store (play never writes /source). Accept copies the ones an accepted entity uses into the world's source, so the reference survives. The player/camera config refs promote the same way — a freeze-created avatar bundle is saved into source and the config repointed at it.
  • promotedEdits — files under /source you edited during play are play-shadowed (not yet written through). Including the "edits" group writes them through.
  • convertedMeshes / convertedMaterials / convertedTextures — a Model pointing at a raw GPU mesh/material/texture (a renderer.*.create with no backing file) would break on reload. Accept reads the resource back, encodes it to a real asset, and repoints the accepted records at it as a typed reference (so it resolves in the scene's dependencies and gates at publish).
  • convertedFx / convertedUi — a postprocess.add stack becomes a .shader plus a PostProcess scene entity; a registered UI screen becomes a UiPanel entity carrying its widget tree. Loading the scene re-runs them.
  • convertedFeatures — a render feature you enabled at the console with renderer.feature.create becomes a .renderFeature asset (its source promoted into /source when authored ad-hoc) plus a RenderFeature scene entity that re-creates the render pass on load.

One rule decides what is frozen: how the runtime resource was created. Something authored ad-hoc (created from an execute call) is frozen — nothing else would re-create it. Something reproduced by code on load (a component or the scene's entrypoint creates it every time) is not frozen — the code already re-creates it, so freezing it would duplicate it. So a mesh your component builds in awake stays code-owned; a mesh you hand-built at the console becomes an asset.

Rejecting

  • rejectChanges — drops a selection, or all of it with no argument
  • the wld toolbox's edit tool — flips once nothing is unaccepted

Rejected entity changes never enter the scene; the live objects stay live for the rest of the session but are hidden from later reviews so they stop nagging. Rejected source files/assets are reverted to their pre-play state, and one created during play is removed.

Leaving play is not a way to throw work away. wld.edit refuses while anything is unaccepted and names what is held; settling it — acceptChanges or rejectChanges, all of it or a selection — is what lets the flip through. After a throwaway test session that means rejectChanges with no argument, then wld.edit.

Multiplayer: accepting against a shared world

A world is shared in edit mode too, so the canonical scene may have changed since you opened your review. acceptChanges does a three-way drift check per record (the baseline at review time, your frozen version, and what canonical holds now). Clean records and safe merges apply. A genuine divergence aborts the whole accept — nothing is written — and returns a conflict report:

{ conflicts = { { name = "hero", kind = "update/update", fields = { "transform" } } } }

Resolve it deliberately:

  • acceptChanges with onConflict = "mine" — overwrite with yours
  • acceptChanges with onConflict = "theirs" — keep the other editor's

Never a silent overwrite of another editor's work — you choose.

Persisting vs publishing

Accepting writes the scene to /zero/source, which is durable the moment it is written and shared live with everyone in the world — there is no separate "save" step (the core/engine and core/worlds guides cover why source always persists). That is different from making a playable version for players, which is a deliberate publish — zm commit / zm push in the engine bash (the core/worlds guide). Saving and publishing are different acts: accept makes it part of the world; push makes it a release.

The loop, in one piece

engine.mode = "play"
local hero = asset.resolve("hero_av", "avatar"):instantiate()
hero.component.add("AnimOverlay", { clip = attackClip, region = "upperBody" })
-- …play, iterate, get it feeling right…

engine.mode = "edit"                      -- freezes; flags unaccepted changes
- **changes** — review the frozen snapshot
- **setPlayer** — every joining user spawns as this
- **acceptChanges** — writes the scene, promoting and freezing refs
-- inspect the result: promotedAssets / convertedMeshes / … = what became permanent

The model to carry: play is where you build, edit is where you commit. You construct the game by playing it; changes() shows you what the play session created; acceptChanges() turns the parts you keep into a scene — entities, the player config, and every asset they depend on — so it loads the same for the next person who opens the world.

  • documentation
  • guide