关于 multiplayer-sync
面向 Decentraland SDK7 开发者的多人同步指南,介绍如何在玩家之间共享场景状态或发送临时事件。它还比较外部 API 与 WebSocket 等方案,并说明自定义组件和连接状态的处理。
主要功能
- 01基于 CRDT 的状态同步
- 02syncEntity 同步实体与组件
- 03MessageBus 发送临时事件
- 04fetch、signedFetch 与 WebSocket 方案
- 05自定义同步组件与 Schema
- 06玩家身份和同步连接状态
适用场景
- 01让玩家共享门、开关或记分牌状态
- 02广播不需要持久化的声音或粒子事件
- 03将游戏数据保存到外部服务
- 04在实时游戏中与服务器双向通信
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ decentraland/ sdk-skills --skill multiplayer-sync安装后在 Claude Code 中输入/multiplayer-sync确认已加载。
附带文件references/networking-patterns.md
详细介绍
Decentraland 中的多人同步
Decentraland 会在玩家的 Explorer 实例中本地运行场景。默认情况下,玩家可以看到彼此并直接互动,但每位玩家与环境的交互都是独立的。默认情况下,环境中的变化不会在玩家之间共享。
要同步场景状态的任何变化,SDK7 使用基于 CRDT 的同步机制。
运行时限制: Decentraland 在 QuickJS 沙箱中运行。不支持 Node.js API(
fs、http、path、process)。请使用fetch()和WebSocket进行网络通信。有关异步模式,请参阅 scene-runtime 技能。
同步策略决策树
根据需求选择合适的网络方案:
| 策略 | 适用场景 | 持久性 | 示例 |
|---|---|---|---|
syncEntity | 所有玩家都能看到且会为新加入玩家保留的共享状态 | 是 — 状态会在玩家加入或离开后保留,但仅当场景中至少还有一位玩家时才会保留。场景一旦无人,状态就会重置 | 门、开关、记分板、电梯 |
MessageBus | 仅在当下有意义的临时事件 | 否 — 后来加入的玩家会错过之前的消息 | 聊天消息、音效、粒子触发器 |
fetch / REST API | 从外部服务读取或向其写入数据 | 取决于服务端 | 排行榜、库存、外部游戏状态 |
signedFetch | 需要证明玩家身份的经过身份验证的请求 | 取决于服务端 | 领取奖励、提交已验证的分数 |
WebSocket | 与服务端进行实时双向通信 | 取决于连接 | 实时游戏服务、实时聊天。 |
决策流程:
- 是否需要让每位玩家都看到相同状态,包括后来加入的玩家? -->
syncEntity - 是否是仅面向当前场景中玩家的即发即弃事件? -->
MessageBus - 是否需要在所有玩家离开后仍保留信息,或对该信息进行安全验证? -->
fetch或signedFetch - 是否需要与服务端持续进行实时通信? -->
WebSocket - 可以自由组合这些方案:使用
syncEntity同步世界状态,使用MessageBus处理效果,使用fetch实现持久化。
syncEntity 基础知识
导入和基本用法
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— 唯一的数字标识符(预定义实体必需,玩家生成的实体可选)
枚举同步 ID(预定义实体)
每个预定义的同步实体都必须有唯一的数字 ID。使用枚举可避免冲突:
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 根据创建者的对等端和其本地引擎实体编号确定身份(引擎会循环重用该编号)。在真实网络通信中,使用自动 ID 的单例同步实体若反复销毁并重建,就很脆弱:它可能无法在远程客户端上正确协调(这些客户端只会看到默认组件数据),即使它在本地单进程预览中运行完美。为任何单例或数量固定、广为人知的一组同步实体分配保留枚举中的稳定显式同步 ID。将自动 ID 留给真正动态、实例众多且创建后无需继续管理的实体。另外:绝不要在同一帧中对固定 ID 的同步实体调用
removeEntity后,又使用相同 ID 重建它 — 内部的NetworkEntity会一直保留到后续 CRDT 刷新,因此立即重建会抛出id provided is already in use;请延迟到后续刻重新生成。完整的故障特征、修复方法和乐观预测配套模式,请参阅{baseDir}/references/networking-patterns.md(syncEntity identity 部分)。
自动生成的 ID(玩家生成的实体)
玩家在运行时创建的实体不需要显式同步 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
}
有些视觉效果本质上是面向每位玩家的,而非共享的。
Billboard(面向摄像机)会在每个探索者客户端上本地重新计算,因此每位玩家看到的都是朝向自己的效果 — 它不会同步,也不需要syncEntity。例外情况是带有targetEntity的Billboard:由于目标的位置属于场景状态,所有玩家看到的该 Billboard 朝向都相同。若需要共享且一致的朝向(例如指向某个共享对象的标牌),请使用targetEntity。有关 Billboard 的详细信息,请参阅player-avatar/sdk-scenes组件参考。
自定义同步组件
定义自定义组件并在玩家之间同步:
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)。
**自定义架构必须具有确定性:**相同的组件名称在所有客户端上必须对应相同的架构。
玩家专属数据
使用 PlayerIdentityData 区分玩家:
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 类型
自定义组件可用的 schema 类型:
| 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) |
较少见的 schema 类型示例:
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),
})
@dcl/sdk 7.28.0 已修复的 Schema 问题——移除旧的变通方案
这四项都是真实存在的错误;如果你使用的是 7.28.0 或更高版本,可以直接编写显而易见的代码。
| Fix | Old broken behavior | Now |
|---|---|---|
Schemas.Optional(...) with falsy values (da82bfb0) | false、0 和 '' 会被视为未设置——不会写入,读回时为 undefined。Optional(Schemas.Boolean) 只能存储 true 或空值。 | 现在会检查值是否为 undefined/null,因此假值可以正常往返。Optional(Boolean) 现在是真正的三态。 |
Schemas.OneOf(...) left unselected (0dce4d2d) | create() 返回不含 $case 的 {};序列化时调用 $case.toString(),导致异常,并使整个场景的引擎 tick 停止。 | 未设置的情况会编码为索引 0(实际情况从 1 开始),并反序列化回 {}。场景从未设置的 OneOf 字段是安全的。 |
Components defined directly from a primitive or array schema (5ec1a8a1) | 每个访问器都会检查存储值是否为真值,因此持有 0、false 或 '' 的组件会被报告为缺失:get 抛出 "not found",getOrNull/deleteFrom 返回 null,getMutable 抛出异常,getOrCreateMutable 抛出 "already exists",而 create 会在已有假值的情况下静默成功。deepReadonly 也会展开非对象值,因此数字会变成 {},数组会变成以索引为键的对象。 | 现在会检查映射中是否存在该值,deepReadonly 会原样传递基本类型,并将数组复制为数组。 |
ReadonlyPrimitive narrowed (5ec1a8a1) | 包含数组类型,因此 DeepReadonly<number[]> 会解析回 number[]——.push() 能通过类型检查,却会在运行时抛出异常,因为数组已被冻结。 | ReadonlyPrimitive = number | string | boolean。现在,数组读取会通过 ReadonlyArray 分支解析:.push() 不再能通过类型检查。 修改前请先复制数组([...arr])。这是一次公共 API 变更;之前会修改读取所得组件数组的现有代码现在可能无法通过编译——这类代码在运行时本就无法正常工作。 |
同一版本中的相关变更:@dcl/sdk 玩家辅助工具中的 Player.wearables 和 Player.emotes 现在是副本,而非被冻结组件数组的别名,因此写入它们不再会抛出异常。
父子同步关系
对于具有父子关系的同步实体,请使用 parentEntity(),而不是设置 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)
连接状态
检查玩家是否已连接到同步房间:
import { isStateSyncronized } from '@dcl/sdk/network'
engine.addSystem(() => {
if (!isStateSyncronized()) return // wait for sync
// safe to read/write synced state
})
注意: SDK 中该函数拼写为 isStateSyncronized(不是“Synchronized”)。
MessageBus
在玩家之间发送自定义消息(即发即忘,不会持久保存):
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仅限客户端使用——无头服务器运行时未实现它所依赖的旧版通信事件,因此在服务器上执行模块级的new MessageBus()(如上所示)会失败,并报错RemoteError: not implemented。在这类场景中,仅在客户端分支(if (!isServer()))内创建它;请参阅authoritative-server技能。
syncEntity 与 MessageBus
syncEntity:新加入的玩家会获取当前状态,并自动解决冲突——CRDT 采用最后写入胜出,因此如果两名玩家同时修改同一个组件,最后写入的内容会胜出。只要场景中至少还有一名玩家,状态就会持续保留MessageBus:即发即忘,新加入的玩家无法收到过去的消息,适合用于瞬时效果
二进制 MessageBus(性能优化)
常规 MessageBus 会在发送前对每个载荷进行 JSON 编码。对于高频消息或大型载荷,有一种较低层级的二进制替代方案,可以直接发送原始 Uint8Array 数据——由于跳过了两端的 JSON 序列化,处理速度更快。这与 syncEntity 内部使用的传输方式相同。
适用于以下情况:
- 每秒发送大量消息(例如,连续的移动数据流、紧密循环中的粒子触发)
- 载荷大小很重要(二进制编码比 JSON 更紧凑)
- 已有二进制数据(例如,预编码缓冲区、CRDT 增量)
对于频率较低、易用性比性能更重要的事件,请继续使用常规 MessageBus。
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 载荷进行编码和解码(例如,使用 DataView、TextEncoder/TextDecoder 或 protobuf)。这里没有 on/emit/topic 层——sendBinary 一次调用即可发送待发送的消息并返回收到的消息,因此通常会在每个 tick 中由一个系统驱动它。
REST API 和签名 Fetch
如需与自己的后端通信(排行榜、游戏状态持久化、玩家身份验证),请使用 fetch 或 signedFetch。所有网络调用都必须在 executeTask 内运行。signedFetch 会附加玩家钱包身份的加密证明——如果服务器需要验证请求发起者的身份,请使用它。
完整的 fetch 和 signedFetch 用法请参阅 scene-runtime 技能。
WebSocket 连接
有关 WebSocket 的完整用法(重新连接、心跳、消息格式),请参阅 {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')
})
玩家进入/离开事件
检测玩家进入或离开场景:
import { onEnterScene, onLeaveScene } from '@dcl/sdk/src/players'
onEnterScene((player) => {
console.log('Player entered:', player.userId)
})
onLeaveScene((userId) => {
console.log('Player left:', userId)
})
SDK 可观察对象(低级)
SDK 还通过 @dcl/sdk/observables 暴露了更低级的可观察对象(onPlayerClickedObservable、onEnterSceneObservable、onLeaveSceneObservable、onRealmChangedObservable、onPlayerExpressionObservable、onProfileChangedObservable)。这些是上述 onEnterScene/onLeaveScene 助手函数所基于的底层原语。请留意近期修复的以下问题:
注意事项:远程玩家的 Transform 是世界坐标
本地玩家的 Transform 是场景局部坐标;其他玩家的实体报告的是世界坐标。比较坐标时请减去 basePos * 16(参见 player-avatar >“远程玩家的 Transform 使用世界坐标”)。任何距离检查、按距离排序的排行榜,或遍历 PlayerIdentityData 的跟随摄像机会受到此问题影响。
面向玩家的视觉效果不会自动共享
一些看起来支持多人游戏的组件是客户端本地的:在远程玩家的实体上写入这些组件,只会改变写入客户端看到的内容。它们不会被转发,而且 syncEntity 也无济于事——玩家实体由引擎拥有,并非由场景创建。
AvatarNametag(头像上方的等级/角色标牌,@dcl/sdk7.28.0+)是客户端本地的。如果希望每位玩家都能看到所有人相同的标牌,每个客户端都必须自行计算并写入整套标牌。移动端仍未实现此功能——此前曾列为将在 v1.14.0 中推出,但 2026 年 10 月的更新(文档提交5f212e3,移动应用 v1.15.0)又将其改回缺失,暂无预计时间。移动端玩家完全看不到标牌,因此绝不要让标牌成为玩家获取所需信息的唯一途径:应在TextShape、HUD 列表或头像已有的颜色中同步显示等级/角色。
有两种方法可以让客户端本地的视觉效果在各客户端保持一致:
- 根据每个客户端都已有的数据推导。例如,对所有
PlayerIdentityData地址排序,并为其编入固定名册的索引——地址列表相同,结果就会在每个客户端上相同,不受加入顺序影响。无需发送消息,也不会发生偏差。scenes/4,24-avatar-nametag使用了这种模式;完整的名册代码(差异检查、限流重新扫描)位于 player-avatarreferences/avatar-nametag.md。 - **复制输入,而非视觉效果。**同步分配结果(使用同步组件或
MessageBus消息),然后让每个客户端在本地将其应用于解析到该用户 ID 的玩家实体。
在这两种情况下:每次写入时,都要根据用户 ID 解析目标 Entity。玩家实体 ID 会在断开连接后循环复用,因此缓存的 Entity 可能会让写入落到另一位玩家身上。
多人游戏测试
每个连接到本地预览 Realm 的客户端都算作一位独立玩家——但桌面版 Explorer 一次只允许运行一个实例。在 Creator Hub 中再次按下 Preview,只会重新聚焦已打开的窗口,不会增加第二位玩家。启动第二个本地客户端有两种方法:
- 第二个桌面实例:在 Creator Hub 中打开 Play Options(Play 按钮上的下拉箭头),将鼠标悬停在 Desktop Client 行上以打开其弹出菜单,然后勾选 Multi-Instance Preview(需要
@dcl/sdk>= 7.20.4);或者运行npx sdk-commands start --multi-instance。然后再次按 Play/运行该命令。-n(“即使客户端已在运行,也打开一个新实例”)会强制打开一个新窗口。多实例模式要求每个窗口使用不同的账户进行身份验证,因此会停用 Skip Auth Screen——请预期每个实例都会显示身份验证界面,并在每个实例中使用不同的钱包登录。 - 桌面端 + 浏览器:保持桌面客户端打开,并将 Bevy Web 客户端作为玩家 2 添加——Play Options → 选择 Web (Bevy) 客户端行,或运行
npx sdk-commands start --web(别名--bevy-web),这会打开https://decentraland.org/bevy-web/?preview=true&realm=<local realm origin>(在--dclenv zone下为.zone)。浏览器标签页没有实例数量限制。
通过 decentraland:// 深层链接触发的 Preview(realm=…&position=…&dclenv=…&local-scene=true)同样受单实例规则限制,除非链接包含 open-deeplink-in-new-instance=true(来自 -n)或 multi-instance=true;请让 CLI 或 Creator Hub 生成链接,不要手动输入。
离线模式
对于不需要多人游戏的 Decentraland Worlds:
{
"worldConfiguration": {
"fixedAdapter": "offline:offline"
}
}
故障排查
| 问题 | 原因 | 解决方案 |
|---|---|---|
Profile not initialized. Call syncEntity inside main() | 在模块顶层调用了 syncEntity(例如在模块初始化器中) | 将所有 syncEntity 调用(以及依赖这些调用的实体创建)移到从 main() 调用的函数中。切勿在模块加载时调用 syncEntity。engine.addSystem() 也同样如此。 |
| 玩家之间的状态未同步 | 缺少 syncEntity() 调用 | 每个需要共享的实体都必须调用 syncEntity(entity, [ComponentId1, ComponentId2]) |
| 同步 ID 冲突 | 两个实体使用了相同的数字同步 ID | 使用枚举为每个预定义的同步实体分配唯一 ID |
| 加入时状态尚未就绪 | 同步完成前读取了同步状态 | 在系统中使用 if (!isStateSyncronized()) return 进行保护 |
| MessageBus 消息丢失 | 后加入的玩家期望收到过去的消息 | MessageBus 采用即发即弃模式。请使用 syncEntity 保存持久状态 |
需要保证一致性、服务端验证或反作弊功能?
syncEntity和MessageBus并非完全可靠——如果所有玩家都必须看到相同的状态变化,请参阅 权威服务端 技能,了解无头服务端模式。有关完整的竞技游戏架构(包含服务端距离验证反作弊、仅在检查点进行 Storage 持久化,以及按变化速率原子拆分组件),请参阅 Gem Rush 参考场景(92,-9-authoritative-server-gem-rush)。
示例场景
在真实引擎中测试过的引擎团队测试场景:
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/88,-13-avatar-masks — 无服务器
syncEntity,即本技能所介绍的模式:使用enum SyncId为两个单例分配稳定 ID,通过syncEntity(crateAnchorEntity, [AvatarAttach.componentId], SyncId.CRATE_ANCHOR)和syncEntity(crateEntity, [Transform.componentId], SyncId.CRATE),再使用@dcl/sdk/network中的parentEntity/removeParent,让玩家之间共享一个箱子。任何客户端都可以修改它——没有validateBeforeChange,也没有isServer()分支。(该场景的主要功能是表情面具;同步是实现该功能的辅助部分。)
作为对比,这组场景中的另一个多人游戏场景采用服务器权威模式——可以通过它了解该模式与本文所述的无服务器模式有何不同:
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/90,-9-authoritative-server-leaderboard — 权威模式(非无服务器):只有服务器调用
syncEntity,并通过validateBeforeChange锁定已同步的组件,因此客户端只能读取它们并发送消息。如果你希望任何客户端都能直接修改共享状态(即本技能所介绍的模式),则每个客户端都要自行调用syncEntity,并且不使用validateBeforeChange。该场景的完整说明请参阅 authoritative-server 技能。