multiplayer-sync

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Multiplayer Synchronization in Decentraland

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.
Decentraland 会在玩家自己的 explorer 实例中本地运行场景。默认情况下,玩家能够看到彼此并直接互动,但每位玩家都独立与环境交互。环境中的更改默认不会在玩家之间共享。
要同步场景状态中的任何更改,SDK7 使用基于 CRDT 的同步。
运行时约束: Decentraland 在 QuickJS 沙箱中运行。不支持 Node.js APIs (
fs
,
http
,
path
,
process
)。使用
fetch()
和
WebSocket
进行网络通信。有关异步模式,请参阅 scene-runtime skill。

Sync Strategy Decision Tree

同步策略决策树

Choose the right networking approach based on what you need:
StrategyUse WhenPersistenceExample
syncEntity
Shared state that all players see and that persists for new arrivalsYes — 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 emptyDoors, switches, scoreboards, elevators
MessageBus
Ephemeral events that only matter in the momentNo — late joiners miss past messagesChat messages, sound effects, particle triggers
fetch
/ REST API
Reading or writing data to an external serverServer-dependentLeaderboards, inventory, external game state
signedFetch
Authenticated requests that prove player identityServer-dependentClaiming rewards, submitting verified scores
WebSocket
Real-time bidirectional communication with a serverConnection-dependentLive 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
所有玩家都能看到且需要为后来加入者保留的共享状态是——状态会在玩家加入或离开后继续存在,但仅在场景中至少还有一名玩家时有效。场景一旦变空,状态就会重置门、开关、记分板、电梯
MessageBus
只在当下有意义的临时事件否——后来加入者会错过之前的消息聊天消息、音效、粒子触发器
fetch
/ REST API
读取或写入外部服务器中的数据取决于服务器排行榜、物品栏、外部游戏状态
signedFetch
可证明玩家身份的已认证请求取决于服务器领取奖励、提交经验证的分数
WebSocket
与服务器进行实时双向通信取决于连接实时游戏服务器、实时聊天
决策流程:
  1. 每位玩家是否都需要看到相同的状态,包括后来加入者? -->
    syncEntity
  2. 是否只需向当前在场景中的玩家发送即发即弃事件? -->
    MessageBus
  3. 是否需要信息在所有玩家离开后仍持久保存,或需要对这些信息执行安全验证? -->
    fetch
    或
    signedFetch
  4. 是否需要与服务器持续进行实时通信? -->
    WebSocket
  5. 可以自由组合这些方式:使用
    syncEntity
    同步世界状态,使用
    MessageBus
    同步效果,并使用
    fetch
    进行持久化。

syncEntity Essentials

syncEntity 基础

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)
typescript
import { 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?)
  • entity
    — 要同步的实体
  • componentIds[]
    — 要保持同步的组件 ID 数组(例如
    [Transform.componentId]
    )
  • syncId
    — 唯一的数字标识符(预定义实体必填,玩家生成的实体可选)

Enum Sync IDs (Predefined Entities)

枚举同步 ID(预定义实体)

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.
每个预定义同步实体都必须有唯一的数字 ID。使用枚举可避免冲突:
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
)
预定义实体(带同步 ID)在创建者离开后仍会保留。玩家创建的实体(无同步 ID)会在玩家断开连接时被移除。
最佳实践——始终为单例实体分配稳定的同步 ID。 自动 ID 根据创建该实体的对等端及其本地 engine 实体编号生成(engine 会复用实体编号)。在真实网络通信中,反复销毁并使用自动 ID 重新创建的单例同步实体非常脆弱:它可能无法在远程客户端上完成协调(远程客户端只能看到默认组件数据),尽管它在本地单进程预览中运行完全正常。请使用保留枚举中的稳定显式同步 ID,为任何单例实体或少量固定且已知的同步实体分配 ID。自动 ID 仅应用于真正动态、数量众多、创建后即用即弃的实体。另外:切勿在同一帧中
removeEntity
固定 ID 的同步实体后,再使用相同 ID 重新创建——内部
NetworkEntity
会一直保留到后续 CRDT 刷新,因此立即重新创建会抛出
id provided is already in use
;应将重新生成操作推迟到后续 tick。有关完整的故障模式特征、修复方法和配套的乐观预测模式,请参阅
{baseDir}/references/networking-patterns.md
(syncEntity identity 部分)。

