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/sourceis 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
Syncfields cross the wire during play.
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:
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:
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:
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:
-- 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.