---
title: "Multiplayer"
description: "Zero is multiplayer by default — in both edit mode and play mode. A world is a live, shared session: other people and agents may be connected to the same world at the same time, editing alongside you…"
section: "Core"
slug: "core-multiplayer"
canonical: "https://origozero.ai/docs/core-multiplayer"
updated: "2026-08-13T08:36:36.036166467+00:00"
tags: ["documentation", "guide"]
---

# Multiplayer

## What syncs, and when

The two modes share differently (edit vs play is the engine guide):

- **In edit mode, the world itself syncs.** Source content and the entities you place propagate to everyone connected — editing is collaborative, like a shared document. This is why there's no "save and send": writing to `/zero/source` *is* sharing it.
- **In play mode, gameplay runs per peer, and only fields you mark for replication sync.** Each component field is declared with a replication mode, and only `Sync` fields cross the wire during play.

```lua
public = {
  health   = Field.number(100, Sync),    -- replicated to all peers during play
  _vfxSeed = Field.number(0,   NoSync),  -- local-only; stays on this peer
}
```

`Sync` / `NoSync` is the second argument to every `Field.<kind>(default, mode)` (the components guide covers fields). Choosing per field is the whole replication design: shared game state is `Sync`; cosmetic or per-peer scratch is `NoSync`. A component's `private` fields take the same argument and replicate the same way when `Sync`; the only difference is that a private field routes to the private table on every peer instead of the inspector-visible one (the components guide covers the private surface).

## Joining: the scene materializes first

A peer that joins a play session receives the current scene as a snapshot before its gameplay starts. While that snapshot is still arriving, gameplay simulation is held — component `update(dt)` / `fixedUpdate(dt)`, physics, and hooks don't run — so a freshly-joined character can't fall through ground that hasn't streamed in yet. Once the snapshot lands, simulation resumes and the character settles onto the now-present scene.

