Log inGet started

multiplayer

Updated October 20, 2026

Multiplayer sync state and operations — connection, peers, ownership, rooms, undo/redo.

require("@builtin::modules.api.engine.multiplayer") -- multiplayer (also available as global 'multiplayer')

The multiplayer namespace: 111 functions.

globals/multiplayer/beginOperation

multiplayer.beginOperation(description: string)

Begin recording an undoable operation. All mutations until commitOperation() are grouped into one undo entry.

Parameters

  • description string — Human-readable label.
multiplayer.beginOperation("move cube")

globals/multiplayer/canRedo

multiplayer.canRedo() -> boolean

Check if this client has any redoable operations.

Returns boolean — True if redo is available.

globals/multiplayer/canUndo

multiplayer.canUndo() -> boolean

Check if this client has any undoable operations.

Returns boolean — True if undo is available.

globals/multiplayer/cancelOperation

multiplayer.cancelOperation()

Cancel the current operation and restore all properties to their values at begin time.

multiplayer.cancelOperation()

globals/multiplayer/claimOwnership

multiplayer.claimOwnership(entityId: (string | entityRef)?) -> boolean

Request ownership of an entity. The relay grants a claim on an entity no peer owns (never claimed, or released by its owner) and tells every peer; an entity another peer owns stays theirs, and the refusal is counted in getDiagnostics().refusals and named by lastRefusal. The owner hands an entity on by releasing it.

Parameters

  • entityId (string | entityRef) (optional) — Entity id or proxy to claim.

Returns boolean — True once the claim is on its way to the relay.

globals/multiplayer/clearHistory

multiplayer.clearHistory()

Drop this client's whole undo/redo history — for boundaries where old edits stop being meaningful (a scene load, a test rig reset).

multiplayer.clearHistory()

globals/multiplayer/commitOperation

multiplayer.commitOperation()

Finalize the current operation and push it onto the undo stack. Only changes that actually differ from the start state are recorded.

multiplayer.commitOperation()

globals/multiplayer/connect

multiplayer.connect(relayUrl: string)

Connect to a multiplayer relay server for the current world. Uses the loaded world's world_id as the room prefix for scene isolation. A world must be loaded before connecting.

Parameters

  • relayUrl string — Relay server URL.
multiplayer.connect("https://relay.example.com")

globals/multiplayer/disconnect

multiplayer.disconnect()

Disconnect from the multiplayer relay server.

multiplayer.disconnect()

globals/multiplayer/explain

multiplayer.explain(entityId: (string | entityRef), componentType: string, property: string) -> DeliveryVerdict

Why a synced property is not reaching the peers this client shares its entity's room with. Answers from the engine's own registry, so a name the component never registered is reported as such instead of inferred from a second client's silence.

Parameters

  • entityId (string | entityRef) — Entity id or proxy.
  • componentType string — Component type name, e.g. "Health".
  • property string — Property name as written in the component's sync {} block.

Returns DeliveryVerdict — arriving true when the property is on its way; otherwise reason names the cause and property carries the registry's record of it when one exists.

local v = multiplayer.explain(player, "Health", "hp")
if not v.arriving then print(v.reason) end

globals/multiplayer/getDiagnostics

multiplayer.getDiagnostics() -> SyncDiagnostics

Get sync diagnostics — traffic counts, bandwidth, link quality, peer count. The counts — bytesSent/Received, datagramsSent/Received, rpcsSent/Received, ownershipChanges, refusals — are running totals for the session, so a sparse event stays readable long after it happened; subtract two samples for the rate over the interval between them. refusals counts the messages of this session's the relay refused (a claim on an entity another peer owns, a change to one, a room outside the world the connection was admitted to), and lastRefusal names the latest in the relay's words once there is one. bytesSentPerSec / bytesReceivedPerSec are averages over the last completed ~1 second window. rttMs is the smoothed round-trip time to the relay and packetLoss the fraction (0..1) of packets lost over the last 5 seconds; both read 0 until the transport has sampled a live connection. messagesAwaitingEntity counts the sync messages this peer is holding for an entity it has not received yet — each waits for the spawn that names it, applies the moment it arrives, and is released once its wait runs out.

Returns SyncDiagnostics — Diagnostics table.

local d = multiplayer.getDiagnostics()
if d.packetLoss > 0.05 then log.warn(("unstable connection, %d ms round trip"):format(d.rttMs)) end
print(d.messagesAwaitingEntity .. " message(s) waiting for their entity")

globals/multiplayer/getPeerId

multiplayer.getPeerId() -> number?

Get the peer ID the relay issued this client's connection.

Returns number? — Local peer ID, or nil exactly when isConnected() is false: while dialing, before the relay names the connection, and after it drops.

globals/multiplayer/getPeers

multiplayer.getPeers() -> { PeerInfo }

Get a list of all connected peers in the current session.

Returns { PeerInfo } — Array of peer info tables.

for _, p in ipairs(multiplayer.getPeers()) do print(p.name) end

globals/multiplayer/getRoomPeers

multiplayer.getRoomPeers(roomKey: string) -> { PeerInfo }

Get the peers this client shares the given room with, ordered by peer id. getPeers answers for the whole session — the union of every room this client is in — while this answers for one room, so a peer that leaves this room while staying in another disappears from here and remains in getPeers.

Parameters

  • roomKey string — Fully-qualified room key ({worldGuid}/{profile}/{mode}/{sceneGuid}).

Returns { PeerInfo } — Array of peer info tables for that room.

for _, p in ipairs(multiplayer.getRoomPeers(key)) do print(p.id) end

globals/multiplayer/getRooms

multiplayer.getRooms() -> { string }

The relay rooms this client has joined, sorted. A broadcast reaches only the peers that share one of these. They are the rooms of observe() as of the last frame, read from the rooms the session publishes every frame, so a component can ask every frame.

Returns { string } — Room keys.

for _, key in ipairs(multiplayer.getRooms()) do print(key) end

globals/multiplayer/getTickRate

multiplayer.getTickRate() -> number

Get the current sync tick rate (network updates per second).

Returns number — Sync ticks per second (default 20).

globals/multiplayer/heldMessages

multiplayer.heldMessages() -> { HeldMessage }

The sync messages this peer is holding for entities it has not received — what getDiagnostics().messagesAwaitingEntity counts, one entry each, with the entity sync id it names, its age and the grace it is held against.

