multiplayer
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
descriptionstring— 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
relayUrlstring— 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.componentTypestring— Component type name, e.g. "Health".propertystring— Property name as written in the component'ssync {}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
roomKeystring— 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 toself.entityIdin 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
roomKeystring— 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
roomKeystring— 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
roomKeystring— 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.componentTypestring— 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
channelstring— 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
pathstring— VFS path of the written file.beforestring(optional) — The file's contents before the write, nil when it did not exist.afterstring(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
entityIdstring— 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
vectorarrives as avector. - 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
componentRefenvelope 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 }witht.whothe receiver's proxy fore. A table whose length#tis above 0 arrives as the list of its values at1..#talone; 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.syncedFunctionsmethod, the payload of aSyncevent and a synced table-valued field. No-op when not connected.
Parameters
channelstring— Channel name listeners subscribe to viamultiplayer.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
descriptionstring— 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
relayUrlstring— 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
roomKeystring— 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 toself.entityIdin 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
roomKeystring— 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
roomKeystring— 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
roomKeystring— 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
channelstring— 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
pathstring— VFS path of the written file.beforestring?(optional) — The file's contents before the write, nil when it did not exist.afterstring?(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
entityIdstring— 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
vectorarrives as avector. - 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
componentRefenvelope 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 }witht.whothe receiver's proxy fore. A table whose length#tis above 0 arrives as the list of its values at1..#talone; 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.syncedFunctionsmethod, the payload of aSyncevent and a synced table-valued field. No-op when not connected.
Parameters
channelstring— Channel name listeners subscribe to viamultiplayer.on.argsany(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
descriptionstring— 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
relayUrlstring— 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.componentTypestring— Component type name, e.g. "Health".propertystring— Property name as written in the component'ssync {}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
roomKeystring— 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 toself.entityIdin 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
roomKeystring— 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
roomKeystring— 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
roomKeystring— 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.componentTypestring— 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
channelstring— 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
pathstring— VFS path of the written file.beforestring(optional) — The file's contents before the write, nil when it did not exist.afterstring(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
entityIdstring— 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
vectorarrives as avector. - 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
componentRefenvelope 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 }witht.whothe receiver's proxy fore. A table whose length#tis above 0 arrives as the list of its values at1..#talone; 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.syncedFunctionsmethod, the payload of aSyncevent and a synced table-valued field. No-op when not connected.
Parameters
channelstring— Channel name listeners subscribe to viamultiplayer.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.