Auto-Generated IDs (Player-Spawned Entities)

自动生成的 ID(玩家生成的实体)

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.
运行时由玩家创建的实体无需显式同步 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
}
某些视觉效果本质上仅属于单个玩家,而非共享状态。
Billboard
(朝向相机)会在每个 explorer 中本地重新计算,因此每位玩家都会看到它朝向自己;它不会被同步,也不需要
syncEntity
。例外是带
targetEntity
的
Billboard
:由于目标的位置属于场景状态,所有玩家看到的该 billboard 朝向都一致。当需要共享且一致的朝向时(例如指向共享对象的标牌),请使用
targetEntity
。有关
Billboard
的详细信息,请参阅
player-avatar
/
sdk-scenes
组件参考。

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.
定义自定义组件并在玩家之间同步:
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()
}
时间戳和其他大数应使用
Schemas.Int64
。
Schemas.Number
/
Schemas.Int
会使超过 13 位的数值(例如
Date.now()
)失真——始终使用
Schemas.Int64
存储此类值(如上面的
lastUpdated
)。
自定义 schema 必须具有确定性: 所有客户端中,同一组件名称必须映射到同一 schema。

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)
	}
})
使用
PlayerIdentityData
区分玩家:
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

Schema 类型

Available schema types for custom components:
TypeUsage
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),
})
自定义组件可用的 schema 类型:
类型用途
Schemas.Boolean
true/false
Schemas.Int
整数
Schemas.Float
小数
Schemas.String
文本字符串
Schemas.Int64
大整数(时间戳)
Schemas.Vector3
3D 坐标
Schemas.Quaternion
旋转
Schemas.Color3
RGB 颜色
Schemas.Color4
RGBA 颜色
Schemas.Entity
实体引用
Schemas.Array(innerType)
数组值
Schemas.Map(spec, default?)
嵌套结构——
spec
是命名字段(
{ x: Schemas.Int, ... }
),而不是同构的键→值映射
Schemas.Optional(innerType)
可空值
Schemas.EnumNumber(enumObj, default)
数字枚举;
default
必填(例如
Schemas.EnumNumber(State, State.Lobby)
)
Schemas.EnumString(enumObj, default)
字符串枚举;
default
必填
Schemas.OneOf({ ... })
判别联合(
$case
+ payload)
较不常见的 schema 类型示例:
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)
对于具有父子关系的同步实体,请使用
parentEntity()
,而不是设置
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.

检查玩家是否已连接到同步房间:
typescript
import { isStateSyncronized } from '@dcl/sdk/network'

engine.addSystem(() => {
	if (!isStateSyncronized()) return // wait for sync
	// safe to read/write synced state
})
注意: 该函数在 SDK 中的拼写是
isStateSyncronized
(而不是 “Synchronized”)。

MessageBus

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.
在玩家之间发送自定义消息(即发即弃,不持久化):
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 })
服务器权威型场景:
MessageBus
仅支持客户端——无头服务器运行时未实现其所依赖的旧版 comms 事件,因此如上所示在模块作用域中执行
new MessageBus()
会在服务器上失败并报错
RemoteError: not implemented
。在这些场景中,只应在客户端分支(
if (!isServer())
)内创建它;参阅
authoritative-server
skill。

syncEntity vs MessageBus

syncEntity 与 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
  • syncEntity
    :后来加入者会获得当前状态,并自动解决冲突——CRDT 采用最后写入优先(last-write-wins),因此如果两名玩家同时修改同一组件,则最后一次写入获胜。只要场景中至少还有一名玩家,状态就会持续存在
  • MessageBus
    :即发即弃,后来加入者会错过过去的消息,适合瞬时效果

Binary MessageBus (Performance Optimization)

二进制 MessageBus(性能优化)

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.

