skills/multiplayer-sync/SKILL.md
Synchronize state between players in Decentraland using CRDT networking (syncEntity), MessageBus, fetch, signedFetch, and WebSocket. Use when the user wants multiplayer, synced entities, shared world state, real-time networking, or player-to-player communication. Do NOT use for server-authoritative multiplayer with anti-cheat (see authoritative-server). Do NOT use for screen UI (see build-ui).
npx skillsauth add dcl-regenesislabs/opendcl multiplayer-syncInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
syncEntity() adds a NetworkEntity component at runtime — and NetworkEntity is not in main-entities.ts's supported component list. The pattern is:
main-entities.ts with their visual components. Look them up in src/index.ts and call syncEntity(entity, [...componentIds], SyncIds.NAME) there.engine.addEntity() in src/index.ts and syncEntity immediately.// main-entities.ts
door: {
components: {
Transform: { position: { x: 8, y: 1, z: 8 } },
MeshRenderer: { mesh: { $case: 'box', box: { uvs: [] } } }
}
}
// src/index.ts
import { engine, Transform, syncEntity } from '@dcl/sdk/network'
export function main() {
const door = engine.getEntityOrNullByName('door')
if (door) syncEntity(door, [Transform.componentId], SyncIds.DOOR)
}
Decentraland scenes are inherently multiplayer — every player in the same scene shares the same space. By default, however, players interact with the environment independently — changes one player makes are NOT shared with others. Opt in to sharing by wrapping mutable state in syncEntity, broadcasting events through MessageBus, or persisting through your own backend.
syncEntity state persists only as long as at least one player remains in the scene. The state resets as soon as the scene is empty. For durable state across scene resets, persist to your own server.
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.
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 | 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, authoritative multiplayer |
Decision flow:
syncEntityMessageBusfetch or signedFetchWebSocketsyncEntity for world state, MessageBus for effects, and fetch for persistence.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)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.
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
}
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 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)
}
})
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(valueType) | Key-value maps |
| Schemas.Optional(innerType) | Nullable values |
| Schemas.Enum(enumType) | Enum values |
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)
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.
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 })
syncEntity: state is persistent, late joiners get current state, automatic conflict resolutionMessageBus: fire-and-forget, late joiners miss past messages, good for transient effectssyncEntity for the door open/closed state, MessageBus for the sound effect when it opensAll network calls must run inside executeTask because the SDK runtime does not support top-level await.
import { executeTask } from '@dcl/sdk/ecs'
executeTask(async () => {
try {
const response = await fetch('https://api.example.com/data')
if (!response.ok) {
console.error('HTTP error:', response.status)
return
}
const data = await response.json()
console.log('Response:', data)
} catch (error) {
console.error('Network error:', error)
}
})
signedFetch attaches a cryptographic signature proving the player's identity:
import { signedFetch } from '~system/SignedFetch'
executeTask(async () => {
try {
const response = await signedFetch({
url: 'https://example.com/api/action',
init: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'claimReward', amount: 100 })
}
})
if (!response.ok) {
console.error('HTTP error:', response.status)
return
}
const result = JSON.parse(response.body)
console.log('Result:', result)
} catch (error) {
console.log('Request failed:', error)
}
})
For full WebSocket patterns (reconnection, heartbeat, message format), see {baseDir}/references/networking-patterns.md.
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')
})
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)
})
Open multiple browser windows to test multiplayer locally. Each window is a separate player.
For Decentraland Worlds that do not need multiplayer:
{
"worldConfiguration": {
"fixedAdapter": "offline:offline"
}
}
| Problem | Cause | Solution |
|---------|-------|----------|
| 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 |
| Entity disappears when creator leaves | No sync ID provided | Add a sync ID (third argument) to syncEntity() for entities that should persist |
| Date.now() values corrupted | Using Schemas.Number for timestamps | Use Schemas.Int64 for any number over 13 digits (like Date.now()) |
| 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 server-side validation or anti-cheat? See the authoritative-server skill for the headless server pattern.
syncEntity(entity, [componentIds]) — pass the componentId of each component to syncSchemas.Int64 for timestamps: Schemas.Number corrupts large numbers (13+ digits). Always use Schemas.Int64 for values like Date.now()authoritative-server skilldevelopment
Capture screenshots of the running Decentraland preview to verify scene changes visually. Covers camera movement, interaction actions, and visual debugging. Use when the preview is running and you need to check what the scene looks like, debug visual issues, or verify layout. Do NOT use for code changes (make changes first, then screenshot).
development
Cross-cutting runtime APIs for Decentraland SDK7 scenes. Use when the user needs async operations (executeTask), HTTP requests (fetch, signedFetch), WebSocket connections, timers, realm/scene detection, restricted actions (movePlayerTo, teleportTo, triggerEmote, openExternalUrl), portable experiences, or the testing framework. Do NOT use for UI (see build-ui), multiplayer sync (see multiplayer-sync), or avatar/player data (see player-avatar).
development
Apply physics forces to the player in Decentraland scenes. Impulses (one-shot pushes), knockback (push away from a point with falloff), continuous forces (wind tunnels, anti-gravity, lift, levitation, hover), timed forces, and repulsion fields. Use when the user wants launch pads, knockback on hit, wind zones, gravity fields, jumps, lifting/floating the player, pushing the player up/sideways/back, hover effects, or any scene-applied force on the player. THIS is also the right skill when an agent's first instinct is to mutate `Transform` on `engine.PlayerEntity` to move/lift/push the player — that does NOT work (the player Transform is engine-controlled and read-only); use the Physics API instead. Do NOT use for player movement speed (see player-avatar AvatarLocomotionSettings) or platform movement (see animations-tweens).
data-ai
Player and avatar system in Decentraland. Read player position/profile, customize appearance (AvatarBase), trigger emotes (triggerEmote/triggerSceneEmote), read equipped wearables (AvatarEquippedData), attach objects to players (AvatarAttach), create NPC avatars (AvatarShape), avatar modifier areas, and locomotion settings. Use when the user wants player data, emotes, wearables, NPC avatars, avatar attachments, or movement speed changes. Do NOT use for wallet/blockchain interactions (see nft-blockchain).