多人服务器
使用无头多人服务器构建多人 Decentraland 场景。
📔 注意: 多人服务器之前被称为 权威服务器。只是名称变了,功能没变。要安装的 SDK 分支仍然名为 auth-server,见 设置.
概览
Decentraland 在玩家的机器上本地运行场景。默认情况下,玩家可以彼此看到并直接交互,但每个人都独立地与环境交互。默认情况下,环境中的更改不会在玩家之间共享。
让所有玩家都看到场景具有相同内容和相同状态,对玩家以更有意义的方式进行交互至关重要。否则,如果一名玩家打开了一扇门并走进房子,其他玩家仍会看到那扇门是关闭的,而第一个玩家在其他玩家眼中就像是直接穿过了关闭的门。
该 多人服务器 是一个无头服务器进程,用于运行你的场景代码、验证状态更改,并将结果广播给所有已连接的玩家。它采用权威服务器架构:不是信任每个客户端上报自己的操作,而是由服务器作为唯一真实来源。这使它成为同步多人场景的推荐方案。
只要游戏机制中公平性很重要,多人服务器就是理想选择,因为你可以实现在服务器端运行的复杂反作弊校验。你还可以把私钥和其他敏感信息存储在服务器上,避免将它们直接暴露给用户。
拥有多人服务器也能解决一个实际问题:在点对点架构中,两个玩家同时控制像悬浮平台这样的物体,可能会产生冲突结果。每个客户端都会把平台设到不同的高度,而没有任何一方有权决定哪个是正确的。多人服务器把所有更改集中到一个地方处理,因此所有客户端都会收敛到同一个状态。
它还为你提供了一个地方来 在不同会话之间持久保存数据:排行榜、玩家进度、已解锁成就,或者环境变化,比如门被打开或物品被放置。玩家回来时,世界会反映之前发生过的事情。
Decentraland 会为你托管并部署服务器。通过正常流程发布你的场景时,也会无缝发布服务器,无需额外步骤,也不需要支付任何托管费用。
设置
1. 安装 auth-server 版 SDK
原生多人服务器 API(isServer, registerMessages, 存储, EnvVar等)都可在单独的 SDK 分支中使用。运行以下命令,将其安装到你的项目中,而不是标准 SDK 分支:
npm install @dcl/sdk@auth-server
npm install @dcl/js-runtime@auth-server2. 配置 scene.json
多人服务器由标志位 "authoritativeMultiplayer": true 在 scene.json在根级别启用。你不需要手动添加它:使用 SDK 的 auth-server 分支时,在你首次构建或预览场景时它会自动添加。只要确保不要把它删掉,因为没有这个标志位服务器永远不会运行,而且 isServer() 总是返回 false.
另外,你也可以选择将以下内容添加到你的 scene.json 根级别:
添加 logsPermissions 以列出可以查看 console.log() 服务器日志的钱包地址。列出的用户随后可以通过运行以下命令在生产环境中查看服务器日志:
npx sdk-commands sdk-server-logs
3. 运行预览
使用标准的预览命令即可,无需额外步骤。使用 SDK 的 auth-server 分支时,预览会自动在后台启动一个本地版多人服务器。
服务器的本地会话不会连接到生产环境中的实例,因此你可以自由测试,而不会影响已经在你已发布场景中的玩家。
服务器 / 客户端分支
你项目中的场景代码在 src 文件夹中同时在服务器和客户端运行。使用 isServer() 函数来分离执行路径:
服务器以无渲染的无头方式运行你的场景。它通过以下方式对所有玩家位置、穿戴物品和其他数据拥有已验证的访问权限: PlayerIdentityData 并且是游戏状态的唯一权威。
同步组件与验证
将实体同步给所有客户端
使用 syncEntity 以广播该实体指定组件中的任何更改:
语法与以下功能所使用的语法完全相同: 无服务器多人游戏 功能,因此把场景从这种架构升级到多人服务器非常简单。当场景使用多人服务器时,状态更新不再在所有玩家之间直接发送,而是全部通过服务器进行路由和验证。
📔 注意:使用多人服务器时,理想模式是只让服务器调用 syncEntity。这样你就不用担心 entity-id 一致性。相反,实体由服务器实例化并共享,所有客户端都会同步到该实例。始终用 isServer()来保护它。这与 无服务器多人游戏不同,在那里每个客户端都会调用 syncEntity ,由各自独立执行。
验证更改
使用 validateBeforeChange() 来限制实体某个特定组件中的任何状态更新。它允许你运行自定义验证函数,并且只有在验证测试通过时更改才会成功。
如果验证返回值 真,则更改会被接受并传播给所有玩家。如果验证返回值 false,则更改会被拒绝。被拒绝的更改不会传递给其他玩家,并且会对尝试进行更改的玩家回滚。
验证值
最简单的情况是验证新的 值 正在写入的值是否在某些参数范围内。例如,只接受对 Transform 的更改,前提是新的 Y 坐标大于 0:
因为 validateBeforeChange() 只在服务器上才有意义,因此始终用 isServer()来保护它。在客户端调用该函数不会产生任何有用作用。
你可以利用这一点阻止违反游戏逻辑的更改,作为反作弊机制。
验证与玩家的距离
你可以将 validateBeforeChange() 结合服务器已验证的玩家位置,在允许玩家与物体交互之前检查他们是否足够靠近该物体。例如,当玩家试图通过更改其 Transform来拾取一个物体时,如果物体距离玩家超过 5 米,你可以拒绝该更改:
这种模式作为反作弊机制很有用:它阻止玩家跨越场景去拿取他们本不该能够交互的物体。
只允许管理员进行更改
你也可以基于 谁 发送了更改。每个传入值都包含一个 senderAddress 字段,其中包含发送者的钱包地址。利用它只允许某些玩家进行更改。例如,只允许场景管理员修改某个 VideoPlayer 组件:
参见 场景管理员 以了解玩家如何在场景中成为管理员的更多背景。
只允许服务器进行更改
最严格的情况是只接受来自服务器本身的写入,拒绝任何来自客户端的更改。这是适用于应完全由服务器权威控制的状态的首选模式:分数、游戏阶段、生成的实体等。
每个传入值都包含一个 senderAddress 字段。当发送者是服务器时,该字段与常量 AUTH_SERVER_PEER_ID,它导出自 @dcl/sdk/network/message-bus-sync.
下面的示例定义了一个小型 protectServerEntity() 辅助函数,它会将此检查应用于给定实体的一个或多个组件。这是保护多个组件(例如 Transform 和 GltfContainer)的便捷方式,只需一次调用:
📔 注意:始终调用 protectServerEntity() 在一个 isServer() 代码块中。它包装了 validateBeforeChange(),它只在服务器上才有意义——在客户端调用会导致错误。
自定义组件
你也可以将 validateBeforeChange() 应用于场景定义的自定义组件。
消息
同步组件非常适合所有玩家都应持续看到的状态:例如位置、分数或游戏阶段。但并非所有内容都适合这种模型。有时,玩家只需要告诉服务器“我点击了这个按钮”或“我想加入游戏”,而服务器需要回复一次性响应,比如“回合开始了”或“这是你的统计数据”。这些属于事件,而不是持续状态。
这就是消息的用途。使用 registerMessages() 在客户端和服务器之间进行带类型、经 schema 验证的通信。消息属于一次发送即忘:客户端把消息发给服务器,服务器处理后可选择回复一条。它们本身不会直接创建任何持久状态。
定义消息
在服务器和客户端都会导入的共享文件中定义所有消息。这样双方始终对有哪些消息以及它们携带什么数据达成一致。每个消息都是一个 Schemas.Map ,用于描述其载荷:
📔 注意:调用 registerMessages() 一次,在共享模块的顶层调用,就像上面的示例一样。这样它会在模块首次加载时于服务器和客户端两边都运行。不要有条件地调用它,也不要放在函数内部,双方必须始终注册相同的消息。
发送消息
客户端只能向服务器发送消息,不存在客户端到客户端的直接消息传递。服务器可以向所有客户端广播,也可以通过地址针对特定玩家。
📔 注意:消息的最大大小约为 13 KB。更大的消息会被传输层静默丢弃,不会产生任何错误。请保持载荷较小;如果需要共享更大的数据,请把它拆分成多条消息,或者使用同步组件。
接收消息
在服务器上,每条收到的消息都包含一个 context 对象,其中包含发送者已验证的钱包地址。利用它来知道是哪位玩家发送了消息(绝不要依赖载荷中自报的身份)。
在发送前等待状态同步
客户端应在发送第一条消息之前等待场景状态同步完成,以避免加入时的竞争条件:
可用的 Schema 类型
所有消息载荷和自定义组件都使用 Schemas 进行二进制序列化。以下是可用类型的快速参考:
📔 注意:消息 必须 使用 Schemas.Map(...)进行定义。你不能发送普通的 JavaScript 对象,它们会在二进制序列化时失败。
服务器读取玩家位置
服务器可以读取 已验证的 玩家位置;客户端无法伪造这些数据。这是基于位置的反作弊基础:
📔 注意:始终使用 PlayerIdentityData + Transform 服务器端获取玩家位置。绝不要信任客户端自身报告的数值。
数据存储
在服务器重启后持久保存数据。Storage 是 仅限服务器,因此始终用 isServer()来保护调用。服务器既可以写入也可以读取这些数据。
数据可以存储在两个层级:
世界:用于与所有玩家都相关的数据,例如排行榜或持久化的环境更改。
玩家:用于玩家特定数据,例如保存该玩家的进度或偏好。
📔 注意: Storage.set(), Storage.player.set(),以及 删除 变体会解析为一个 布尔值。它们绝不会抛出异常——在失败时(网络错误,或者并发请求过多,见下文)它们会记录错误并解析为 false,表示该值已 不是 持久化。务必检查结果:被丢弃的 false 就是一次静默丢失的保存。
在检查点保存,不要在每次变更时都保存
Storage 是用于必须在服务器重启和重新部署后仍然保留的数据的持久存储。它不是实时数据存储。把你的运行中游戏状态保存在服务器内存里。那样更快,也更符合服务器的正确模式。只有在真正需要的时候、在有意义的检查点上,才写入 Storage。
⚠️ 警告:服务器运行时最多允许 40 个进行中的主机调用 同时存在,并且由 所有内容 场景请求运行时执行的操作:每次存储请求, signedFetch,以及其他运行时 API 都会计入同一个限制。超出的调用 不会排队。它们会立即以 并发主机调用过多 错误被拒绝。SDK 会捕获该拒绝并将 Storage.set promise 解析为 false 而不是抛出异常。如果你的代码丢弃了那个布尔值,失败的保存就会变得不可见,而你持久化的数据最终会过时或丢失。请检查每一次写入的结果(参见上面的注释)。
适合持久化的时机:
游戏结束,或者一局结束时。
玩家离开时。
定期去抖保存(例如每 30 秒一次),而不是每帧一次。
只持久化必须在重启或重新部署后仍然存在的数据。其他一切都可以保存在内存中。
下面的示例将分数保存在内存中,只在检查点写入,而不是每得一分都写入:
对于有大量并发玩家的场景,更稳健的方法是跟踪哪些键有未保存的更改(一个“脏集合”),并在下一次刷新时重试失败的写入:
世界存储 — 所有玩家共享
你也可以通过命令行管理场景存储,使用 npx sdk-commands storage scene:
玩家存储 — 按钱包地址区分
你也可以通过命令行管理玩家存储,使用 npx sdk-commands storage player:
访问已存储的数据
你可以通过存储界面查看和编辑服务器上实时存储的数据,输入此链接:
你也可以通过 Creator Hub 访问此页面。打开 管理 选项卡,点击你已发布内容的地点旁边的三个点,然后选择 查看存储.
在那里你可以看到所有可发布场景的世界和地块列表。
打开你的场景,然后打开 场景 或 玩家 选项卡中。
在 场景 选项卡,你会看到所有已存储变量的列表。你可以在这里通过点击铅笔或垃圾桶图标来编辑或移除这些变量中的任意一个。