常规
MessageBus
会在发送前将每个 payload 编码为 JSON。对于高频消息或大型 payload,可以使用更底层的二进制替代方案,直接发送原始
Uint8Array
数据——由于两端都省去了 JSON 序列化,因此处理速度更快。这与
syncEntity
内部使用的传输机制相同。
在以下情况下使用:
  • 每秒发送大量消息(例如在紧凑循环中持续发送移动流、粒子触发器)
  • payload 大小很重要(二进制编码比 JSON 更紧凑)
  • 已有二进制数据(例如预先编码的缓冲区、CRDT 增量)
对于低频事件,如果易用性比性能更重要,请使用常规
MessageBus
。
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)
	}
})
你需要自行负责编码和解码
Uint8Array
payload(例如使用
DataView
、
TextEncoder
/
TextDecoder
或 protobuf)。它没有 on/emit/topic 抽象层——
sendBinary
是一次调用,既会发送待发消息,也会返回收到的消息,因此通常应在每个 tick 中由 system 驱动它。

REST API and Signed Fetch

REST API 与 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.

如需与自己的后端通信(排行榜、游戏状态持久化、玩家身份验证),请使用
fetch
或
signedFetch
。所有网络调用都必须在
executeTask
中运行。
signedFetch
会附加玩家钱包身份的加密证明——当服务器需要验证请求者身份时,请使用它。
有关完整的
fetch
和
signedFetch
模式,请参阅 scene-runtime skill。

WebSocket Connections

WebSocket 连接

For full WebSocket patterns (reconnection, heartbeat, message format), see
{baseDir}/references/networking-patterns.md
.
有关完整的 WebSocket 模式(重连、心跳、消息格式),请参阅
{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')
})

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)
})
检测玩家进入或离开场景:
typescript
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:
json
{
	"worldConfiguration": {
		"fixedAdapter": "offline:offline"
	}
}
对于不需要多人游戏的 Decentraland World:
json
{
	"worldConfiguration": {
		"fixedAdapter": "offline:offline"
	}
}

Troubleshooting

故障排除

ProblemCauseSolution
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 playersMissing
syncEntity()
call
Every 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
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 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
).
问题原因解决方案
Profile not initialized. Call syncEntity inside main()
在模块顶层调用了
syncEntity
(例如在模块初始化器中)
将所有
syncEntity
调用(以及依赖这些调用的实体创建)移入由
main()
调用的函数中。切勿在模块加载时调用
syncEntity
。
engine.addSystem()
同理。
玩家之间的状态未同步缺少
syncEntity()
调用
每个希望共享的实体都必须调用
syncEntity(entity, [ComponentId1, ComponentId2])
同步 ID 冲突两个实体共享同一个数字同步 ID使用枚举为每个预定义同步实体分配唯一 ID
加入时状态尚未就绪在同步完成前读取同步状态在 system 中使用
if (!isStateSyncronized()) return
进行保护
MessageBus 消息丢失后来加入者期待收到过去的消息MessageBus 是即发即弃的。持久状态请使用
syncEntity
需要保证一致性、服务端验证或反作弊?
syncEntity
和
MessageBus
并非完全可靠——如果必须确保所有玩家看到相同的状态更改,请参阅 authoritative-server skill,了解无头服务器模式。若需要完整的竞技游戏架构(包含带服务端邻近验证的反作弊、仅检查点 Storage 持久化、按变化速率进行原子组件拆分),请参阅 Gem Rush 参考场景(
92,-9-authoritative-server-gem-rush
)。

Example scenes

示例场景

No serverless
syncEntity
/
MessageBus
reference scene is available in the engine-team test set yet. For contrast, the closest multiplayer scene is server-authoritative (use it to see how the authoritative 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.
引擎团队测试集中目前还没有无服务器的
syncEntity
/
MessageBus
参考场景。作为对比,最接近的多人场景采用服务器权威模式(可参考它了解权威模式与本文所述无服务器模式有何差异):
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/90,-9-authoritative-server-leaderboard — 服务器权威型(非无服务器):只有服务器调用
    syncEntity
    ,并使用
    validateBeforeChange
    锁定同步组件,因此客户端只能读取这些组件并发送消息。如果希望任何客户端直接修改共享状态(也就是本 skill 记录的模式),则每个客户端都对自己的实体调用
    syncEntity
    ,且不使用
    validateBeforeChange
    。有关该场景的完整详解,请参阅 authoritative-server skill。