关于 authoritative-server
用于开发 Decentraland 场景的技能,由无头服务器控制共享游戏状态并验证客户端变更。内容涵盖服务端与客户端逻辑、同步实体、消息、存储和反作弊检查。
主要功能
- 01配置权威无头服务器
- 02服务端状态验证
- 03客户端与服务端消息模式
- 04持久化场景及玩家数据
- 05验证玩家位置
适用场景
- 01构建 Decentraland 多人游戏
- 02验证共享组件的写入
- 03在客户端与服务端间发送消息
- 04在重启后保留游戏数据
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ decentraland/ sdk-skills --skill authoritative-server安装后在 Claude Code 中输入/authoritative-server确认已加载。
附带文件references/auth-server-examples.mdreferences/server-patterns.md
详细介绍
多人游戏服务端
📔 注意:多人游戏服务端以前称为权威服务端。只有名称变了,功能不变。要安装的 SDK 分支仍名为 auth-server。
重要:在将其添加到场景之前,务必告知用户并询问他们是否要继续。请说明,这需要安装 @dcl/sdk@auth-server 分支,而不是标准 SDK。
构建多人游戏 Decentraland 场景,让无头服务端控制游戏状态、验证更改并防止作弊。同一代码库同时运行于服务端和客户端,且服务端拥有完全控制权。Decentraland 会自动托管并部署服务端。关于基础 CRDT 多人游戏(无服务端),请改用 multiplayer-sync 技能。
设置
必须使用 npm install @dcl/sdk@auth-server 和 npm install @dcl/js-runtime@auth-server——标准的 @dcl/sdk 不包含权威服务端 API。scene.json根级别的 "authoritativeMultiplayer": true 用于启用无头服务端(如果没有此配置,场景将作为普通的无服务端 CRDT 运行,且 isServer() 永远不会返回 true),但不要手动添加:@dcl/sdk@auth-server 的 sdk-commands 会在每次构建和预览时自动添加(bundle.ts 会通过 ensureJsonKey 将 authoritativeMultiplayer: true 写入 scene.json,仅在该配置不存在时添加;它还会自动将 server-logs 脚本添加到 package.json)。规则很简单:不要删除它。 也可以添加 logsPermissions(根级别的钱包地址数组),以授权读取生产服务端日志——参见 {baseDir}/references/server-patterns.md → Production Logs。预览时会自动在后台启动本地服务端。
服务端/客户端分支逻辑
在单一代码库中使用 @dcl/sdk/network 中的 isServer() 来区分逻辑。服务端以无头模式运行(不进行渲染),并可通过 PlayerIdentityData 获取所有玩家的位置。
对于通过底层 ~system/EngineApi 中的 isServer()(异步,与同步的 @dcl/sdk/network 辅助函数不同)自行判断角色的共享代码/库代码,请使用防御性惯用写法:在启动时判断一次角色,在角色仍未知时让系统提前返回,让仅限客户端的功能在服务端始终保持关闭,并将查询失败视为客户端——这样真实客户端即使查询出错,也不会丧失功能。
带验证的同步组件
定义从服务端同步到所有客户端的自定义组件。务必使用 validateBeforeChange(),防止客户端修改服务端权威状态。务必在 isServer() 块内调用 validateBeforeChange()(以及任何封装它的辅助函数,例如 protectServerEntity())——无论是哪种重载(按实体或全局无实体),都只在服务端有意义,在客户端调用会产生错误。这同样适用于共享文件中的全局自定义组件验证器:在模块作用域定义组件,但将 validateBeforeChange() 调用放在 isServer() 保护条件中(例如,放在 main() 内,或放在 shared/schemas.ts 中的 if (isServer()) { ... } 块内)。
验证器回调会接收 { entity, currentValue, newValue, senderAddress, createdBy }。通过 value.newValue.<field> 读取组件字段(不要使用 value.<field>——该字段不存在)。currentValue 是更改前的值(若组件不存在,则为 undefined)。删除组件时,newValue 为 undefined。senderAddress 是发送方的钱包地址;如果由服务端发送,则等于 AUTH_SERVER_PEER_ID。比较地址时务必使用 .toLowerCase()。
验证模式
- 模式 1 — 仅允许服务端写入(最严格):
Score.validateBeforeChange((v) => v.senderAddress === AUTH_SERVER_PEER_ID) - 模式 2 — 验证值本身:拒绝不可能的值(例如
value.newValue.position.y > 0) - 模式 3 — 邻近性验证(防作弊):通过
PlayerIdentityData+Transform检查玩家是否靠近对象。服务端读取的玩家Transform.position是以场景为局部坐标系的米数——与客户端看到的坐标系相同,也与场景实体使用的坐标系相同——因此可以使用Vector3.distance()直接与实体位置比较,无需计算基础地块偏移。规范示例:官方测试场景90,-9-authoritative-server-leaderboard(部署在非原点地块上)会将服务端读取的玩家位置直接与场景局部目标进行比较。(某些较旧的 SDK 服务端版本会返回世界/地块绝对坐标;这是一个已修复的 bug——不要添加scene.base偏移修正。) - 模式 4 — 仅允许管理员写入:使用
@dcl/asset-packs/dist/admin-toolkit-ui/ModerationControl/api中的getSceneAdmins()将操作限制为管理员。对于轻量级固定允许列表,也可以改为根据经服务端验证的发送者限制消息处理器:在共享配置中保留小写的ADMINS: string[],并在room.onMessage(...)内检查ADMINS.includes(context.from.toLowerCase())(绝不要信任客户端报告的角色)。客户端可以读取同一个列表,以决定是否显示特权按钮,但只有服务端的检查具有权威性。
使用 @dcl/asset-packs/dist/admin-toolkit-ui/fetch-utils 中的 isPreview()(同步、无参数、返回 boolean)在本地开发期间放宽验证。只有深层 dist/... 导入路径可用——该包没有顶层重新导出。
自定义组件使用全局验证:GameState.validateBeforeChange((value) => ...)。内置组件(Transform、GltfContainer)使用按实体验证:Transform.validateBeforeChange(entity, (value) => ...)。
创建并保护实体后,使用 syncEntity(entity, [Transform.componentId, GameState.componentId]) 同步它。在权威服务端场景中,只有服务端应调用 syncEntity()——请将调用置于 if (isServer()) 中。服务端创建并共享实体实例;所有客户端都会收到同步。这与 multiplayer-sync 模式(无服务端)不同,在该模式中,每个客户端都会在自己的客户端上调用 syncEntity。在权威服务端场景中于客户端调用 syncEntity 会产生错误;避免客户端调用也无需再担心各节点之间的实体 ID 一致性。
每玩家同步实体
对于由服务器创建的、每位玩家各有一个的实体(分数、持有时间、钱包),绝不要根据玩家地址推导显式同步 ID(例如 hash(address) % N)。显式同步 ID 是一个全局网络身份,并带有严格的冲突检查——当两个地址哈希到同一槽位时(对于 100k 的范围,大约 370 名玩家时概率约为 50%),以及同一玩家在旧实体清理前重新连接时,哈希 ID 都会触发 syncEntity failed because the id provided is already in use。应改为省略 ID(syncEntity(entity, [Comp.componentId]))——自动分配从设计上保证唯一——并将玩家地址存储在组件字段(playerId)中;所有读取方(客户端系统和服务器重启后的协调逻辑)都应根据该字段匹配,绝不使用网络 ID。显式枚举 ID 应保留给固定的单例(游戏状态、标记、排行榜)。
长时间运行的服务器会循环复用实体槽位,因此缓存的 Map<address, Entity> 句柄可能过期(组件已不存在,但映射仍指向失效实体)。复用缓存项前,使用 Comp.getOrNull(entity) 验证;命中过期项时重新创建。在逐帧系统中使用 getMutableOrNull 并添加保护,这样暂时未找到实体时只会跳过一个 tick,而不会每帧都抛出 [mutable] Component <name> for <id> not found。从 engine.getEntitiesWith(Comp) 扫描中接管实体时(例如服务器重启后重建映射),跳过编号(entity & 0xffff)小于 512 的实体——这些是由运行时拥有的保留槽位/头像范围槽位;绝不要缓存它们或对其调用 removeEntity()。完整代码模式见:{baseDir}/references/server-patterns.md → 每玩家同步实体。
消息
使用 registerMessages() 进行客户端到服务器以及服务器到客户端的通信。使用 Schemas.Map(...) 定义消息架构——普通 JS 对象会导致二进制序列化失败。
模块加载时机(关键): registerMessages() 会在内部定义一个组件,而 shared/schemas.ts 中的 engine.defineComponent() 也会定义组件。两者都必须在初始模块加载期间、引擎封存之前运行。应通过静态 import 引入(例如在 index.ts 顶部使用 import './shared/messages'),不要在 main() 中使用动态 import()——动态导入会在封存后运行,并抛出 Engine is already sealed。只有仅服务端模块(即导入 @dcl/sdk/server 的模块)才应在 isServer() 分支中动态导入,而且仅限于它们未在模块作用域定义组件的情况——这样可避免 @dcl/sdk/server 进入客户端打包路径。
- 客户端发送:
room.send('playerJoin', { displayName: 'Alice' }) - 服务器发送给所有人:
room.send('gameEvent', { ... }) - 服务器发送给一人:
room.send('gameEvent', { ... }, { to: [playerAddress] }) - 接收:
room.onMessage('playerJoin', (data, context) => { ... })—context.from是发送者的钱包地址
客户端必须等待 isStateSyncronized()(注意 SDK 中的拼写错误)返回 true 后才能发送消息。
重要 — 消息大小限制:绝不要发送大于 13 KB 的消息。传输层会静默丢弃任何超过此限制的消息。如有需要,请将大型负载拆分为较小的数据块。
MessageBus 仅供客户端使用。 MessageBus(来自 @dcl/sdk/message-bus)会订阅旧版 EngineApi.subscribe('comms') 事件,而无头服务器运行时并未实现该事件——在服务器上使用会失败并提示 RemoteError: not implemented。由于模块作用域代码会在两端运行,在权威服务器场景中,绝不要在模块作用域构造实例(const bus = new MessageBus());只能在客户端分支内构造(if (!isServer()))。它在客户端仍可用于临时的客户端到客户端效果,但服务器既不能通过它发送,也不能通过它接收——所有客户端↔服务器通信都必须通过 registerMessages() + room 完成。
Schema 类型参考
Schemas.String、.Int、.Float、.Boolean、.Int64(用于 Date.now() / 13 位以上的数字)、.Vector3、.Quaternion、.Entity、.Array(Schemas.String)、.Optional(Schemas.String)、.Map({ name: Schemas.String, hp: Schemas.Int })。
布尔型 schema 是
Schemas.Boolean,不是Schemas.Bool(已验证 —@dcl/ecs的schemas/index.d.ts暴露的是Schemas.Boolean;内部类名为Bool,但未以该名称在Schemas命名空间中暴露)。
时间戳请使用 Schemas.Int64 — Schemas.Number 会破坏较大的数字(13 位以上)。
服务端读取玩家位置
通过 engine.getEntitiesWith(PlayerIdentityData) + Transform.getOrNull(entity) 读取经服务端实际验证的位置。切勿信任客户端报告的位置。
存储
在服务重启后持久化数据。仅限服务端 — 使用 isServer() 进行保护。从 @dcl/sdk/server 导入。
- Scene Storage(全局,在所有玩家间共享):
Storage.set/get/delete(key)—Storage上的顶层方法 - Player Storage(每位玩家独立,按钱包地址隔离):
Storage.player.set/get/delete(address, key)
Storage 仅接受字符串 — 对对象使用 JSON.stringify()/JSON.parse()。本地开发存储位于 node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json。生产环境存储位于 decentraland.org/storage。CLI:npx sdk-commands storage scene/player set/get/delete ...。存储会在重新部署后保留(按 world 隔离,而非按 hash)。
没有追加和比较并设置操作 — 向日志添加内容需要先读取再写入,因此两个服务实例(旧部署与新部署重叠的时间段)可能会丢失彼此的行:在写入前重新读取并按 id 合并,可以缩短这个时间窗口,但无法完全消除。对于不断增长的日志(试玩测试答案、回合历史、遥测数据),请将其拆分到以数字编号的键中,并用一个 head 键标明当前部分:参见 {baseDir}/references/server-patterns.md → Append-only logs: rolling CSV parts in Storage,Scene Feedback 模块使用的模式。
重要 — 存储写入有上限,切勿在每次变更 / 每个 tick 时写入:如果场景在每次分数变化 / 每个事件 / 每个 tick 时调用一次 Storage.set,就会触及 isolate 共享的进行中 host-call 上限;超出的写入会静默失败 — SDK 会将其解析为 false,而不是抛出异常。Storage.set/Storage.player.set 返回 Promise<boolean> — 请检查返回值(false = 写入未持久化;请重试或提示用户)。请将实时 / 工作状态保存在内存中(对服务端而言速度更快且更可靠),仅在有意义的检查点写入 Storage:游戏结束、玩家离开,或定期防抖保存。仅持久化必须在服务重启 / 重新部署后保留的数据。有关上限机制和检查点模式,请参见 {baseDir}/references/server-patterns.md → Storage Patterns and Server Resource Limits。
实时存储 Web UI(decentraland.org/storage,也可通过 Creator Hub Manage → 已发布场景旁的三点菜单 → View Storage 访问)。包含三个选项卡 — Scene、Player、Environment。编辑会实时应用于正在运行的场景,无需重新发布:
- Scene 选项卡:查看 / 编辑 / 删除共享变量(排行榜、门的状态)。适合调整实时值,例如重置排行榜。
- Player 选项卡:按钱包地址或名称查找玩家,并检查 / 编辑 / 清除其存储的数据。主要用途是支持 — 无需重新部署,即可解除玩家被错误状态卡住的问题(例如,旧版场景留下的矛盾数据)。
环境变量
配置值,避免硬编码。仅限服务端。@dcl/sdk/server 中的 EnvVar.get(key: string): Promise<string> 始终会解析为字符串;如果变量未设置或获取失败,则返回 ''(空字符串),绝不会返回 undefined。默认值仍可使用 || 'fallback' 模式,因为 '' || 'x' 的结果是 'x'。本地使用 .env 文件(将其添加到 .gitignore)。使用 npx sdk-commands storage env set KEY --value VALUE 部署。生产环境 UI 位于 decentraland.org/storage → Environment 选项卡(或 Creator Hub → Manage → 三点菜单 → View Storage)。
- 存放机密信息的合适位置(私钥、奖励/领取码、API 密钥)——这些值只会存在于服务端,绝不会传到客户端或已发布的场景代码中。
- UI 中只能写入:你可以添加、覆盖或删除变量,但无法读取当前值(这是有意设计的,用于保护机密信息)。
- 也非常适合存放你想在场景运行期间调整、且无需重新发布的可实时调整的游戏参数/功能开关(对局时长、玩家人数上限)。
推荐的项目结构
src/
├── index.ts # Entry point — isServer() branching
├── client/
│ ├── setup.ts # Client init, input handlers, message senders
│ └── ui.tsx # React ECS UI (reads synced state, sends messages)
├── server/
│ ├── server.ts # Server init, systems, message handlers
│ └── gameState.ts # Server state management class
└── shared/
├── schemas.ts # Synced component definitions + validateBeforeChange
└── messages.ts # Message definitions via registerMessages()
性能最佳实践
每次组件发生变化,都会发送该组件的全部数据。优先使用原子组件,而不是单体组件——将一起变化的字段分组,并将快速变化的数据与缓慢变化的数据分开。对频繁发送的消息进行限流(绝不要每帧都发送)。对于可推导的状态,每隔约 30 秒广播一次,并在两次广播之间在本地计算。
服务端生命周期
只有场景中至少有一名玩家时,服务端才会保持活动状态。最后一名玩家离开后,服务端会继续运行约两分钟,然后关闭。下次访问时会冷启动一个新实例,生产环境中需要约 15 秒。本地预览会立即启动服务端——这正是服务端就绪状态相关的 Bug 几乎总会悄然进入生产环境的原因。务必针对“已有一段时间没有玩家来过”的情况,使用实际部署版本进行测试。
**isStateSyncronized() 不是服务端就绪状态检查。**它只能确认 CRDT 房间传输已连接。房间的 CRDT 快照可能保存着上一次服务端运行时的状态,因此即使服务端仍在启动,新客户端也可能看到“有效”状态;如果该客户端是唯一的客户端,且平台尚未启动服务端,服务端甚至可能根本不会启动。在这段时间内发送的消息会被悄然丢弃,场景则会一直等待永远不会到来的服务端响应。
可靠的做法是使用服务端心跳:服务端每隔约 2 秒将 Date.now() 写入一个同步组件字段;客户端跟踪上次观察到该值变化时的客户端时间(而不是服务端时间戳),并且只有在最近约 3 倍间隔内观察到一次心跳时,才认为服务端处于活动状态。跟踪客户端观察到的时间,而不是心跳值,可以避免将已停止很久的服务端运行留下的过期快照误判为实时状态,同时也不受服务端与客户端时钟偏差的影响。在服务端的状态初始化函数中立即发布第一次心跳,这样首个连接的客户端就不必等待一个完整间隔。
在 UI 层区分两种故障模式——它们看起来相似,但表现截然不同。房间未同步是暂时的(场景加载期间约 1 秒):将操作暂存起来,并通过重试系统自动触发。服务端未活动可能持续 15 秒或更久(冷启动时),也可能一直无法恢复:显示“服务端正在唤醒”弹窗,而不是悄然暂存操作;一旦收到心跳就自动关闭弹窗,避免已经等待的玩家仍盯着过时的对话框。完整实现请参阅 {baseDir}/references/auth-server-examples.md → Server Liveness Heartbeat。
部署版本控制
客户端和服务端始终成对更新(按哈希配对)。现有玩家在重新加入前会继续使用旧版本。Storage 数据会跨版本保留。
测试与调试
- 日志前缀:在
console.log()中使用[SERVER]和[CLIENT] - 本地多人游戏:桌面版 Explorer 一次只能运行一个实例——再次按下 Play 只会重新聚焦已打开的窗口,不会添加玩家。可以选择:(a)在 Creator Hub 的 Play Options 弹出菜单中勾选 Multi-Instance Preview(将鼠标悬停在 Desktop Client 行上以打开其弹出菜单;=
sdk-commands start --multi-instance,需要@dcl/sdk>= 7.20.4),然后再次按下 Play——多实例模式会强制每个窗口单独登录,因此会关闭 Skip Auth Screen,且每个实例都必须使用不同的账户登录;或(b)保持桌面客户端打开,并添加浏览器客户端作为玩家 2——Play Options → 选择 Web (Bevy) 客户端行(=sdk-commands start --web,别名--bevy-web)。单独使用-n也会强制打开新的桌面窗口。 - 生产环境日志:运行
npx sdk-commands sdk-server-logs(对于 Worlds,添加--world WORLD_NAME.dcl.eth)。系统会提示进行钱包签名验证;签名钱包必须列在scene.json的logsPermissions中。参见{baseDir}/references/server-patterns.md→ 生产环境日志。 - 服务端日志噪声特征:服务端日志中反复出现的两种错误都表示客户端专用代码正在服务端分支中运行。
EngineApi.subscribe('comms')出现RemoteError: not implemented→ 在服务端创建了MessageBus(参见消息)。comms-gatekeeper.decentraland.org返回400 Invalid metadata content→ 服务端代码通过signedFetch调用了客户端上下文的平台 API。这两种情况都不会导致服务崩溃,但反复出现的噪声会淹没真正的服务端日志——请在问题代码外层添加!isServer()判断。 - 每个玩家的实体错误特征:
syncEntity failed because the id provided is already in use→ 显式同步 ID 发生冲突(通常是基于哈希生成的每玩家 ID 冲突,或在同一帧中移除并以相同 ID 重新创建);[mutable] Component <name> for <id> not found每帧反复出现 → 长时间运行的服务端上,缓存的实体句柄已失效。以上两种情况的修复方法见上文“每个玩家的同步实体”。 - 过时的 CRDT 文件:删除
main.crdt和main1.crdt,然后重启 - 存储检查:检查本地 JSON 文件或访问 decentraland.org/storage
- 计时器与沙箱:QuickJS 沙箱中没有 Node.js API(
fs、http等)。延迟操作请使用来自@dcl/sdk/ecs的timers.setTimeout/timers.setInterval——切勿使用原生 JS 全局对象。游戏逻辑优先使用带 dt 累加器的engine.addSystem() - 实体同步:验证
syncEntity(entity, [componentIds])中的.componentId值是否正确
示例场景
-
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/90,-9-authoritative-server-leaderboard — 完整的端到端权威排行榜。客户端发送
claimPoint操作(绝不发送分数);无头服务会验证玩家是否接近“得分球”(场景局部坐标,直接比较),自行递增分数,将每位玩家的总分持久化到Storage,并广播同步的前 N 名Leaderboard组件,供所有客户端渲染。展示内容:scene.json中的authoritativeMultiplayer: true、isServer()分支、为确保模块加载时序而静态导入registerMessages()/defineComponent()、通过isServer()控制的自定义组件validateBeforeChange(仅服务端可通过AUTH_SERVER_PEER_ID写入)、仅服务端执行的syncEntity、原子组件(心跳与排行榜分开),以及带有客户端观测时间跟踪的服务端存活心跳。 -
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/92,-9-authoritative-server-gem-rush — 完整的竞技游戏,包含反作弊和检查点持久化。关键模式:通过
PlayerIdentityData+Transform在服务端验证距离(绝不信任客户端位置;服务端直接读取玩家变换,并使用Vector3.distance进行比较);将位置存储在受保护的自定义组件中(而非同步的 Transform),以防客户端回写;根据变化频率拆分原子组件(倒计时、记分板和荣誉榜分别独立);仅在回合结束时写入 Storage(每回合 1 次场景 SET + N 次玩家 SET,游戏过程中调用 0 次);在拒绝消息中使用antiCheat: true标记;启动时及之后每隔约 2 秒使用Date.now()发送一次ServerHeartbeat;每位玩家的统计数据在每个会话中通过Storage.player.get延迟加载一次,并缓存在内存中。 -
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/93,-9-authoritative-server-limits-lab — 对所有运行时限制进行压力测试:
maxInflightHostCalls40、maxConcurrentFetches32、maxSendMessages512、maxCommsMessageBytes30000、maxMessagesPerWindow300/s、maxLiveEntities100000、isolateMemoryLimitBytes256 MB、同步 10 秒 / 异步 60 秒、fetch 超时 15 秒、最大 WS 消息 1 MB、最大打开套接字数 32、最大 fetch 重定向数 5、最大正文 10 MB。{baseDir}/references/server-patterns.md→ Server Resource Limits 中提供了这些限制值的参考。 -
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/0,-1-scene-feedback — Scene Feedback 游戏测试问卷模块,也是 Storage 中仅追加日志的参考实现:内存缓冲区、缓冲即确认、滚动生成 400 KB 的 CSV 分片(
scene-feedback-N.csv),并使用一个头部键;服务端根据客户端生成的requestId去重、限制每个地址的请求频率、转义 CSV 并防止电子表格公式注入。将src/scene-feedback/+assets/scene-feedback/复制到场景中,启用服务端(@dcl/sdk@auth-server、authoritativeMultiplayer: true),并将USE_FETCH添加到requiredPermissions;在questions.ts中进行配置。其 README 中包含 Agent 安装提示词。关于该问什么以及何时提问的设计层面指导:game-design → Test Early。
完整代码示例(验证模式、消息、Storage、EnvVar、性能)请参阅 {baseDir}/references/auth-server-examples.md。服务端设置模式请参阅 {baseDir}/references/server-patterns.md。