Returns { HeldMessage } — Held messages.

for _, m in ipairs(multiplayer.heldMessages()) do print(m.kind, m.ageMs) end

globals/multiplayer/isConnected

multiplayer.isConnected() -> boolean

Check if a multiplayer session is connected to a relay and the relay has named this client. True exactly when getPeerId() answers an id.

Returns boolean — True if connected under the peer id the relay issued.

globals/multiplayer/isHost

multiplayer.isHost() -> boolean

Whether THIS client is the host (authoritative owner) of the current scene's play room — the relay room CREATOR, or offline / single-player. Host code spawns the shared synced world (via entity.spawnSynced or a scene's onHostLoad) and runs authoritative simulation; a non-host (JOINER) receives that content from the relay snapshot and must NOT re-create it. Gate ANY code that spawns synced entities or owns shared state with this so it runs on exactly one client — running it on every peer is the double-spawn 'explosion'.

Returns boolean — True on the host / offline / single-player; false on a confirmed joiner. Defaults to true when the role isn't known yet (degrade to host so single-player and pre-join code still run) — pair with a scene's onHostLoad hook when exact one-shot timing matters.

if multiplayer.isHost() then enemy = entity.spawnSynced("enemy") end

globals/multiplayer/isOperationOpen

multiplayer.isOperationOpen() -> boolean

Whether an operation is open: true between beginOperation and its commitOperation or cancelOperation. A caller that records file writes reads the file's prior contents only while one is open.

Returns boolean — True while an operation is open.

if multiplayer.isOperationOpen() then before = vfs.read(p) end

globals/multiplayer/isOwner

multiplayer.isOwner(entityId: (string | entityRef)?) -> boolean

Check if the local client owns the given entity (or the current entity if called from a component). Only the owner can modify synced properties directly.

Parameters

  • entityId (string | entityRef) (optional) — Entity id or proxy to check (defaults to self.entityId in component context).

Returns boolean — True if the local client is the owner.

globals/multiplayer/isRoomCreator

multiplayer.isRoomCreator(roomKey: string) -> boolean?

Whether this client created the given room — it was the FIRST peer to join it (race-free; the relay assigns it on join). In play mode the creator instantiates the scene's entities (synced) and every other joiner receives them from the relay snapshot, so the scene is never double-instantiated.

Parameters

  • roomKey string — Fully-qualified room key ({worldGuid}/{profile}/{mode}/{sceneGuid}).

Returns boolean? — True if this client created the room, false if it joined an existing one, nil if the relay hasn't reported a role yet (offline / not joined).

if multiplayer.isRoomCreator(key) ~= false then layers.load(scene) end

globals/multiplayer/joinRoom

multiplayer.joinRoom(roomKey: string)

Join a relay room. Room keys are built as {worldGuid}/{profile}/{mode}/{sceneGuid} — four segments, the {profile} one keeping a runtime peer (published content) and an editor peer (live content) in separate rooms even when both are in play mode. Rooms partition the relay's fan-out: only peers in the same room receive each other's broadcasts. getRooms() reports the keys this client is already in and roomFor(entity) the one an entity broadcasts into, so a key can be read rather than rebuilt. A relay that verifies its peers holds each connection to the world it was admitted to: a key whose first segment names another world is refused, and getDiagnostics().refusals counts the refusal and lastRefusal names it. No-op when not connected or already joined.

Parameters

  • roomKey string — Fully-qualified room key.
multiplayer.joinRoom(worldGuid .. "/" .. engine.profile .. "/play/" .. sceneGuid)

globals/multiplayer/leaveRoom

multiplayer.leaveRoom(roomKey: string)

Leave a relay room. The key is reported under observe().withdrawnRooms until joinRoom names it again. No-op when not connected or not joined.

Parameters

  • roomKey string — Fully-qualified room key.

globals/multiplayer/loopback

multiplayer.loopback() -> { [string]: any }

Loopback testing harness. Returns a table with enable(), disable(), flush(), receive() methods for testing sync without a relay server.

Returns { [string]: any } — Loopback API table.

globals/multiplayer/observe

multiplayer.observe() -> ReplicationObservation

Report what this peer is replicating and why a property is not arriving. Carries the rooms this client joined, one record per entity with a sync id — its owner, the room it broadcasts into, how many other peers share that room, and every REGISTERED synced component with its declared property names, wire indices, public/private table and dirty bits — the messages held for entities that have not arrived, and the registry's totals. Every property carries notArriving: one name from reasons, or nil when it is on its way. Answers in edit mode as well as play mode, for what the relay carries in each: in edit mode scene content is left out of the sync-id pass, so its changes travel to the other clients with the source they are written into and it reads entityNotSynced here. The document describes the session as of the last frame: when no frame has assembled one since the surface was last read, the call waits for the next frame to assemble it, and a caller that cannot yield receives the one published last.

Returns ReplicationObservation — The engine's current replication observation.