`engine.gameplayReady` reports this: it is `true` when gameplay is simulating (and `false` while a joiner's scene is still materializing, or in edit mode). If your `update()` isn't firing in play mode, read it — `gameplayReady == false` with `engine.paused == false` means the scene is still materializing. The same per-frame state is on every `execute` response as `state.gameplayReady`.

## Entities: who owns what

Entities carry an owner, and you decide which ones replicate at all:

```lua
local e = entity(id)
e.setSynced(true)     -- this entity participates in multiplayer
e.synced()            -- boolean
e.isLocal()           -- does this peer own it?
e.owner()             -- owning peer id (0 = unowned / server)
```

The **owner** is the peer that simulates an entity; other peers receive its synced state. When ownership matters at runtime (picking up an object, taking control of a vehicle), the `multiplayer` namespace handles it:

```lua
multiplayer.isOwner(id)
multiplayer.claimOwnership(id)
multiplayer.releaseOwnership(id)
```

Author so that the owning peer drives logic and everyone else reflects it — a common shape is "only act on entities where `isLocal()` is true."

## Players and peers

Players are first-class and engine-spawned; the scene's root surfaces them (the scenes guide has the players registry — `localPlayer`, `onJoin`, `onLeave`). A player is the per-peer identity; its **avatar** is a separate physical entity — an ordinary synced entity that every peer sees, owned by the peer it belongs to and removed when that peer leaves. View concerns stay per-peer: each peer drives its OWN avatar and runs its own camera/input, gating controller logic on `entity:isLocal()` so a peer never moves another peer's avatar. For the raw session you also have:

```lua
world.participants()                         -- who's connected
world.on("player_join",  function(p) end)
world.on("player_leave", function(p) end)
multiplayer.getPeers()                        -- peer list
multiplayer.getPeerId()                       -- this peer's id
```

## Why isn't this arriving? — reading what the engine is replicating

When a synced value does not show up on the other client, ask the engine what it
is doing with it rather than standing up a second peer and concluding from
absence. `multiplayer.observe()` reports everything this engine is replicating,
and the two calls under it answer the question at the granularity it is asked
at:

```lua
-- Everything this entity syncs: its owner, the room it broadcasts into, and
-- every REGISTERED synced component with its declared property names. It is
-- nil when the session holds no replication record for the entity at all,
-- which is itself an answer — nothing about it reaches anyone.
local record = multiplayer.observeEntity(id)
if record then
    print(record.room, record.roomPeers .. " other peer(s) share it")
    for _, c in ipairs(record.components) do
        for _, p in ipairs(c.properties) do
            print(c.componentType, p.name, "index " .. p.index, p.visibility, p.dirty)
        end
    end
end

-- Why one property is not reaching them. This answers for any entity,
-- including one with no record.
local verdict = multiplayer.explain(id, "Health", "hp")
if not verdict.arriving then print(verdict.reason) end
```

`reason` is one name from a closed set, and each names the thing to fix:

| reason | what it means |
|---|---|
| `noSession` | this engine holds no relay session at all |
| `notConnected` | a session exists and the relay link is down |
| `entityNotSynced` | the entity has no sync record, so none of it goes on the wire |
| `componentNotRegistered` | no synced component of that type is registered on it |
| `propertyNotDeclared` | the component is registered and never declared that name |
| `awaitingEntity` | traffic for this entity is held here, waiting for its spawn |
| `notOwner` | another peer owns it, so writes made here are not sent |
| `roomNotJoined` | this client is not in the room the entity broadcasts into |
| `noPeersInRoom` | nobody else is in that room to receive it |
| `pendingSend` | it changed and the next sync tick has not carried it yet |

`propertyNotDeclared` is the one that ends most searches: a name absent from
`record`'s property list is a name the component's `Sync` declaration never
registered, which is invisible from the component's source alone.

Every reason speaks for **relay replication** — the per-property deltas a synced
component puts on the wire. Edit mode carries a second channel beside it, the one
the top of this guide describes: writing to `/zero/source` *is* sharing it, and a
scene-authored entity's changes travel with that source. Scene content is left
out of the sync-id pass on purpose, so in edit mode `observe().entities` holds
the runtime-synced entities and every property on a scene entity reads
`entityNotSynced` — the surface reporting that this entity's channel to the other
clients is the source, which the VFS write path carries. What the reasons above
are about is the other channel: an entity from `entity.spawnSynced`, and a
component added with `component.addSynced` or declaring `Sync`.

The rest of the document answers the questions around it. `multiplayer.getRooms()`
lists the relay rooms this client joined and `multiplayer.roomFor(id)` the one an
entity broadcasts into, so a room key is read rather than rebuilt.
`multiplayer.syncTotals()` counts what the registry holds — entities, component
instances, declared properties and functions, and how many components hold a
dirty property this tick. `multiplayer.heldMessages()` enumerates what
`getDiagnostics().messagesAwaitingEntity` counts, each with the entity it names
and how long it has waited. Every property carries `lastSentMs` and
`lastReceivedMs` on the same clock as `sampledAtMs`, so subtracting two of them
gives the interval between a send and what came back.

The same document is a file: `/zero/runtime/sync/replication` is the whole of it,
`/zero/runtime/sync/entities/<entityId>` one entity's record, and
`/zero/runtime/sync/rooms` the joined keys. It answers in edit mode as well as
play mode, for what the relay carries in each.

## Beyond this

The `multiplayer` namespace also exposes collaborative undo/redo, room/mode state, and diagnostics — discover it with `lsp.methods("multiplayer")` (the discovering guide). The concept to hold onto: the world is shared at all times; `Sync`/`NoSync` and entity ownership are how you decide what everyone sees.

Replication is for *live* state — what's happening right now. For state that must **survive restarts** and be there next session (progression, saves, per-player records), reach for `runtime_data` (the runtime-data guide) instead: it's the world's durable, shared memory, where replication is its short-term memory.
