Log inGet started

Building a game — the play-mode authoring loop

Updated October 19, 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. zero 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()
-- Make it your body. The player's controls drive the local player's body and
-- no other character, and the bind despawns the body it replaces.
layers.active.players.localPlayer.avatar = hero

-- 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::systems.characterController.files.synty.animations.A_Jump_Idle_Masc_anims.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 rifle = entity.find("rifle")
local h = hero.component.get("Humanoid")
h:attach("righthand", rifle.id)   -- role-keyed; works on any humanoid mesh
-- Offsets and scale are metres in the humanoid's own space, the same on every body.
local hat = entity.find("hat")
h:attach("head", hat.id, { position = { 0, 0.12, 0 }, scale = 1.1 })

An item the player USES (a rifle firing) is that attachment, a control from an input map the world declares (inputAuthor.map writes one), and an effect played at the item's muzzle on each press: the effect plays on the item, so it follows the item. The rifle is one part of the game, and every part is found the same way: break the game into the systems and parts it needs (for a shooter, the controller and camera, the weapons, hit detection, damage and health, enemy AI, the HUD, the level, audio and effects), and before writing any of them, search for one that already does it. Find the effect by what it shows. The zeromind search answers this world's effects and the published ones; its hits are a shortlist, and what decides between them is what each one does. Take two or three and read each one's implementation in the shell, which installs nothing:

zm ls-tree @<guid>                 # its files, and the ones it pulls from other worlds
zm grep -n emit @<guid>            # where it does the thing
zm show @<guid>:<path>             # read a file
zm log @<guid>                     # its history

Compare them, preview the footprint with zeromind.preview, and take the one that fits. One this world does not hold yet comes in through installAsset; the effect then resolves by its guid. If it does not do the job once it is playing, remove it and take the next.

local found = tools.use("zeromind", "search", "a rifle's muzzle flash", "effect")
for _, hit in ipairs(found.assets) do
    print(hit.guid, hit.name, hit.summary)   -- the shortlist to read with `zm`
end
local chosen = "<guid>"   -- the candidate the reads above showed fits
local guid = chosen
for _, hit in ipairs(found.assets) do
    if hit.guid == chosen and hit.local_ == nil then
        guid = tools.use("zeromind", "installAsset", chosen).root_guid
    end
end
local rifle = entity.find("rifle")
local flash = asset.resolve(guid, "effect")
flash:warm(nil, 4)   -- nil names the effect's default entry; flash:entries() lists them all
local weaponMap = asset.resolve("weapon", "inputMap")
local controls = weaponMap:activate()
controls.fire:onPressed(function()
    flash:play(nil, { root = rifle, offset = { 0, 0.04, -0.62 } })
end)

An effect's README says what it shows and what each input changes, and zm show @<guid>:README.md reads it before the install. When no effect shows what the game needs, make one: the topics/visual-effects guide is the short path from wanting an effect to having one.

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.

A character you instantiate without binding it (an NPC, a mannequin) stands where it is until a component of yours writes its CharacterController input bus (inputX / inputZ / inputJump), the way an AI brain does (man CharacterController).

(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 the avatar hero_av, an entity named rifle and an input map weapon with a fire control already exist. 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 refuses the flip rather than discard it silently — leaving play would otherwise destroy live-only objects — and the session stays in play, running, while you settle what you made. Review it:

the sceneAuthoring toolbox's changes tool

changes reads the world within the frame it is called in and renders that frame's 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 review describes the frame it was taken in, and the session keeps running while you act on it — a review you hold open costs nobody else on the world their simulation.

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

Bytes that reached the shadow before you knew you wanted them — a capture's save_path, an upload_file, a bash cp, every write you made before the decision — are taken to disk by naming them:

vfs.promotePlayShadow("/zero/source/game/Vent.component/init.luau")

Play keeps running. The mode, the clock and every shadow entry the call did not name are exactly where they were, so this costs the rest of the session nothing.

It takes one file, not a folder, or a list of them. One authored folder asset shadows its entry file, its README and its metadata together, and they settle together: promoting one file of an asset promotes the asset's other shadowed files with it, and the second return value names every path that settled. A slice is tens of paths, and naming the list is what keeps the call to your own work on an engine other people are running in:

local mine = {}
for _, path in ipairs(vfs.playShadowPaths()) do
  if string.find(path, "/zero/source/game/", 1, true) == 1 then table.insert(mine, path) end
end
local settled = vfs.promotePlayShadow(mine)

Every named path is attempted, and one that refuses does not stop the ones after it. The call raises when any promotion could not happen (a folder covering shadowed edits, a read-only workspace), naming each refusal and how many settled, and the settled ones are canonical source either way. A named path that held nothing on the play shadow settles nothing and raises nothing; the third return value lists it. vfs.promotePlayShadow(vfs.playShadowPaths()) is the whole set, which is yours to take only when the engine is yours alone.

vfs.revertPlayShadow takes the same one path or list and does the opposite: the pre-play copy goes back into the live slot, or the file goes away when play created it.

changes → acceptChanges (above) reaches pending entity changes as well, and the session keeps running from the review to the verdict — on a world several agents share, settling your own work leaves every one of them theirs.

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 staged — acceptChanges 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()
layers.active.players.localPlayer.avatar = hero    -- your body: the controls drive it
hero.component.add("AnimOverlay", { clip = attackClip, region = "upperBody" })
-- …play, iterate, get it feeling right…

engine.mode = "edit"                      -- freezes; flags unaccepted changes
- **changes** — review what the play session made
- **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