Building a game — the play-mode authoring loop
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/startrun in both modes — they fire whenever a component initialises.update/fixedUpdateare the gameplay tick: they run while the gameplay clock runs.editorUpdateis 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 durable — false 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
changes → acceptChanges (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:
setPlayertakes 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 staged —acceptChangeswrites it, converting a live entity into a bundle when one doesn't already back it.setCamerareplaces the default (which follows the avatar) with your component. It attaches to the camera entity now; the config records onacceptChanges. 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
/runtimestore (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
/sourceyou 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.*.createwith 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.addstack becomes a.shaderplus aPostProcessscene entity; a registered UI screen becomes aUiPanelentity carrying its widget tree. Loading the scene re-runs them. - convertedFeatures — a render feature you enabled at the console with
renderer.feature.createbecomes a.renderFeatureasset (its source promoted into/sourcewhen authored ad-hoc) plus aRenderFeaturescene 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
wldtoolbox'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.