Loading...
Loading...
Peer-to-peer multiplayer in Decentraland using CRDT networking with syncEntity and MessageBus. Use when the user wants multiplayer, synced entities, shared world state, broadcast events, or player-to-player communication without a server. Do NOT use for server-authoritative multiplayer, anti-cheat, or persistent storage (see authoritative-server). Do NOT use for screen UI (see build-ui).
npx skill4agent add decentraland/sdk-skills multiplayer-syncRuntime constraint: Decentraland runs in a QuickJS sandbox. No Node.js APIs (,fs,http,path). Useprocessandfetch()for network communication. See the scene-runtime skill for async patterns.WebSocket
| Strategy | Use When | Persistence | Example |
|---|---|---|---|
| Shared state that all players see and that persists for new arrivals | Yes — state survives player join/leave, but only as long as at least one player remains in the scene. The state resets as soon as the scene is empty | Doors, switches, scoreboards, elevators |
| Ephemeral events that only matter in the moment | No — late joiners miss past messages | Chat messages, sound effects, particle triggers |
| Reading or writing data to an external server | Server-dependent | Leaderboards, inventory, external game state |
| Authenticated requests that prove player identity | Server-dependent | Claiming rewards, submitting verified scores |
| Real-time bidirectional communication with a server | Connection-dependent | Live game servers, real-time chat. |
syncEntityMessageBusfetchsignedFetchWebSocketsyncEntityMessageBusfetchimport { engine, Transform, MeshRenderer, Material } from '@dcl/sdk/ecs'
import { syncEntity } from '@dcl/sdk/network'
import { Vector3, Color4 } from '@dcl/sdk/math'syncEntity(entity, componentIds[], syncId?)entitycomponentIds[][Transform.componentId]syncIdenum SyncIds {
DOOR = 1,
ELEVATOR = 2,
SCOREBOARD = 3,
}
const door = engine.addEntity()
Transform.create(door, { position: Vector3.create(8, 1, 8) })
MeshRenderer.setBox(door)
syncEntity(
door,
[Transform.componentId, MeshRenderer.componentId],
SyncIds.DOOR
)Best practice — always give singletons a stable sync ID. Auto IDs derive identity from the creating peer + its local engine entity number (which the engine recycles). A singleton synced entity that is destroyed and recreated repeatedly with an auto ID is fragile over real network comms: it may fail to reconcile on remote clients (they see only default component data) even though it works perfectly in local single-process preview. Assign any singleton or small fixed set of well-known synced entities a STABLE explicit sync ID from a reserved enum. Reserve auto IDs for genuinely dynamic, many-instance, create-and-forget entities. Also: nevera fixed-ID synced entity and recreate it with the same ID in the SAME frame — the internalremoveEntitysurvives until a later CRDT flush, so recreating immediately throwsNetworkEntity; defer the re-spawn to a later tick. Seeid provided is already in use(syncEntity identity section) for the full failure-mode signature, fix, and the optimistic-prediction companion pattern.{baseDir}/references/networking-patterns.md
function createProjectile() {
const projectile = engine.addEntity()
Transform.create(projectile, { position: Vector3.create(4, 1, 4) })
MeshRenderer.setSphere(projectile)
syncEntity(projectile, [Transform.componentId])
return projectile
}Some visuals are inherently per-player, not shared. A(camera-facing) is recomputed locally in each explorer, so every player sees it facing themselves — this is not synced and needs noBillboard. The exception is asyncEntitywith aBillboard: because the target's position is scene state, all players see that billboard oriented the same way. UsetargetEntitywhen you need a shared, consistent orientation (e.g. a sign that points at a shared object). See thetargetEntity/player-avatarcomponent reference for Billboard details.sdk-scenes
import { engine, Schemas } from '@dcl/sdk/ecs'
import { syncEntity } from '@dcl/sdk/network'
const ScoreBoard = engine.defineComponent('scoreBoard', {
score: Schemas.Int,
playerName: Schemas.String,
lastUpdated: Schemas.Int64,
})
const board = engine.addEntity()
ScoreBoard.create(board, { score: 0, playerName: '', lastUpdated: 0 })
syncEntity(board, [ScoreBoard.componentId])
function addScore(points: number) {
const data = ScoreBoard.getMutable(board)
data.score += points
data.lastUpdated = Date.now()
}Usefor timestamps and other large numbers.Schemas.Int64/Schemas.Numbercorrupt values over 13 digits (likeSchemas.Int) — always store such values inDate.now()(asSchemas.Int64above does).lastUpdated
PlayerIdentityDataimport { engine, PlayerIdentityData } from '@dcl/sdk/ecs'
engine.addSystem(() => {
for (const [entity] of engine.getEntitiesWith(PlayerIdentityData)) {
const data = PlayerIdentityData.get(entity)
console.log('Player:', data.address, 'Guest:', data.isGuest)
}
})| Type | Usage |
|---|---|
| true/false |
| Integer numbers |
| Decimal numbers |
| Text strings |
| Large integers (timestamps) |
| 3D coordinates |
| Rotations |
| RGB colors |
| RGBA colors |
| Entity reference |
| Array of values |
| Nested struct — |
| Nullable values |
| Numeric enum; |
| String enum; |
| Discriminated union ( |
enum Rarity { Common = 0, Rare = 1, Legendary = 2 }
const Loot = engine.defineComponent('game::Loot', {
rarity: Schemas.EnumNumber<Rarity>(Rarity, Rarity.Common),
payload: Schemas.OneOf({
coins: Schemas.Int,
item: Schemas.String,
}),
label: Schemas.Optional(Schemas.String),
})parentEntity()Transform.parentimport {
syncEntity,
parentEntity,
getParent,
getChildren,
removeParent,
} from '@dcl/sdk/network'
const parent = engine.addEntity()
const child = engine.addEntity()
syncEntity(parent, [Transform.componentId], 1)
syncEntity(child, [Transform.componentId], 2)
// Use parentEntity() — NOT Transform.parent
parentEntity(child, parent)
const parentRef = getParent(child)
const childrenArray = Array.from(getChildren(parent))
// Remove parent relationship
removeParent(child)import { isStateSyncronized } from '@dcl/sdk/network'
engine.addSystem(() => {
if (!isStateSyncronized()) return // wait for sync
// safe to read/write synced state
})isStateSyncronizedimport { MessageBus } from '@dcl/sdk/message-bus'
const bus = new MessageBus()
bus.on('hit', (data: { damage: number }) => {
console.log('Took damage:', data.damage)
})
bus.emit('hit', { damage: 10 })Authoritative-server scenes:is client-only — the headless server runtime does not implement the legacy comms event it relies on, and a module-scopeMessageBus(as above) fails on the server withnew MessageBus(). In those scenes construct it only inside the client branch (RemoteError: not implemented); see theif (!isServer())skill.authoritative-server
syncEntityMessageBusMessageBusUint8ArraysyncEntityMessageBusimport { sendBinary } from '~system/CommunicationsController'
import { executeTask } from '@dcl/sdk/ecs'
// Send a binary message to all peers (or a specific subset via peerData)
executeTask(async () => {
const payload = new Uint8Array([1, 2, 3, 4]) // your encoded data
const response = await sendBinary({
data: [payload],
peerData: undefined, // optional: target specific peers
})
// response.data is a Uint8Array[] of messages received from other peers
for (const incoming of response.data) {
handleBinaryMessage(incoming)
}
})Uint8ArrayDataViewTextEncoderTextDecodersendBinaryfetchsignedFetchexecuteTasksignedFetchfetchsignedFetch{baseDir}/references/networking-patterns.mdexecuteTask(async () => {
const ws = new WebSocket('wss://example.com/ws')
ws.onopen = () => {
console.log('Connected to WebSocket')
ws.send(JSON.stringify({ type: 'join', playerId: 'player123' }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
switch (msg.type) {
case 'gameState':
handleGameState(msg)
break
case 'playerJoin':
handlePlayerJoin(msg)
break
case 'playerLeave':
handlePlayerLeave(msg)
break
}
}
ws.onerror = (error) => console.error('WebSocket error:', error)
ws.onclose = () => console.log('Disconnected')
})import { onEnterScene, onLeaveScene } from '@dcl/sdk/src/players'
onEnterScene((player) => {
console.log('Player entered:', player.userId)
})
onLeaveScene((userId) => {
console.log('Player left:', userId)
}){
"worldConfiguration": {
"fixedAdapter": "offline:offline"
}
}| Problem | Cause | Solution |
|---|---|---|
| | Move all |
| State not syncing between players | Missing | Every entity you want shared must call |
| Sync ID collision | Two entities share the same numeric sync ID | Use an enum to assign unique IDs to every predefined synced entity |
| State not ready on join | Reading synced state before sync completes | Guard with |
| MessageBus messages lost | Late joiner expecting past messages | MessageBus is fire-and-forget. Use |
Need guaranteed consistency, server-side validation, or anti-cheat?andsyncEntityare not entirely reliable — if it's important that all players see the same state change, see the authoritative-server skill for the headless server pattern. For a complete competitive game architecture (anti-cheat with server-side proximity validation, checkpoint-only Storage persistence, atomic component splits by change rate), see the Gem Rush reference scene (MessageBus).92,-9-authoritative-server-gem-rush
syncEntityMessageBussyncEntityvalidateBeforeChangesyncEntityvalidateBeforeChange