multiplayer-sync
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).
How do I install this agent skill?
npx skills add https://github.com/decentraland/sdk-skills --skill multiplayer-syncIs this agent skill safe to install?
- Gen Agent Trust Hubpass
This skill provides educational patterns and architectural guidance for implementing multiplayer synchronization in Decentraland. It includes standard implementations for peer-to-peer state sharing, event broadcasting, and authenticated communication with external servers. No security vulnerabilities or malicious patterns were detected.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Multiplayer Synchronization in Decentraland
Decentraland runs scenes locally in a player's instance of the explorer. By default, players are able to see each other and interact directly, but each player interacts with the environment independently. Changes in the environment aren't shared between players by default.
To sync any changes in the scene state, SDK7 uses CRDT-based synchronization.
Runtime constraint: Decentraland runs in a QuickJS sandbox. No Node.js APIs (
fs,http,path,process). Usefetch()andWebSocketfor network communication. See the scene-runtime skill for async patterns.
Sync Strategy Decision Tree
Choose the right networking approach based on what you need:
| Strategy | Use When | Persistence | Example |
|---|---|---|---|
syncEntity | 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 |
MessageBus | Ephemeral events that only matter in the moment | No — late joiners miss past messages | Chat messages, sound effects, particle triggers |
fetch / REST API | Reading or writing data to an external server | Server-dependent | Leaderboards, inventory, external game state |
signedFetch | Authenticated requests that prove player identity | Server-dependent | Claiming rewards, submitting verified scores |
WebSocket | Real-time bidirectional communication with a server | Connection-dependent | Live game servers, real-time chat. |
Decision flow:
- Does every player need to see the same state, including late joiners? -->
syncEntity - Is it a fire-and-forget event only for players currently in the scene? -->
MessageBus - Do you need the information to be persisted even after all players leave, or to run secure validations on that information? -->
fetchorsignedFetch - Do you need continuous real-time server communication? -->
WebSocket - Combine approaches freely: use
syncEntityfor world state,MessageBusfor effects, andfetchfor persistence.
syncEntity Essentials
Import and Basic Usage
import { engine, Transform, MeshRenderer, Material } from '@dcl/sdk/ecs'
import { syncEntity } from '@dcl/sdk/network'
import { Vector3, Color4 } from '@dcl/sdk/math'
Signature: syncEntity(entity, componentIds[], syncId?)
entity— the entity to synchronizecomponentIds[]— array of component IDs to keep in sync (e.g.,[Transform.componentId])syncId— unique numeric identifier (required for predefined entities, optional for player-spawned entities)
Enum Sync IDs (Predefined Entities)
Every predefined synced entity MUST have a unique numeric ID. Use an enum to avoid collisions:
enum 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
)
Predefined entities (with a sync ID) persist after the creating player leaves. Player-created entities (no sync ID) are removed when the player disconnects.
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: never
removeEntitya fixed-ID synced entity and recreate it with the same ID in the SAME frame — the internalNetworkEntitysurvives until a later CRDT flush, so recreating immediately throwsid provided is already in use; defer the re-spawn to a later tick. See{baseDir}/references/networking-patterns.md(syncEntity identity section) for the full failure-mode signature, fix, and the optimistic-prediction companion pattern.
Auto-Generated IDs (Player-Spawned Entities)
Entities created at runtime by players do not need an explicit sync ID:
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
Billboard(camera-facing) is recomputed locally in each explorer, so every player sees it facing themselves — this is not synced and needs nosyncEntity. The exception is aBillboardwith atargetEntity: 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 theplayer-avatar/sdk-scenescomponent reference for Billboard details.
Custom Synced Components
Define custom components and sync them between players:
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()
}
Use
Schemas.Int64for timestamps and other large numbers.Schemas.Number/Schemas.Intcorrupt values over 13 digits (likeDate.now()) — always store such values inSchemas.Int64(aslastUpdatedabove does).
Custom schemas must be deterministic: the same component name must map to the same schema across all clients.
Player-Specific Data
Use PlayerIdentityData to distinguish players:
import { 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)
}
})
Schema Types
Available schema types for custom components:
| Type | Usage |
|---|---|
Schemas.Boolean | true/false |
Schemas.Int | Integer numbers |
Schemas.Float | Decimal numbers |
Schemas.String | Text strings |
Schemas.Int64 | Large integers (timestamps) |
Schemas.Vector3 | 3D coordinates |
Schemas.Quaternion | Rotations |
Schemas.Color3 | RGB colors |
Schemas.Color4 | RGBA colors |
Schemas.Entity | Entity reference |
Schemas.Array(innerType) | Array of values |
Schemas.Map(spec, default?) | Nested struct — spec is named fields ({ x: Schemas.Int, ... }), NOT a homogeneous key→value map |
Schemas.Optional(innerType) | Nullable values |
Schemas.EnumNumber(enumObj, default) | Numeric enum; default is required (e.g. Schemas.EnumNumber(State, State.Lobby)) |
Schemas.EnumString(enumObj, default) | String enum; default is required |
Schemas.OneOf({ ... }) | Discriminated union ($case + payload) |
Example with the less common schema types:
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),
})
Schema fixes landed in @dcl/sdk 7.28.0 — drop the old workarounds
All four were real bugs; if you are on 7.28.0+ you can write the obvious code.
| Fix | Old broken behavior | Now |
|---|---|---|
Schemas.Optional(...) with falsy values (da82bfb0) | false, 0 and '' were treated as unset — never written, read back as undefined. An Optional(Schemas.Boolean) could only hold true or nothing. | Presence is tested for undefined/null, so falsy values round-trip. Optional(Boolean) is now a real tri-state. |
Schemas.OneOf(...) left unselected (0dce4d2d) | create() returns {} with no $case; serialization called $case.toString(), threw, and killed the engine tick for the whole scene. | The unset case is encoded as index 0 (real cases start at 1) and deserializes back to {}. A OneOf field the scene never sets is safe. |
Components defined directly from a primitive or array schema (5ec1a8a1) | Every accessor tested the stored value for truthiness, so a component holding 0, false or '' reported itself as missing: get threw "not found", getOrNull/deleteFrom returned null, getMutable threw, getOrCreateMutable threw "already exists", and create silently succeeded over an existing falsy value. deepReadonly also spread non-objects, so a number came back as {} and an array as an index-keyed object. | Presence is asked of the map, and deepReadonly passes primitives through and copies arrays as arrays. |
ReadonlyPrimitive narrowed (5ec1a8a1) | Included array types, so DeepReadonly<number[]> resolved back to number[] — a .push() type-checked and then threw at runtime, because the array is frozen. | ReadonlyPrimitive = number | string | boolean. Array reads now resolve through the ReadonlyArray branch: .push() no longer type-checks. Copy the array ([...arr]) before mutating. This is a public API change; existing code that mutated a component array read may newly fail to compile — that code was already broken at runtime. |
Related in the same release: Player.wearables and Player.emotes from the @dcl/sdk player helper are now copies, not aliases of the frozen component arrays, so writing to them no longer throws.
Parent-Child Sync Relationships
For synced entities with parent-child relationships, use parentEntity() instead of setting Transform.parent:
import {
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)
Connection State
Check if the player is connected to the sync room:
import { isStateSyncronized } from '@dcl/sdk/network'
engine.addSystem(() => {
if (!isStateSyncronized()) return // wait for sync
// safe to read/write synced state
})
Note: The function is spelled isStateSyncronized (not "Synchronized") in the SDK.
MessageBus
Send custom messages between players (fire-and-forget, no persistence):
import { 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:
MessageBusis client-only — the headless server runtime does not implement the legacy comms event it relies on, and a module-scopenew MessageBus()(as above) fails on the server withRemoteError: not implemented. In those scenes construct it only inside the client branch (if (!isServer())); see theauthoritative-serverskill.
syncEntity vs MessageBus
syncEntity: late joiners get current state, automatic conflict resolution — CRDT last-write-wins, so if two players change the same component simultaneously the last write wins. The state persists as long as at least one player remains in the sceneMessageBus: fire-and-forget, late joiners miss past messages, good for transient effects
Binary MessageBus (Performance Optimization)
The regular MessageBus JSON-encodes every payload before sending. For high-frequency messages or large payloads, there's a lower-level binary alternative that sends raw Uint8Array data directly — faster to process because it skips JSON serialization on both ends. This is the same transport syncEntity uses internally.
Use it when:
- You are emitting many messages per second (e.g., continuous movement streams, particle triggers in tight loops)
- Payload size matters (binary encoding is more compact than JSON)
- You already have binary data (e.g., pre-encoded buffers, CRDT deltas)
Stick with the regular MessageBus for low-frequency events where ergonomics beat performance.
import { 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)
}
})
You are responsible for encoding/decoding the Uint8Array payloads yourself (e.g., with DataView, TextEncoder/TextDecoder, or protobuf). There's no on/emit/topic layer — sendBinary is a single call that both sends pending outgoing messages and returns incoming ones, so you typically drive it from a system on each tick.
REST API and Signed Fetch
For communicating with your own backend (leaderboards, game state persistence, player auth), use fetch or signedFetch. All network calls must run inside executeTask. signedFetch attaches a cryptographic proof of the player's wallet identity — use it when your server needs to verify who is making the request.
See the scene-runtime skill for full fetch and signedFetch patterns.
WebSocket Connections
For full WebSocket patterns (reconnection, heartbeat, message format), see {baseDir}/references/networking-patterns.md.
Basic Connection
executeTask(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')
})
Player Enter/Leave Events
Detect players entering or leaving the scene:
import { onEnterScene, onLeaveScene } from '@dcl/sdk/src/players'
onEnterScene((player) => {
console.log('Player entered:', player.userId)
})
onLeaveScene((userId) => {
console.log('Player left:', userId)
})
SDK Observables (low-level)
The SDK also exposes lower-level observables (onPlayerClickedObservable, onEnterSceneObservable, onLeaveSceneObservable, onRealmChangedObservable, onPlayerExpressionObservable, onProfileChangedObservable) from @dcl/sdk/observables. These are the primitives underlying the onEnterScene/onLeaveScene helpers above. Recent fixes to be aware of:
GOTCHA: remote players' Transforms are world coordinates
The local player's Transform is scene-local; another player's entity reports world coordinates. Subtract basePos * 16 to compare them (see player-avatar > "remote players' Transforms are in WORLD coordinates"). This bites any distance check, leaderboard-by-proximity, or follow camera that iterates PlayerIdentityData.
Per-player visuals are NOT automatically shared
Some components that look multiplayer are client-local: writing them on a remote player's entity changes only what the writing client sees. They are never relayed, and syncEntity does not help — player entities are engine-owned, not scene-created.
AvatarNametag(the rank/role plate above an avatar,@dcl/sdk7.28.0+) is client-local. If every player should see the same plate on everyone, each client must compute and write the whole set itself.
Two ways to make a client-local visual agree across clients:
- Derive it from data every client already has. E.g. sort all
PlayerIdentityDataaddresses and index a fixed roster — same address list, same result on every client, independent of join order. No messages, no drift. This is the pattern inscenes/4,24-avatar-nametag. - Replicate the input, not the visual. Sync the assignment (a synced component or a
MessageBusmessage), then have every client apply it locally to the player entity it resolves for that user id.
In both cases: resolve the target Entity from the user id on every write. Player entity ids are recycled across disconnects, so a cached Entity can land the write on a different player.
Multiplayer Testing
Every client connected to the local preview realm counts as a separate player — but the desktop Explorer only allows one instance at a time. Pressing Preview a second time in the Creator Hub re-focuses the window that is already open; it does not add a second player. Two ways to get a second local client:
- Second desktop instance: in Creator Hub open Play Options (the dropdown arrow on the Play button), hover the Desktop Client row to open its flyout, and tick Multi-Instance Preview (requires
@dcl/sdk>= 7.20.4); or runnpx sdk-commands start --multi-instance. Then press Play / run the command again.-n("open a new instance of the Client even if one is already running") forces a new window on its own. Multi-instance needs each window to authenticate as a different account, so it disables Skip Auth Screen — expect an auth screen per instance and sign in with a different wallet in each. - Desktop + browser: leave the desktop client open and add the Bevy web client as player 2 — Play Options → select the Web (Bevy) client row, or
npx sdk-commands start --web(alias--bevy-web), which openshttps://decentraland.org/bevy-web/?preview=true&realm=<local realm origin>(.zoneunder--dclenv zone). Browser tabs have no instance limit.
The decentraland:// deep link Preview fires (realm=…&position=…&dclenv=…&local-scene=true) is subject to the same single-instance rule unless it carries open-deeplink-in-new-instance=true (from -n) or multi-instance=true; let the CLI or Creator Hub build it rather than typing it by hand.
Offline Mode
For Decentraland Worlds that do not need multiplayer:
{
"worldConfiguration": {
"fixedAdapter": "offline:offline"
}
}
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Profile not initialized. Call syncEntity inside main() | syncEntity called at module top-level (e.g. in a module initialiser) | Move all syncEntity calls (and entity creation that depends on them) into a function called from main(). Never call syncEntity at module load time. Same applies to engine.addSystem(). |
| State not syncing between players | Missing syncEntity() call | Every entity you want shared must call syncEntity(entity, [ComponentId1, ComponentId2]) |
| 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 if (!isStateSyncronized()) return in your system |
| MessageBus messages lost | Late joiner expecting past messages | MessageBus is fire-and-forget. Use syncEntity for persistent state |
Need guaranteed consistency, server-side validation, or anti-cheat?
syncEntityandMessageBusare 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 (92,-9-authoritative-server-gem-rush).
Example scenes
Engine-team test scenes exercised against the real engine:
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/88,-13-avatar-masks — serverless
syncEntity, the pattern this skill documents: anenum SyncIdgiving two singletons stable IDs,syncEntity(crateAnchorEntity, [AvatarAttach.componentId], SyncId.CRATE_ANCHOR)andsyncEntity(crateEntity, [Transform.componentId], SyncId.CRATE), plusparentEntity/removeParentfrom@dcl/sdk/networkto hand a shared crate between players. Any client may mutate it — there is novalidateBeforeChangeand noisServer()branch. (The scene's headline feature is emote masks; the sync is the supporting half of it.)
For contrast, the other multiplayer scene in the set is server-authoritative — use it to see how that pattern differs from the serverless one described here:
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/90,-9-authoritative-server-leaderboard — authoritative (NOT serverless): only the server calls
syncEntity, and synced components are locked withvalidateBeforeChangeso clients can only read them and send messages. If you instead want any client to mutate shared state directly (the pattern this skill documents), each client callssyncEntityon its own and there is novalidateBeforeChange. See the authoritative-server skill for that scene's full breakdown.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/decentraland/sdk-skills/multiplayer-sync">View multiplayer-sync on skillZs</a>