在 玩家 选项卡,你会看到所有在你的服务器上存有任何数据的玩家列表。你可以按地址或名称搜索他们,然后查看其关联的全部数据。你也可以通过点击铅笔或垃圾桶图标来编辑或移除这些数据。
更改数据结构
生产环境中的存储数据 在你发布场景新版本时不会被清除。这对于排行榜、玩家进度以及玩家期望在场景小更新之后仍然存在的持久环境变化非常有用。
另一方面,存储中的数据是由你代码的旧版本写入的。如果你的新代码期望不同的结构,那么解析或读取这些旧数据时可能会以微妙的方式失败。你重命名过的字段会缺失。过去是字符串、现在变成对象的字段,在你尝试访问其属性时会抛出异常。一个几个月都没登录过的玩家,可能会加载一个早于你代码已不再知道如何处理的结构的数据。
📔 注意:模式变更不会只影响部署后的第一次读取。存储数据会一直存在,直到被覆盖或删除,因此旧格式的值可能在任何时候出现,通常来自你早已忘记的回归玩家。
最佳实践
始终采用防御性解析。即使是你写入的,也要把任何从存储中取出的内容视为不可信输入。将
JSON.parse()包裹在一个try/catch中,在读取字段之前检查它们是否存在,并在它们不存在时准备一个合理的默认值:添加字段,不要重命名或删除字段。最安全的模式更改是增量式的:引入一个带默认值的新字段,并保持现有字段不变。旧数据只是不会包含新字段,而你的防御性解析已经能处理这种情况。重命名字段会让每一条旧记录都失效。
为你存储的对象添加版本。从第一天起就包含一个
version字段。在读取数据时,根据版本分支处理,并在使用之前把旧结构迁移到当前结构。这样其余代码就能始终使用单一的当前结构:将迁移后的值写回。一旦你在内存中升级了某条记录,就把它写回去,这样下一次读取时它已经是新格式。随着时间推移,这会逐步清空旧结构记录,而无需一次性迁移脚本。
对于破坏性更改,请使用新键。如果新结构确实不兼容,而且迁移不值得,那么就写入一个新的存储键(例如
progress_v2),并忽略旧键。旧键会安然留在存储中,而你也避免了任何必须解释它的读取路径。你可以稍后通过 存储界面 或npx sdk-commands storage命令来清理旧键。使用真实生产数据进行测试。在部署结构性更改之前,从存储界面中拉取几条真实记录,并用你的新解析代码对它们运行测试。真正会出问题的边角案例,通常都是你根本不知道存在的记录。
保留应急出口。请记住,你可以通过存储界面或通过
npx sdk-commands storage编辑或删除单条记录。如果某个玩家在模式更改后卡在了坏状态里,你可以直接修复他们的记录,而无需重新部署。
环境变量
在不把值硬编码进代码的情况下配置你的场景。环境变量适合存放敏感数据,也适合存放功能开关或参数,这些内容无需重新发布场景即可轻松更改。
环境变量是 仅限服务器。使用 isServer()进行保护。服务器可以读取环境变量,但不能修改它们的值。
EnvVar.get() 返回一个 Promise<string> ,在变量未设置时会解析为空字符串,因此始终要为缺失值提供回退:
敏感数据
环境变量对于存放私钥、奖励兑换码以及其他敏感数据尤其有用,因为把这些内容暴露在公开场景的编译代码里会很危险。
你可以把私钥存储在服务器的存储中,并且只让服务器通过 isServer()来读取它们。这样敏感数据就永远不会经过玩家的机器。
本地开发
要在本地运行项目时使用环境变量,请在项目根目录创建一个 .env 文件:
重要:将 .env 添加到你的 .gitignore,这样这些可能敏感的值就永远不会被上传到公共内容服务器。
更改环境变量
更改环境变量值的最简单方式是通过存储界面。
你可以通过输入此链接来访问由场景存储保存的数据:
你也可以通过 Creator Hub 访问此页面。打开 管理 选项卡,点击你已发布内容的地点旁边的三个点,然后选择 查看存储.
在那里你可以看到所有可发布场景的世界和地块列表。
打开你的场景,然后打开 环境 选项卡。你应该会看到项目中的所有环境变量。

