mcpField
M
multiplayer-sync

multiplayer-sync

github.com/decentraland开发4295 次安装今天更新
代码仓库

关于 multiplayer-sync

面向 Decentraland SDK7 开发者的多人同步指南,介绍如何在玩家之间共享场景状态或发送临时事件。它还比较外部 API 与 WebSocket 等方案,并说明自定义组件和连接状态的处理。

主要功能

  1. 01基于 CRDT 的状态同步
  2. 02syncEntity 同步实体与组件
  3. 03MessageBus 发送临时事件
  4. 04fetch、signedFetch 与 WebSocket 方案
  5. 05自定义同步组件与 Schema
  6. 06玩家身份和同步连接状态

适用场景

  1. 01让玩家共享门、开关或记分牌状态
  2. 02广播不需要持久化的声音或粒子事件
  3. 03将游戏数据保存到外部服务
  4. 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与服务端进行实时双向通信取决于连接实时游戏服务、实时聊天。

决策流程:

  1. 是否需要让每位玩家都看到相同状态,包括后来加入的玩家? --> syncEntity
  2. 是否是仅面向当前场景中玩家的即发即弃事件? --> MessageBus
  3. 是否需要在所有玩家离开后仍保留信息,或对该信息进行安全验证? --> fetch 或 signedFetch
  4. 是否需要与服务端持续进行实时通信? --> WebSocket
  5. 可以自由组合这些方案:使用 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 类型:

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(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 或更高版本,可以直接编写显而易见的代码。

FixOld broken behaviorNow
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/sdk 7.28.0+)是客户端本地的。如果希望每位玩家都能看到所有人相同的标牌,每个客户端都必须自行计算并写入整套标牌。移动端仍未实现此功能——此前曾列为将在 v1.14.0 中推出,但 2026 年 10 月的更新(文档提交 5f212e3,移动应用 v1.15.0)又将其改回缺失,暂无预计时间。移动端玩家完全看不到标牌,因此绝不要让标牌成为玩家获取所需信息的唯一途径:应在 TextShape、HUD 列表或头像已有的颜色中同步显示等级/角色。

有两种方法可以让客户端本地的视觉效果在各客户端保持一致:

  1. 根据每个客户端都已有的数据推导。例如,对所有 PlayerIdentityData 地址排序,并为其编入固定名册的索引——地址列表相同,结果就会在每个客户端上相同,不受加入顺序影响。无需发送消息,也不会发生偏差。scenes/4,24-avatar-nametag 使用了这种模式;完整的名册代码(差异检查、限流重新扫描)位于 player-avatar references/avatar-nametag.md。
  2. **复制输入,而非视觉效果。**同步分配结果(使用同步组件或 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 技能。

评论

支持 Markdown