# 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).

- Skill: `decentraland/multiplayer-sync` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add decentraland/multiplayer-sync`
- Raw SKILL.md: https://api.skillmd.com/api/skills/decentraland/multiplayer-sync/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: decentraland (https://skillmd.com/u/decentraland)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/decentraland/multiplayer-sync

---


# 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`). 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:

| 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:**

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 the information to be persisted even after all players leave, or to run secure validations on that information? --> `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

```typescript
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:

```typescript
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 `removeEntity` a fixed-ID synced entity and recreate it with the same ID in the SAME frame — the internal `NetworkEntity` survives until a later CRDT flush, so recreating immediately throws `id 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:

```typescript
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 no `syncEntity`. The exception is a `Billboard` with a `targetEntity`: because the target's position is scene state, all players see that billboard oriented the same way. Use `targetEntity` when you need a shared, consistent orientation (e.g. a sign that points at a shared object). See the `player-avatar` / `sdk-scenes` component reference for Billboard details.

## Custom Synced Components

Define custom components and sync them between players:

```typescript
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.Int64` for timestamps and other large numbers.** `Schemas.Number` / `Schemas.Int` corrupt values over 13 digits (like `Date.now()`) — always store such values in `Schemas.Int64` (as `lastUpdated` above 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:

```typescript
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:

```typescript
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),
})
```

## Parent-Child Sync Relationships

For synced entities with parent-child relationships, use `parentEntity()` instead of setting `Transform.parent`:

```typescript
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:

```typescript
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):

```typescript
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**: `MessageBus` is client-only — the headless server runtime does not implement the legacy comms event it relies on, and a module-scope `new MessageBus()` (as above) fails on the server with `RemoteError: not implemented`. In those scenes construct it only inside the client branch (`if (!isServer())`); see the `authoritative-server` skill.

### 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 scene
- `MessageBus`: 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.

```typescript
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

```typescript
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:

```typescript
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:

## 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:

```json
{
	"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?** `syncEntity` and `MessageBus` are 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`](https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/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: an `enum SyncId` giving two singletons stable IDs, `syncEntity(crateAnchorEntity, [AvatarAttach.componentId], SyncId.CRATE_ANCHOR)` and `syncEntity(crateEntity, [Transform.componentId], SyncId.CRATE)`, plus `parentEntity`/`removeParent` from `@dcl/sdk/network` to hand a shared crate between players. Any client may mutate it — there is no `validateBeforeChange` and no `isServer()` 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 with `validateBeforeChange` so 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 calls `syncEntity` on its own and there is no `validateBeforeChange`. See the **authoritative-server** skill for that scene's full breakdown.

