Log inGet started

Building a game — the play-mode authoring loop

A game in Zero is a scene the world loads: its entities, their components, the player avatar, and the camera. The way you build that scene is the part worth learning, because it is not "place objects…

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.

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 run in play only — the gameplay tick.
  • editorUpdate runs in edit only — editor tooling.

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 mouse click, 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
    mouseButton = 0,
})

-- 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, click, 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 landstools.use("appearance", "replaceWithContent", "rider_slot", asset) 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 tools.use("services", "status", g.id).asset, 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:

tools.use("sceneAuthoring", "changes")

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.

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:

tools.use("sceneAuthoring", "setPlayer", "hero")     -- entity id / name / bundle name / part of one
tools.use("sceneAuthoring", "setCamera", "OrbitCam") -- a component that drives the camera
  • setPlayer makes something 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.

tools.use("sceneAuthoring", "acceptChanges")                       -- everything under review
tools.use("sceneAuthoring", "acceptChanges", { 2, "hero", "edits" }) -- a selection

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 and discarding

tools.use("sceneAuthoring", "rejectChanges", "tile_")  -- drop a selection (or all, with no arg)
tools.use("wld", "edit", true)                         -- discard EVERYTHING unaccepted, return to edit

Rejected 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. tools.use("wld", "edit", true) is the blunt exit — discard all unaccepted play work and return to edit in one call (useful after a throwaway test session).

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:

tools.use("sceneAuthoring", "acceptChanges", sel, { onConflict = "mine" })    -- overwrite with yours
tools.use("sceneAuthoring", "acceptChanges", sel, { 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
tools.use("sceneAuthoring", "changes")            -- review the frozen snapshot
tools.use("sceneAuthoring", "setPlayer", "hero")  -- every joining user spawns as this
tools.use("sceneAuthoring", "acceptChanges")      -- writes the scene; promotes/freezes 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