---
title: "Building a game — the play-mode authoring loop"
description: "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…"
section: "Topics"
slug: "topics-building-a-game"
canonical: "https://origozero.ai/docs/topics-building-a-game"
updated: "2026-09-06T10:26:33.542889462+00:00"
tags: ["documentation", "guide"]
---

# 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` / `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.

```lua
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:

```lua
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.

```lua
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:

```lua
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:

- `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:

```lua
{
  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

```lua
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.