local o = multiplayer.observe()
print(#o.entities .. " entities, " .. o.totals.properties .. " synced properties")

globals/multiplayer/observeComponent

multiplayer.observeComponent(entityId: (string | entityRef), componentType: string) -> SyncedComponent?

The registered synced component of the named type on an entity's record. Matches a fully-qualified type (@builtin::components.Model) and the leaf name it ends in (Model) alike.

Parameters

  • entityId (string | entityRef) — Entity id or proxy.
  • componentType string — Component type name or its leaf.

Returns SyncedComponent? — The registered component instance, or nil when none of that type is registered on the entity.

local c = multiplayer.observeComponent(player, "Health")
print(#c.properties .. " declared properties")

globals/multiplayer/observeEntity

multiplayer.observeEntity(entityId: (string | entityRef)) -> EntityReplication?

The replication record for one entity — its sync id, owner, room, and the synced components registered on it.

Parameters

  • entityId (string | entityRef) — Entity id or proxy.

Returns EntityReplication? — The entity's record, or nil when the engine holds no sync record for it.

local r = multiplayer.observeEntity(e)
for _, c in ipairs(r.components) do print(c.componentType) end

globals/multiplayer/on

multiplayer.on(channel: string, callback: (number, ...any) -> ())

Subscribe to a custom message channel. The callback runs as callback(fromPeerId, ...args) whenever another peer calls multiplayer.send(channel, ...). Multiple callbacks per channel fire in registration order.

Parameters

  • channel string — Channel name to listen on.
  • callback (number, ...any) -> () — function(fromPeerId: number, ...) — the sender's peer id then the sent args.

globals/multiplayer/recordFileWrite

multiplayer.recordFileWrite(path: string, before: string?, after: string?) -> boolean

Record a file write into the open operation. Undoing the operation writes before back through the VFS (removing the file when before is nil), which runs the owning asset type's onChange as the original write did; redoing writes after. The path is resolved and held to the rules vfs.write applies, so a sidecar (.meta, .refs, .pull) or an asset root no script may write raises. A step's files are put back all together or not at all: each file is checked (its asset exists, and it holds what the step being reversed left in it) before any is written and again immediately before it is written; when a check or a write fails partway, the files the step already wrote go back to what they held, and a notice names each file of the step and what happened to it. Several writes of one path in an operation undo to the first before. Records nothing and returns false when no operation is open.

Parameters

  • path string — VFS path of the written file.
  • before string (optional) — The file's contents before the write, nil when it did not exist.
  • after string (optional) — The file's contents after the write, nil when the write removed it.

Returns boolean — True when the write was recorded.

local before = vfs.read(p); vfs.write(p, body); multiplayer.recordFileWrite(p, before, body)

globals/multiplayer/recordSpawn

multiplayer.recordSpawn(entityId: string)

Adopt an existing entity into the open operation as its spawn — for flows that create an entity before the operation opens (a drag preview adopted on drop). Undoing the operation despawns it.

Parameters

  • entityId string — Entity id to record as spawned by this operation.
multiplayer.recordSpawn(id)

globals/multiplayer/redo

multiplayer.redo() -> boolean

Redo this client's last undone operation.

Returns boolean — True if an operation was redone.

globals/multiplayer/releaseOwnership

multiplayer.releaseOwnership(entityId: (string | entityRef)?) -> boolean

Release ownership of an entity, leaving it unowned so another peer's claim takes it. Only the owner releases an entity.

Parameters

  • entityId (string | entityRef) (optional) — Entity id or proxy to release.

Returns boolean — True if ownership was released.

globals/multiplayer/roomFor

multiplayer.roomFor(entityId: (string | entityRef)) -> string?

The room key an entity's spawns and property deltas broadcast into: the room of its observeEntity record as of the last frame, read from the rooms the session publishes every frame, so a component can ask every frame.

Parameters

  • entityId (string | entityRef) — Entity id or proxy.

Returns string? — The room key, or nil when the engine has established no scene context for the entity.

local key = multiplayer.roomFor(player)
if key then multiplayer.joinRoom(key) end

globals/multiplayer/send

multiplayer.send(channel: string, ...: any?)

Broadcast a message on a named channel to every OTHER peer in the room. The relay forwards it transparently; peers receive it via multiplayer.on. The arguments are delivered to listeners in order, each one arriving as what it is, at the top level and at every depth of a table:

  • nil, a boolean, a number and a string arrive as themselves.
  • A vector arrives as a vector.
  • The proxy of a synced entity arrives as the receiving peer's proxy for that entity; the proxy of an entity no peer shares, as its id string.
  • A synced component (its proxy, or the componentRef envelope naming it) arrives as that component on the receiving peer; the proxy of a component that holds no sync id, as the envelope { __ref, entity, componentType } naming it.
  • A transform vector an entity hands out (e.position, e.rotation, e.localScale, ...) arrives as the table of its components, { x, y, z } or { x, y, z, w }.
  • Every other table is data and arrives as a new table, each value carried by these same rules: { x = 5, kind = "coin" } arrives as { x = 5, kind = "coin" }, and { who = e, n = 1 } with t.who the receiver's proxy for e. A table whose length #t is above 0 arrives as the list of its values at 1..#t alone; any other arrives keyed by its string keys and by each number key as the string of its whole part ({ [5] = "a" } arrives as { ["5"] = "a" }).
  • A value no peer can hold (a function, a thread, a buffer) arrives as nil. The same rules carry the arguments of a declare.syncedFunctions method, the payload of a Sync event and a synced table-valued field. No-op when not connected.

Parameters

  • channel string — Channel name listeners subscribe to via multiplayer.on.
  • ... any (optional) — Zero or more values delivered to each listener after the sender's peer id.

globals/multiplayer/syncTotals

multiplayer.syncTotals() -> SyncTotals

What the sync registry holds across every entity: entities with a registered synced component, component instances, declared properties, declared functions, and the component instances holding a dirty property this tick.

Returns SyncTotals — The registry totals.

print(multiplayer.syncTotals().properties .. " synced properties registered")

globals/multiplayer/undo

multiplayer.undo() -> boolean

Undo this client's last edit-mode operation.

Returns boolean — True if an operation was undone.

modules/multiplayer/beginOperation

beginOperation(description: string)

Begin recording an undoable operation. All mutations until commitOperation() are grouped into one undo entry.

Parameters

  • description string — Human-readable label.
multiplayer.beginOperation("move cube")

modules/multiplayer/canRedo

canRedo(): boolean

Check if this client has any redoable operations.

modules/multiplayer/canUndo

canUndo(): boolean

Check if this client has any undoable operations.

modules/multiplayer/cancelOperation

cancelOperation()

Cancel the current operation and restore all properties to their values at begin time.

multiplayer.cancelOperation()

modules/multiplayer/claimOwnership

claimOwnership(entityId: (string | entityRef)?): boolean

Request ownership of an entity. The relay grants a claim on an entity no peer owns (never claimed, or released by its owner) and tells every peer; an entity another peer owns stays theirs, and the refusal is counted in getDiagnostics().refusals and named by lastRefusal. The owner hands an entity on by releasing it.

Parameters

  • entityId (string | entityRef)? (optional) — Entity id or proxy to claim.

modules/multiplayer/clearHistory

clearHistory()

Drop this client's whole undo/redo history — for boundaries where old edits stop being meaningful (a scene load, a test rig reset).

multiplayer.clearHistory()

modules/multiplayer/commitOperation

commitOperation()

Finalize the current operation and push it onto the undo stack. Only changes that actually differ from the start state are recorded.

multiplayer.commitOperation()

modules/multiplayer/connect

connect(relayUrl: string)

Connect to a multiplayer relay server for the current world. Uses the loaded world's world_id as the room prefix for scene isolation. A world must be loaded before connecting.

Parameters

  • relayUrl string — Relay server URL.
multiplayer.connect("https://relay.example.com")

modules/multiplayer/disconnect

disconnect()

Disconnect from the multiplayer relay server.

multiplayer.disconnect()

modules/multiplayer/explain

explain(

Why a synced property is not reaching the peers this client shares its entity's room with. Answers from the engine's own registry, so a name the component never registered is reported as such instead of inferred from a second client's silence.

local v = multiplayer.explain(player, "Health", "hp")
if not v.arriving then print(v.reason) end

modules/multiplayer/getDiagnostics

getDiagnostics(): SyncDiagnostics

Get sync diagnostics — traffic counts, bandwidth, link quality, peer count. The counts — bytesSent/Received, datagramsSent/Received, rpcsSent/Received, ownershipChanges, refusals — are running totals for the session, so a sparse event stays readable long after it happened; subtract two samples for the rate over the interval between them. refusals counts the messages of this session's the relay refused (a claim on an entity another peer owns, a change to one, a room outside the world the connection was admitted to), and lastRefusal names the latest in the relay's words once there is one. bytesSentPerSec / bytesReceivedPerSec are averages over the last completed ~1 second window. rttMs is the smoothed round-trip time to the relay and packetLoss the fraction (0..1) of packets lost over the last 5 seconds; both read 0 until the transport has sampled a live connection. messagesAwaitingEntity counts the sync messages this peer is holding for an entity it has not received yet — each waits for the spawn that names it, applies the moment it arrives, and is released once its wait runs out.

local d = multiplayer.getDiagnostics()
if d.packetLoss > 0.05 then log.warn(("unstable connection, %d ms round trip"):format(d.rttMs)) end
print(d.messagesAwaitingEntity .. " message(s) waiting for their entity")

modules/multiplayer/getPeerId

getPeerId(): number?

Get the peer ID the relay issued this client's connection.

modules/multiplayer/getPeers

getPeers(): { PeerInfo }

Get a list of all connected peers in the current session.

for _, p in ipairs(multiplayer.getPeers()) do print(p.name) end

modules/multiplayer/getRoomPeers

getRoomPeers(roomKey: string): { PeerInfo }

Get the peers this client shares the given room with, ordered by peer id. getPeers answers for the whole session — the union of every room this client is in — while this answers for one room, so a peer that leaves this room while staying in another disappears from here and remains in getPeers.

Parameters

  • roomKey string — Fully-qualified room key ({worldGuid}/{profile}/{mode}/{sceneGuid}).
for _, p in ipairs(multiplayer.getRoomPeers(key)) do print(p.id) end

modules/multiplayer/getRooms

getRooms(): { string }

The relay rooms this client has joined, sorted. A broadcast reaches only the peers that share one of these. They are the rooms of observe() as of the last frame, read from the rooms the session publishes every frame, so a component can ask every frame.

for _, key in ipairs(multiplayer.getRooms()) do print(key) end

modules/multiplayer/getTickRate

getTickRate(): number

Get the current sync tick rate (network updates per second).

modules/multiplayer/heldMessages

heldMessages(): { HeldMessage }

The sync messages this peer is holding for entities it has not received — what getDiagnostics().messagesAwaitingEntity counts, one entry each, with the entity sync id it names, its age and the grace it is held against.

for _, m in ipairs(multiplayer.heldMessages()) do print(m.kind, m.ageMs) end

modules/multiplayer/isConnected

isConnected(): boolean

Check if a multiplayer session is connected to a relay and the relay has named this client. True exactly when getPeerId() answers an id.

modules/multiplayer/isHost

isHost(): boolean

Whether THIS client is the host (authoritative owner) of the current scene's play room — the relay room CREATOR, or offline / single-player. Host code spawns the shared synced world (via entity.spawnSynced or a scene's onHostLoad) and runs authoritative simulation; a non-host (JOINER) receives that content from the relay snapshot and must NOT re-create it. Gate ANY code that spawns synced entities or owns shared state with this so it runs on exactly one client — running it on every peer is the double-spawn 'explosion'.

if multiplayer.isHost() then enemy = entity.spawnSynced("enemy") end

modules/multiplayer/isOperationOpen

isOperationOpen(): boolean

Whether an operation is open: true between beginOperation and its commitOperation or cancelOperation. A caller that records file writes reads the file's prior contents only while one is open.

if multiplayer.isOperationOpen() then before = vfs.read(p) end

modules/multiplayer/isOwner

isOwner(entityId: (string | entityRef)?): boolean

Check if the local client owns the given entity (or the current entity if called from a component). Only the owner can modify synced properties directly.

Parameters

  • entityId (string | entityRef)? (optional) — Entity id or proxy to check (defaults to self.entityId in component context).

modules/multiplayer/isRoomCreator

isRoomCreator(roomKey: string): boolean?

Whether this client created the given room — it was the FIRST peer to join it (race-free; the relay assigns it on join). In play mode the creator instantiates the scene's entities (synced) and every other joiner receives them from the relay snapshot, so the scene is never double-instantiated.

Parameters

  • roomKey string — Fully-qualified room key ({worldGuid}/{profile}/{mode}/{sceneGuid}).
if multiplayer.isRoomCreator(key) ~= false then layers.load(scene) end

modules/multiplayer/joinRoom

joinRoom(roomKey: string)

Join a relay room. Room keys are built as {worldGuid}/{profile}/{mode}/{sceneGuid} — four segments, the {profile} one keeping a runtime peer (published content) and an editor peer (live content) in separate rooms even when both are in play mode. Rooms partition the relay's fan-out: only peers in the same room receive each other's broadcasts. getRooms() reports the keys this client is already in and roomFor(entity) the one an entity broadcasts into, so a key can be read rather than rebuilt. A relay that verifies its peers holds each connection to the world it was admitted to: a key whose first segment names another world is refused, and getDiagnostics().refusals counts the refusal and lastRefusal names it. No-op when not connected or already joined.

Parameters

  • roomKey string — Fully-qualified room key.
multiplayer.joinRoom(worldGuid .. "/" .. engine.profile .. "/play/" .. sceneGuid)

modules/multiplayer/leaveRoom

leaveRoom(roomKey: string)

Leave a relay room. The key is reported under observe().withdrawnRooms until joinRoom names it again. No-op when not connected or not joined.

Parameters

  • roomKey string — Fully-qualified room key.

modules/multiplayer/loopback

loopback(): { [string]: any }

Loopback testing harness. Returns a table with enable(), disable(), flush(), receive() methods for testing sync without a relay server.

modules/multiplayer/observe

observe(): ReplicationObservation

Report what this peer is replicating and why a property is not arriving. Carries the rooms this client joined, one record per entity with a sync id — its owner, the room it broadcasts into, how many other peers share that room, and every REGISTERED synced component with its declared property names, wire indices, public/private table and dirty bits — the messages held for entities that have not arrived, and the registry's totals. Every property carries notArriving: one name from reasons, or nil when it is on its way. Answers in edit mode as well as play mode, for what the relay carries in each: in edit mode scene content is left out of the sync-id pass, so its changes travel to the other clients with the source they are written into and it reads entityNotSynced here. The document describes the session as of the last frame: when no frame has assembled one since the surface was last read, the call waits for the next frame to assemble it, and a caller that cannot yield receives the one published last.

local o = multiplayer.observe()
print(#o.entities .. " entities, " .. o.totals.properties .. " synced properties")

modules/multiplayer/observeComponent

observeComponent(

The registered synced component of the named type on an entity's record. Matches a fully-qualified type (@builtin::components.Model) and the leaf name it ends in (Model) alike.

local c = multiplayer.observeComponent(player, "Health")
print(#c.properties .. " declared properties")

modules/multiplayer/observeEntity

observeEntity(entityId: (string | entityRef)): EntityReplication?

The replication record for one entity — its sync id, owner, room, and the synced components registered on it.

Parameters

  • entityId (string | entityRef) — Entity id or proxy.
local r = multiplayer.observeEntity(e)
for _, c in ipairs(r.components) do print(c.componentType) end

modules/multiplayer/on

on(channel: string, callback: (number, ...any) -> ())

Subscribe to a custom message channel. The callback runs as callback(fromPeerId, ...args) whenever another peer calls multiplayer.send(channel, ...). Multiple callbacks per channel fire in registration order.

Parameters

  • channel string — Channel name to listen on.
  • callback (number, ...any) -> () — function(fromPeerId: number, ...) — the sender's peer id then the sent args.

modules/multiplayer/recordFileWrite

recordFileWrite(path: string, before: string?, after: string?): boolean

Record a file write into the open operation. Undoing the operation writes before back through the VFS (removing the file when before is nil), which runs the owning asset type's onChange as the original write did; redoing writes after. The path is resolved and held to the rules vfs.write applies, so a sidecar (.meta, .refs, .pull) or an asset root no script may write raises. A step's files are put back all together or not at all: each file is checked (its asset exists, and it holds what the step being reversed left in it) before any is written and again immediately before it is written; when a check or a write fails partway, the files the step already wrote go back to what they held, and a notice names each file of the step and what happened to it. Several writes of one path in an operation undo to the first before. Records nothing and returns false when no operation is open.

Parameters

  • path string — VFS path of the written file.
  • before string? (optional) — The file's contents before the write, nil when it did not exist.
  • after string? (optional) — The file's contents after the write, nil when the write removed it.
local before = vfs.read(p); vfs.write(p, body); multiplayer.recordFileWrite(p, before, body)

modules/multiplayer/recordSpawn

recordSpawn(entityId: string)

Adopt an existing entity into the open operation as its spawn — for flows that create an entity before the operation opens (a drag preview adopted on drop). Undoing the operation despawns it.

Parameters

  • entityId string — Entity id to record as spawned by this operation.
multiplayer.recordSpawn(id)

modules/multiplayer/redo

redo(): boolean

Redo this client's last undone operation.

modules/multiplayer/releaseOwnership

releaseOwnership(entityId: (string | entityRef)?): boolean

Release ownership of an entity, leaving it unowned so another peer's claim takes it. Only the owner releases an entity.

Parameters

  • entityId (string | entityRef)? (optional) — Entity id or proxy to release.

modules/multiplayer/roomFor

roomFor(entityId: (string | entityRef)): string?

The room key an entity's spawns and property deltas broadcast into: the room of its observeEntity record as of the last frame, read from the rooms the session publishes every frame, so a component can ask every frame.

Parameters

  • entityId (string | entityRef) — Entity id or proxy.
local key = multiplayer.roomFor(player)
if key then multiplayer.joinRoom(key) end

modules/multiplayer/send

send(channel: string, ...: any)

Broadcast a message on a named channel to every OTHER peer in the room. The relay forwards it transparently; peers receive it via multiplayer.on. The arguments are delivered to listeners in order, each one arriving as what it is, at the top level and at every depth of a table:

  • nil, a boolean, a number and a string arrive as themselves.
  • A vector arrives as a vector.
  • The proxy of a synced entity arrives as the receiving peer's proxy for that entity; the proxy of an entity no peer shares, as its id string.
  • A synced component (its proxy, or the componentRef envelope naming it) arrives as that component on the receiving peer; the proxy of a component that holds no sync id, as the envelope { __ref, entity, componentType } naming it.
  • A transform vector an entity hands out (e.position, e.rotation, e.localScale, ...) arrives as the table of its components, { x, y, z } or { x, y, z, w }.
  • Every other table is data and arrives as a new table, each value carried by these same rules: { x = 5, kind = "coin" } arrives as { x = 5, kind = "coin" }, and { who = e, n = 1 } with t.who the receiver's proxy for e. A table whose length #t is above 0 arrives as the list of its values at 1..#t alone; any other arrives keyed by its string keys and by each number key as the string of its whole part ({ [5] = "a" } arrives as { ["5"] = "a" }).
  • A value no peer can hold (a function, a thread, a buffer) arrives as nil. The same rules carry the arguments of a declare.syncedFunctions method, the payload of a Sync event and a synced table-valued field. No-op when not connected.

Parameters

  • channel string — Channel name listeners subscribe to via multiplayer.on.
  • args any (optional)

modules/multiplayer/syncTotals

syncTotals(): SyncTotals

What the sync registry holds across every entity: entities with a registered synced component, component instances, declared properties, declared functions, and the component instances holding a dirty property this tick.

print(multiplayer.syncTotals().properties .. " synced properties registered")

modules/multiplayer/undo

undo(): boolean

Undo this client's last edit-mode operation.

typed/builtin//modules/api/engine/multiplayer/multiplayer/beginOperation

multiplayer.beginOperation(description: string)

Begin recording an undoable operation. All mutations until commitOperation() are grouped into one undo entry.

Parameters

  • description string — Human-readable label.
multiplayer.beginOperation("move cube")

typed/builtin//modules/api/engine/multiplayer/multiplayer/canRedo

multiplayer.canRedo() -> boolean

Check if this client has any redoable operations.

Returns boolean — True if redo is available.

typed/builtin//modules/api/engine/multiplayer/multiplayer/canUndo

multiplayer.canUndo() -> boolean

Check if this client has any undoable operations.

Returns boolean — True if undo is available.

typed/builtin//modules/api/engine/multiplayer/multiplayer/cancelOperation

multiplayer.cancelOperation()

Cancel the current operation and restore all properties to their values at begin time.

multiplayer.cancelOperation()

typed/builtin//modules/api/engine/multiplayer/multiplayer/claimOwnership

multiplayer.claimOwnership(entityId: (string | entityRef)?) -> boolean

Request ownership of an entity. The relay grants a claim on an entity no peer owns (never claimed, or released by its owner) and tells every peer; an entity another peer owns stays theirs, and the refusal is counted in getDiagnostics().refusals and named by lastRefusal. The owner hands an entity on by releasing it.

Parameters

  • entityId (string | entityRef) (optional) — Entity id or proxy to claim.

Returns boolean — True once the claim is on its way to the relay.

typed/builtin//modules/api/engine/multiplayer/multiplayer/clearHistory

multiplayer.clearHistory()

Drop this client's whole undo/redo history — for boundaries where old edits stop being meaningful (a scene load, a test rig reset).

multiplayer.clearHistory()

typed/builtin//modules/api/engine/multiplayer/multiplayer/commitOperation

multiplayer.commitOperation()

Finalize the current operation and push it onto the undo stack. Only changes that actually differ from the start state are recorded.

multiplayer.commitOperation()

typed/builtin//modules/api/engine/multiplayer/multiplayer/connect

multiplayer.connect(relayUrl: string)

Connect to a multiplayer relay server for the current world. Uses the loaded world's world_id as the room prefix for scene isolation. A world must be loaded before connecting.

Parameters

  • relayUrl string — Relay server URL.
multiplayer.connect("https://relay.example.com")

typed/builtin//modules/api/engine/multiplayer/multiplayer/disconnect

multiplayer.disconnect()

Disconnect from the multiplayer relay server.

multiplayer.disconnect()

typed/builtin//modules/api/engine/multiplayer/multiplayer/explain

multiplayer.explain(entityId: (string | entityRef), componentType: string, property: string) -> DeliveryVerdict

Why a synced property is not reaching the peers this client shares its entity's room with. Answers from the engine's own registry, so a name the component never registered is reported as such instead of inferred from a second client's silence.

Parameters

  • entityId (string | entityRef) — Entity id or proxy.
  • componentType string — Component type name, e.g. "Health".
  • property string — Property name as written in the component's sync {} block.

Returns DeliveryVerdict — arriving true when the property is on its way; otherwise reason names the cause and property carries the registry's record of it when one exists.

local v = multiplayer.explain(player, "Health", "hp")
if not v.arriving then print(v.reason) end

typed/builtin//modules/api/engine/multiplayer/multiplayer/getDiagnostics

multiplayer.getDiagnostics() -> SyncDiagnostics

Get sync diagnostics — traffic counts, bandwidth, link quality, peer count. The counts — bytesSent/Received, datagramsSent/Received, rpcsSent/Received, ownershipChanges, refusals — are running totals for the session, so a sparse event stays readable long after it happened; subtract two samples for the rate over the interval between them. refusals counts the messages of this session's the relay refused (a claim on an entity another peer owns, a change to one, a room outside the world the connection was admitted to), and lastRefusal names the latest in the relay's words once there is one. bytesSentPerSec / bytesReceivedPerSec are averages over the last completed ~1 second window. rttMs is the smoothed round-trip time to the relay and packetLoss the fraction (0..1) of packets lost over the last 5 seconds; both read 0 until the transport has sampled a live connection. messagesAwaitingEntity counts the sync messages this peer is holding for an entity it has not received yet — each waits for the spawn that names it, applies the moment it arrives, and is released once its wait runs out.

Returns SyncDiagnostics — Diagnostics table.

local d = multiplayer.getDiagnostics()
if d.packetLoss > 0.05 then log.warn(("unstable connection, %d ms round trip"):format(d.rttMs)) end
print(d.messagesAwaitingEntity .. " message(s) waiting for their entity")

typed/builtin//modules/api/engine/multiplayer/multiplayer/getPeerId

multiplayer.getPeerId() -> number?

Get the peer ID the relay issued this client's connection.

Returns number? — Local peer ID, or nil exactly when isConnected() is false: while dialing, before the relay names the connection, and after it drops.

typed/builtin//modules/api/engine/multiplayer/multiplayer/getPeers

multiplayer.getPeers() -> { PeerInfo }

Get a list of all connected peers in the current session.

Returns { PeerInfo } — Array of peer info tables.

for _, p in ipairs(multiplayer.getPeers()) do print(p.name) end

typed/builtin//modules/api/engine/multiplayer/multiplayer/getRoomPeers

multiplayer.getRoomPeers(roomKey: string) -> { PeerInfo }

Get the peers this client shares the given room with, ordered by peer id. getPeers answers for the whole session — the union of every room this client is in — while this answers for one room, so a peer that leaves this room while staying in another disappears from here and remains in getPeers.

Parameters

  • roomKey string — Fully-qualified room key ({worldGuid}/{profile}/{mode}/{sceneGuid}).

Returns { PeerInfo } — Array of peer info tables for that room.

for _, p in ipairs(multiplayer.getRoomPeers(key)) do print(p.id) end

typed/builtin//modules/api/engine/multiplayer/multiplayer/getRooms

multiplayer.getRooms() -> { string }

The relay rooms this client has joined, sorted. A broadcast reaches only the peers that share one of these. They are the rooms of observe() as of the last frame, read from the rooms the session publishes every frame, so a component can ask every frame.

Returns { string } — Room keys.

for _, key in ipairs(multiplayer.getRooms()) do print(key) end

typed/builtin//modules/api/engine/multiplayer/multiplayer/getTickRate

multiplayer.getTickRate() -> number

Get the current sync tick rate (network updates per second).

Returns number — Sync ticks per second (default 20).

typed/builtin//modules/api/engine/multiplayer/multiplayer/heldMessages

multiplayer.heldMessages() -> { HeldMessage }

The sync messages this peer is holding for entities it has not received — what getDiagnostics().messagesAwaitingEntity counts, one entry each, with the entity sync id it names, its age and the grace it is held against.

Returns { HeldMessage } — Held messages.

for _, m in ipairs(multiplayer.heldMessages()) do print(m.kind, m.ageMs) end

typed/builtin//modules/api/engine/multiplayer/multiplayer/isConnected

multiplayer.isConnected() -> boolean

Check if a multiplayer session is connected to a relay and the relay has named this client. True exactly when getPeerId() answers an id.

Returns boolean — True if connected under the peer id the relay issued.

typed/builtin//modules/api/engine/multiplayer/multiplayer/isHost

multiplayer.isHost() -> boolean

Whether THIS client is the host (authoritative owner) of the current scene's play room — the relay room CREATOR, or offline / single-player. Host code spawns the shared synced world (via entity.spawnSynced or a scene's onHostLoad) and runs authoritative simulation; a non-host (JOINER) receives that content from the relay snapshot and must NOT re-create it. Gate ANY code that spawns synced entities or owns shared state with this so it runs on exactly one client — running it on every peer is the double-spawn 'explosion'.

Returns boolean — True on the host / offline / single-player; false on a confirmed joiner. Defaults to true when the role isn't known yet (degrade to host so single-player and pre-join code still run) — pair with a scene's onHostLoad hook when exact one-shot timing matters.

if multiplayer.isHost() then enemy = entity.spawnSynced("enemy") end

typed/builtin//modules/api/engine/multiplayer/multiplayer/isOperationOpen

multiplayer.isOperationOpen() -> boolean

Whether an operation is open: true between beginOperation and its commitOperation or cancelOperation. A caller that records file writes reads the file's prior contents only while one is open.

Returns boolean — True while an operation is open.

if multiplayer.isOperationOpen() then before = vfs.read(p) end

typed/builtin//modules/api/engine/multiplayer/multiplayer/isOwner

multiplayer.isOwner(entityId: (string | entityRef)?) -> boolean

Check if the local client owns the given entity (or the current entity if called from a component). Only the owner can modify synced properties directly.

Parameters

  • entityId (string | entityRef) (optional) — Entity id or proxy to check (defaults to self.entityId in component context).

Returns boolean — True if the local client is the owner.

typed/builtin//modules/api/engine/multiplayer/multiplayer/isRoomCreator

multiplayer.isRoomCreator(roomKey: string) -> boolean?

Whether this client created the given room — it was the FIRST peer to join it (race-free; the relay assigns it on join). In play mode the creator instantiates the scene's entities (synced) and every other joiner receives them from the relay snapshot, so the scene is never double-instantiated.

Parameters

  • roomKey string — Fully-qualified room key ({worldGuid}/{profile}/{mode}/{sceneGuid}).

Returns boolean? — True if this client created the room, false if it joined an existing one, nil if the relay hasn't reported a role yet (offline / not joined).

if multiplayer.isRoomCreator(key) ~= false then layers.load(scene) end

typed/builtin//modules/api/engine/multiplayer/multiplayer/joinRoom

multiplayer.joinRoom(roomKey: string)

Join a relay room. Room keys are built as {worldGuid}/{profile}/{mode}/{sceneGuid} — four segments, the {profile} one keeping a runtime peer (published content) and an editor peer (live content) in separate rooms even when both are in play mode. Rooms partition the relay's fan-out: only peers in the same room receive each other's broadcasts. getRooms() reports the keys this client is already in and roomFor(entity) the one an entity broadcasts into, so a key can be read rather than rebuilt. A relay that verifies its peers holds each connection to the world it was admitted to: a key whose first segment names another world is refused, and getDiagnostics().refusals counts the refusal and lastRefusal names it. No-op when not connected or already joined.

Parameters

  • roomKey string — Fully-qualified room key.
multiplayer.joinRoom(worldGuid .. "/" .. engine.profile .. "/play/" .. sceneGuid)

typed/builtin//modules/api/engine/multiplayer/multiplayer/leaveRoom

multiplayer.leaveRoom(roomKey: string)

Leave a relay room. The key is reported under observe().withdrawnRooms until joinRoom names it again. No-op when not connected or not joined.

Parameters

  • roomKey string — Fully-qualified room key.

typed/builtin//modules/api/engine/multiplayer/multiplayer/loopback

multiplayer.loopback() -> { [string]: any }

Loopback testing harness. Returns a table with enable(), disable(), flush(), receive() methods for testing sync without a relay server.

Returns { [string]: any } — Loopback API table.

typed/builtin//modules/api/engine/multiplayer/multiplayer/observe

multiplayer.observe() -> ReplicationObservation

Report what this peer is replicating and why a property is not arriving. Carries the rooms this client joined, one record per entity with a sync id — its owner, the room it broadcasts into, how many other peers share that room, and every REGISTERED synced component with its declared property names, wire indices, public/private table and dirty bits — the messages held for entities that have not arrived, and the registry's totals. Every property carries notArriving: one name from reasons, or nil when it is on its way. Answers in edit mode as well as play mode, for what the relay carries in each: in edit mode scene content is left out of the sync-id pass, so its changes travel to the other clients with the source they are written into and it reads entityNotSynced here. The document describes the session as of the last frame: when no frame has assembled one since the surface was last read, the call waits for the next frame to assemble it, and a caller that cannot yield receives the one published last.

Returns ReplicationObservation — The engine's current replication observation.

local o = multiplayer.observe()
print(#o.entities .. " entities, " .. o.totals.properties .. " synced properties")

typed/builtin//modules/api/engine/multiplayer/multiplayer/observeComponent

multiplayer.observeComponent(entityId: (string | entityRef), componentType: string) -> SyncedComponent?

The registered synced component of the named type on an entity's record. Matches a fully-qualified type (@builtin::components.Model) and the leaf name it ends in (Model) alike.

Parameters

  • entityId (string | entityRef) — Entity id or proxy.
  • componentType string — Component type name or its leaf.

Returns SyncedComponent? — The registered component instance, or nil when none of that type is registered on the entity.

local c = multiplayer.observeComponent(player, "Health")
print(#c.properties .. " declared properties")

typed/builtin//modules/api/engine/multiplayer/multiplayer/observeEntity

multiplayer.observeEntity(entityId: (string | entityRef)) -> EntityReplication?

The replication record for one entity — its sync id, owner, room, and the synced components registered on it.

Parameters

  • entityId (string | entityRef) — Entity id or proxy.

Returns EntityReplication? — The entity's record, or nil when the engine holds no sync record for it.

local r = multiplayer.observeEntity(e)
for _, c in ipairs(r.components) do print(c.componentType) end

typed/builtin//modules/api/engine/multiplayer/multiplayer/on

multiplayer.on(channel: string, callback: (number, ...any) -> ())

Subscribe to a custom message channel. The callback runs as callback(fromPeerId, ...args) whenever another peer calls multiplayer.send(channel, ...). Multiple callbacks per channel fire in registration order.

Parameters

  • channel string — Channel name to listen on.
  • callback (number, ...any) -> () — function(fromPeerId: number, ...) — the sender's peer id then the sent args.

typed/builtin//modules/api/engine/multiplayer/multiplayer/recordFileWrite

multiplayer.recordFileWrite(path: string, before: string?, after: string?) -> boolean

Record a file write into the open operation. Undoing the operation writes before back through the VFS (removing the file when before is nil), which runs the owning asset type's onChange as the original write did; redoing writes after. The path is resolved and held to the rules vfs.write applies, so a sidecar (.meta, .refs, .pull) or an asset root no script may write raises. A step's files are put back all together or not at all: each file is checked (its asset exists, and it holds what the step being reversed left in it) before any is written and again immediately before it is written; when a check or a write fails partway, the files the step already wrote go back to what they held, and a notice names each file of the step and what happened to it. Several writes of one path in an operation undo to the first before. Records nothing and returns false when no operation is open.

Parameters

  • path string — VFS path of the written file.
  • before string (optional) — The file's contents before the write, nil when it did not exist.
  • after string (optional) — The file's contents after the write, nil when the write removed it.

Returns boolean — True when the write was recorded.

local before = vfs.read(p); vfs.write(p, body); multiplayer.recordFileWrite(p, before, body)

typed/builtin//modules/api/engine/multiplayer/multiplayer/recordSpawn

multiplayer.recordSpawn(entityId: string)

Adopt an existing entity into the open operation as its spawn — for flows that create an entity before the operation opens (a drag preview adopted on drop). Undoing the operation despawns it.

Parameters

  • entityId string — Entity id to record as spawned by this operation.
multiplayer.recordSpawn(id)

typed/builtin//modules/api/engine/multiplayer/multiplayer/redo

multiplayer.redo() -> boolean

Redo this client's last undone operation.

Returns boolean — True if an operation was redone.

typed/builtin//modules/api/engine/multiplayer/multiplayer/releaseOwnership

multiplayer.releaseOwnership(entityId: (string | entityRef)?) -> boolean

Release ownership of an entity, leaving it unowned so another peer's claim takes it. Only the owner releases an entity.

Parameters

  • entityId (string | entityRef) (optional) — Entity id or proxy to release.

Returns boolean — True if ownership was released.

typed/builtin//modules/api/engine/multiplayer/multiplayer/roomFor

multiplayer.roomFor(entityId: (string | entityRef)) -> string?

The room key an entity's spawns and property deltas broadcast into: the room of its observeEntity record as of the last frame, read from the rooms the session publishes every frame, so a component can ask every frame.

Parameters

  • entityId (string | entityRef) — Entity id or proxy.

Returns string? — The room key, or nil when the engine has established no scene context for the entity.

local key = multiplayer.roomFor(player)
if key then multiplayer.joinRoom(key) end

typed/builtin//modules/api/engine/multiplayer/multiplayer/send

multiplayer.send(channel: string, ...: any?)

Broadcast a message on a named channel to every OTHER peer in the room. The relay forwards it transparently; peers receive it via multiplayer.on. The arguments are delivered to listeners in order, each one arriving as what it is, at the top level and at every depth of a table:

  • nil, a boolean, a number and a string arrive as themselves.
  • A vector arrives as a vector.
  • The proxy of a synced entity arrives as the receiving peer's proxy for that entity; the proxy of an entity no peer shares, as its id string.
  • A synced component (its proxy, or the componentRef envelope naming it) arrives as that component on the receiving peer; the proxy of a component that holds no sync id, as the envelope { __ref, entity, componentType } naming it.
  • A transform vector an entity hands out (e.position, e.rotation, e.localScale, ...) arrives as the table of its components, { x, y, z } or { x, y, z, w }.
  • Every other table is data and arrives as a new table, each value carried by these same rules: { x = 5, kind = "coin" } arrives as { x = 5, kind = "coin" }, and { who = e, n = 1 } with t.who the receiver's proxy for e. A table whose length #t is above 0 arrives as the list of its values at 1..#t alone; any other arrives keyed by its string keys and by each number key as the string of its whole part ({ [5] = "a" } arrives as { ["5"] = "a" }).
  • A value no peer can hold (a function, a thread, a buffer) arrives as nil. The same rules carry the arguments of a declare.syncedFunctions method, the payload of a Sync event and a synced table-valued field. No-op when not connected.

Parameters

  • channel string — Channel name listeners subscribe to via multiplayer.on.
  • ... any (optional) — Zero or more values delivered to each listener after the sender's peer id.

typed/builtin//modules/api/engine/multiplayer/multiplayer/syncTotals

multiplayer.syncTotals() -> SyncTotals

What the sync registry holds across every entity: entities with a registered synced component, component instances, declared properties, declared functions, and the component instances holding a dirty property this tick.

Returns SyncTotals — The registry totals.

print(multiplayer.syncTotals().properties .. " synced properties registered")

typed/builtin//modules/api/engine/multiplayer/multiplayer/undo

multiplayer.undo() -> boolean

Undo this client's last edit-mode operation.

Returns boolean — True if an operation was undone.

  • api
  • reference