mcpField
M
multiplayer-sync

multiplayer-sync

github.com/dcl-regenesislabsDevelopment1737 installsUpdated 21w ago
Repository

About multiplayer-sync

A multiplayer synchronization skill for Decentraland scene developers. It explains sharing entity state across players and choosing between persistent state, transient messages, and external server communication, with guidance on custom components and connection state.

Key features

  1. 01Synchronize entity components and state
  2. 02Send transient messages with MessageBus
  3. 03Communicate through REST, signedFetch, or WebSocket
  4. 04Define custom synchronized components
  5. 05Track player identity and sync status

Use cases

  1. 01Share doors or scoreboards among players
  2. 02Broadcast transient chat or sound events
  3. 03Persist data on an external server
  4. 04Build real-time server communication

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/dcl-regenesislabs/opendcl --skill multiplayer-sync

After installing, type/multiplayer-syncin Claude Code to confirm it loaded.

Bundled filesreferences/networking-patterns.md

Details

From the project README

Multiplayer Synchronization in Decentraland

Authoring split

syncEntity() adds a NetworkEntity component at runtime — and NetworkEntity is not in main-entities.ts's supported component list. The pattern is:

  • Predefined synced entities (a door, a switch, a scoreboard — known at author time): declare them in main-entities.ts with their visual components. Look them up in src/index.ts and call syncEntity(entity, [...componentIds], SyncIds.NAME) there.
  • Dynamically-spawned synced entities (projectiles, drops, runtime-created game objects): create with 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). Use fetch() and WebSocket for network communication. See the scene-runtime skill for async patterns.

Sync Strategy Decision Tree

Choose the right networking approach based on what you need:

StrategyUse WhenPersistenceExample
syncEntityShared state that all players see and that persists for new arrivalsYes — state survives player join/leaveDoors, switches, scoreboards, elevators
MessageBusEphemeral events that only matter in the momentNo — late joiners miss past messagesChat messages, sound effects, particle triggers
fetch / REST APIReading or writing data to an external serverServer-dependentLeaderboards, inventory, external game state
signedFetchAuthenticated requests that prove player identityServer-dependentClaiming rewards, submitting verified scores
WebSocketReal-time bidirectional communication with a serverConnection-dependentLive game servers, real-time chat, authoritative multiplayer

Decision flow:

  1. Does every player need to see the same state, including late joiners? --> syncEntity
  2. Is it a fire-and-forget event only for players currently in the scene? --> MessageBus
  3. Do you need state persisted after all players leave, or server-side validation? --> external server via fetch or signedFetch
  4. Do you need continuous real-time server communication? --> WebSocket
  5. Combine approaches freely: use syncEntity for world state, MessageBus for effects, and fetch for 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 synchronize
  • componentIds[] — 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.

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
}

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()
}

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:

TypeUsage
Schemas.Booleantrue/false
Schemas.IntInteger numbers
Schemas.FloatDecimal numbers
Schemas.StringText strings
Schemas.Int64Large integers (timestamps)
Schemas.Vector33D coordinates
Schemas.QuaternionRotations
Schemas.Color3RGB colors
Schemas.Color4RGBA colors
Schemas.EntityEntity reference
Schemas.Array(innerType)Array of values
Schemas.Map(valueType)Key-value maps
Schemas.Optional(innerType)Nullable values
Schemas.Enum(enumType)Enum values

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 })
syncEntity vs MessageBus
  • syncEntity: state is persistent, late joiners get current state, automatic conflict resolution
  • MessageBus: fire-and-forget, late joiners miss past messages, good for transient effects
  • Combine both: use syncEntity for the door open/closed state, MessageBus for the sound effect when it opens

REST API Calls (fetch)

All 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)
  }
})

Signed Fetch (Authenticated Requests)

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)
  }
})

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)
})

Multiplayer Testing

Open multiple browser windows to test multiplayer locally. Each window is a separate player.

Offline Mode

For Decentraland Worlds that do not need multiplayer:

{
  "worldConfiguration": {
    "fixedAdapter": "offline:offline"
  }
}

Troubleshooting

ProblemCauseSolution
State not syncing between playersMissing syncEntity() callEvery entity you want shared must call syncEntity(entity, [ComponentId1, ComponentId2])
Sync ID collisionTwo entities share the same numeric sync IDUse an enum to assign unique IDs to every predefined synced entity
Entity disappears when creator leavesNo sync ID providedAdd a sync ID (third argument) to syncEntity() for entities that should persist
Date.now() values corruptedUsing Schemas.Number for timestampsUse Schemas.Int64 for any number over 13 digits (like Date.now())
State not ready on joinReading synced state before sync completesGuard with if (!isStateSyncronized()) return in your system
MessageBus messages lostLate joiner expecting past messagesMessageBus 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.

Important Notes

  • Entities must be explicitly synced via syncEntity(entity, [componentIds]) — pass the componentId of each component to sync
  • CRDT resolution: If two players change the same component simultaneously, last-write-wins
  • No server-side code: Decentraland scenes run entirely client-side with CRDT sync
  • Entity limits apply: Each synced entity counts toward the scene's entity budget
  • Custom schemas must be deterministic: Same component name = same schema across all clients
  • Use Schemas.Int64 for timestamps: Schemas.Number corrupts large numbers (13+ digits). Always use Schemas.Int64 for values like Date.now()
  • For server-authoritative multiplayer with validation and anti-cheat, see the authoritative-server skill

Comments

Markdown supported