请注意,你无法读取这些环境变量中的任何值(这是为了保护敏感数据),但你可以删除或覆盖其中任何一个。只需点击铅笔或垃圾桶图标即可。
你也可以通过命令行管理环境变量,使用 npx sdk-commands storage env:
你也可以通过 --target 标志来指定特定环境:
已部署的环境变量优先于 .env 取值列表。
推荐的项目结构
将服务器、客户端和共享代码分离,可以让代码库随着增长仍保持可读:
性能最佳实践
每次组件更改都会发送 整个 组件数据到网络上。这与 Colyseus 的做法不同,后者只发送差异。在设计自定义组件时请记住这一点。最优方案可能是根据变更频率,把数据存放在不同组件中。
❌ 避免单体组件
✅ 优先使用原子化组件
经验法则:把一起变化且频率相近的字段分组。将变化快的数据(计时器、位置)与变化慢的数据(分数、配置)分开。
对高频消息进行限流
避免每帧都发送消息。尽可能批量处理或限流:
例如,如果服务器控制一个倒计时器,就没有必要每秒向所有玩家发送更新。最好让每个客户端自己计算时间流逝,然后服务器每约 30 秒广播一次当前状态,以确保一致性。
服务器资源限制
多人服务器会在一个沙箱化隔离环境中运行每个场景,并施加严格的资源上限。触及这些限制可能会静默丢失数据,或者让场景中的所有人服务器终止,因此请让你的场景设计远低于这些上限。
内存
该隔离环境有一个 256 MB 内存上限。如果超出,隔离环境会被释放,服务器会对所有已连接玩家关闭。请保持工作状态精简,并在玩家离开时清理每个玩家的数据。
CPU
每个执行轮次都有一个墙钟时间预算:
同步执行:每轮 10 秒。超出这个时间的无限循环会让隔离环境被终止。
异步轮次完成:60 秒。如果一个
Promise链或await解析所需时间超过这个限制,隔离环境就会被终止。
使用一个 dt 中的累加器把重工作分散到多个 tick 中, engine.addSystem()。绝不要在服务器上运行无限制的同步循环。
入站消息速率
每个已连接对等端大约最多可发送 每 1,000 ms 300 条消息。超出的数据帧会被丢弃(不会排队)。绝不要从客户端在每帧都发送消息。参见 性能最佳实践 了解限流模式。
消息大小
入站包大小上限为 128 KB 每个包。过大的包会被直接丢弃。
场景到通信的消息大小上限约为 30 KB。在实际传输层中,请将同步消息控制在远低于 13 KB (见 消息 部分进一步说明)。
外部获取
并发 signedFetch 调用上限为 32 个进行中。额外的 fetch 会排队,直到有一个槽位空出。每次 fetch 尝试都有 15 秒 超时。Fetch 响应大小上限为 10 MB。WebSocket 连接数量限制为 32 个并发 socket,单条消息最大大小为 1 MB 每条消息。
进行中的主机调用
中描述的 40 次调用上限 数据存储 适用于整个 isolate 的所有宿主调用,包括 Storage、 signedFetch以及其他运行时 API。超出调用会立即被拒绝,不会排队。
常见陷阱
忘记对仅服务器状态进行校验
如果没有 validateBeforeChange,客户端可以向任何组件写入:
信任客户端提供的值
永远不要让客户端为生命值、得分或位置等重要数据决定其自身的值:
在状态同步之前发送消息
客户端必须等到状态同步完成后才能交互:
等待服务器启动
服务器仅在场景中至少有一名玩家时才处于活动状态。最后一名玩家离开后,服务器会继续运行大约两分钟,然后关闭。下一次访问会冷启动一个新实例,这大约需要 生产环境下 15 秒。本地预览会立即启动服务器,因此冷启动问题几乎总是在发布后才会显现。
你的场景代码应准备好等待服务器上线。服务器启动完成之前发送的消息会被静默丢失。等待期间向玩家显示“服务器正在启动”消息,并为初始服务器请求添加重试逻辑。
完整示例
一个最小的多人计数器:点击按钮,服务器会递增一个同步分数。服务器会将计数器持久化到 存储 ,这样该值在服务器重启后仍然保留。请记住,当场景中没有玩家时服务器会关闭,因此如果没有存储,只要场景里没人,计数就会每次重置为零。
本地测试
标准预览会处理一切。当使用 SDK 的 auth-server 分支时,本地服务器会在客户端预览旁边的后台自动启动。
要在本地测试多人交互,请在两个独立窗口中打开预览,每个窗口都会被视为一个独立玩家。使用不同地址连接每个窗口。两个客户端都会连接到同一个本地服务器实例。
使用 Creator Hub,再次点击“预览”按钮,它会打开第二个 Decentraland explorer 窗口。你必须在两个窗口中使用不同的地址连接。即使场景重新加载,两个会话也会保持打开。
作为替代方案,你可以在浏览器 URL 中输入以下内容,以打开第二个 Decentraland explorer 窗口:
decentraland://realm=http://127.0.0.1:8000&local-scene=true&debug=true&multi-instance=true
调试提示
为日志添加前缀 中调用你的 UI 渲染方法,使用
[SERVER]或[CLIENT],这样你就能在终端中区分它们:验证组件同步 在客户端通过记录实体数量:
在生产环境中调试
要查看 console.log() 已发布服务器的输出,你的钱包地址必须列在 logsPermissions 数组里 scene.json:
中。否则,即使场景所有者也看不到生产环境中的服务器日志。
在项目文件夹中运行以下命令,即可从命令行实时流式查看服务器日志
你还可以通过以下方式手动指定日志的世界名称:
在查看多场景世界中的某个场景或 Genesis City 中的地块日志时,还要传入一个 位置 用于坐标的
系统会提示你使用 logsPermissions 中列出的某个钱包签名一条消息 console.log() 进行身份验证。连接后,你将实时看到服务器端
输出,这对于无需重新部署即可诊断问题非常有用。
你可以通过输入此链接来访问由场景存储保存的数据:
你也可以通过 Creator Hub 访问此页面。打开 管理 选项卡,点击你已发布内容的地点旁边的三个点,然后选择 查看存储.
在那里你可以看到所有可发布场景的世界和地块列表。
查看存储数据
打开世界或玩家数据,查看为每个对象存储的信息。例如,如果某个玩家在游玩你的场景时遇到问题,你可以通过地址查找这个玩家,并查看为其存储了哪些数据,以了解他们的情况。也许他们遇到了一个边缘情况,导致数据自相矛盾。你甚至可以在此页面上清除或编辑该玩家的数据,把他们恢复到稳定状态。
版本控制
场景的每个已发布版本都会获得自己独特的哈希 ID,并且每个哈希都对应自己的服务器实例。这意味着客户端代码和服务器代码始终同步更新,不会出现旧逻辑的客户端与新逻辑的服务器通信(反之亦然)的窗口期。
当你发布更新时:
场景中已存在的玩家 在他们离开并回来之前,会继续看到旧版本的场景。他们的客户端会保持连接到与旧哈希匹配的服务器实例。
新到达的玩家 在更新后会加载新场景版本并连接到新的服务器实例。
这保证了由于 schema 更改或组件重命名导致的客户端和服务器状态永远不会失去同步。更新绝不会破坏已经在你场景中的玩家会话。
代价是,在部署后的短时间窗口内,玩家可能会分散到两个不同的服务器实例上。已经在场景中的玩家和刚到达的玩家可能彼此看不到,也无法通过场景交互,即使他们在同一个场景中,直到较早的玩家离开并重新加入。
从 Colyseus 迁移
如果你已有基于 Colyseus 构建的场景,下表会将常见的 Colyseus 模式映射到它们在 SDK7 中的等价写法:
room.send(type, data)
room.send(type, data) — 相同 API
room.onMessage(type, cb)
room.onMessage(type, cb) — 相同 API
room.state.players (schema)
syncEntity + 自定义组件
JSON 序列化
二进制序列化(自动通过 Schemas)
独立的服务器应用
同一代码库—— isServer() 分支
自定义服务器托管
内置:预览会自动运行服务器
需要牢记的关键差异:
序列化:Colyseus 发送 JSON diff;SDK 在每次更改时发送整个组件。保持组件尽可能小(见 性能最佳实践).
状态模型:Colyseus 使用带自动 diff 的可变状态树。SDK 使用通过
syncEntity同步并受validateBeforeChange.托管:无需单独部署服务器。多人服务器会与场景一起自动部署。
最后更新于