关于 colyseus
colyseus 是一项技能,指导编码 Agent 使用 Node.js 权威式多人游戏框架 Colyseus。它强调先确认已安装版本,并针对 API 变更提供版本对应的指导,尤其涉及 0.17 和 0.18。
主要功能
- 01按版本提供 API 指导
- 02Colyseus 服务端与房间模式
- 03Schema 与同步状态指导
- 04客户端回调与重连模式
- 05迁移注意事项与参考索引
适用场景
- 01修改前确认项目的 Colyseus 版本
- 02实现房间与同步状态
- 03将代码更新至 Colyseus 0.18
- 04处理客户端状态回调与重连
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ colyseus/ skill --skill colyseus安装后在 Claude Code 中输入/colyseus确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
.gitignoresources.jsonREADME.mdpackage.jsonLICENSE.github/workflows/sync.ymlreferences/netcode.mdschema.mdroom.mdclient-sdk.mdtools/sync.mjs详细介绍
Colyseus
Colyseus 是一个适用于 Node.js 的权威多人游戏框架。服务器拥有状态;客户端发送输入并接收二进制增量补丁。
API 在 0.17 版本发生了重大变更,在 0.18 版本又有一次,而大多数模型训练数据都早于这两个版本。请假设你对 Colyseus 的了解已经过时,并在编写前查证。
第 1 步:确认版本。不要跳过。
读取项目的 package.json(或 pnpm-lock.yaml / yarn.lock),检查
colyseus 和 @colyseus/schema。
| 已安装版本 | 操作 |
|---|---|
colyseus 0.18.x,@colyseus/schema 5.x | 直接使用本指南中的说明。 |
| 0.17.x | 阅读 https://docs.colyseus.io/migrating/0.18.md,了解哪些内容暂不适用,然后遵循 0.17 的惯用写法。 |
| 0.16.x 或更早版本 | 遵循该版本自己的文档。不要应用以下内容。对应版本的网站:https://0-16-x.docs.colyseus.io/。 |
| 尚未安装 | 使用 npm create colyseus-app@latest 初始化项目,该命令会创建 0.18 版本。 |
如果版本不明确,请先询问,不要猜测。把 0.18 API 写进 0.16 项目会导致代码在导入时失败;把 0.16 API 写进 0.18 项目则会导致代码在运行时失败或静默失去同步。
步骤 2:记忆会出什么错
左栏列出了过时记忆会导致的错误写法。检查你自己的输出是否有这些问题,并留意现有项目中的这些写法。
| 不要这样写 | 改为这样写 | 原因 |
|---|---|---|
import { Client } from "colyseus.js" | import { Client } from "@colyseus/sdk" | JS 客户端包已重命名。 |
class MyRoom extends Room<MyState> | class MyRoom extends Room,并添加 state = new MyState() | 泛型现在是一个选项对象:Room<{ state, metadata, client }>。单独的状态类型现在无法通过类型检查。 |
Client<UserData, AuthData> | Client<{ userData: UserData, auth: AuthData }> | 客户端类型也做了同样的更改。 |
onCreate() { this.onMessage("x", fn) } | 将 messages = { x: fn } 作为类属性 | 声明式映射是主要 API。this.onMessage() 仍可使用,并会返回一个解绑函数。 |
仅使用 @type("string") name: string | schema({ name: t.string() }, "MyState") | 构建器是主要写法,且无需编译器标志。装饰器仍受支持。 |
schema({ name: "string" }) | schema({ name: t.string() }) | 在 schema 5 中,原始字符串字段类型会在定义时抛出异常。它们仍可用作集合子类型:t.array("string")。 |
client.id | client.sessionId | client.id 已在 0.18 中移除。 |
async onLeave(client, consented: boolean) | onLeave(client, code: CloseCode) | 第二个参数是关闭代码。4000 的值为真,因此 if (!consented) 永远不会执行,也就会悄无声息地禁止重连。请参阅下方的重连写法。 |
Protocol.WS_CLOSE_CONSENTED | 来自 "colyseus" 的 CloseCode.CONSENTED | Protocol.WS_* 已移至 CloseCode。4000–4010 为保留值;自定义代码使用 4011–4999。 |
this.setSeatReservationTime(15) | this.seatReservationTimeout = 15 | 该方法已改为属性。 |
使用 this.setMetadata({ status: "x" }) 并期待合并 | this.setMetadata({ ...this.metadata, status: "x" }) | 0.18 会整体替换元数据,而不再进行浅合并。setMatchmaking({ metadata }) 也一样。 |
@filter() / @filterChildren() | 在字段上使用 @view(),并在 onJoin 中设置 client.view = new StateView() | 每个客户端的筛选需要通过视图显式启用。 |
this.setSimulationInterval(fn) | this.setTimestep(fn) | 已重命名。旧名称仍会转发调用,因此并未失效,但新代码应使用 setTimestep()。对于需要预测的游戏,请使用 setFixedTimestep(step, tickRate)。 |
包含 64 个或更多字段的 Schema | 将超出的字段移至嵌套的子 Schema | 0.18 将 Schema 的字段数上限设为 63,并会在定义时抛出异常。继承的字段也计入其中。 |
@colyseus/fossil-delta-serializer | @colyseus/schema | 该包已在 0.18 中移除。 |
另有两个在生产环境中容易踩坑的问题:
- Playground 在生产环境挂载时(
NODE_ENV=production)会返回 404,除非你通过其use选项传入保护中间件。 @colyseus/auth的密码哈希方式已在 0.18 中更改。新哈希器无法验证 0.18 之前的哈希,因此现有的电子邮件/密码用户必须重置密码。旧版scrypt哈希器及其全局变量AUTH_SALT已弃用。
改变的不只是名称,还有结构
以下是 pre-0.17 代码中结构有所不同的三个地方,因此无法通过重命名对照表展示修复方式。左侧是需要识别的旧写法。
服务端定义。 new Server() + gameServer.define(),以及 @colyseus/tools 的 config({ initializeGameServer }) 包装器,都改成了 defineServer():
-import config from "@colyseus/tools";
-export default config({
- initializeGameServer: (gameServer) => {
- gameServer.define("my_room", MyRoom);
- },
- initializeExpress: (app) => {},
-});
+import { defineServer, defineRoom } from "colyseus";
+export default defineServer({
+ rooms: { my_room: defineRoom(MyRoom) },
+ express: (app) => {},
+});
状态回调。 处理器不再是围绕状态对象的可调用包装器,而是一个对象,其方法接收集合名称或实例:
-import { Client, getStateCallbacks } from "colyseus.js";
-const $ = getStateCallbacks(room);
-$(room.state).players.onAdd((player, sessionId) => {
- $(player).listen("x", (value, previous) => { /* ... */ });
+import { Client, Callbacks } from "@colyseus/sdk";
+const callbacks = Callbacks.get(room);
+callbacks.onAdd("players", (player, sessionId) => {
+ callbacks.listen(player, "x", (value, previous) => { /* ... */ });
});
callbacks.onRemove("players", fn)、callbacks.onChange(player, fn)、callbacks.bindTo(player, sprite),以及针对根属性的 callbacks.listen("phase", fn) 都遵循相同形式。每次注册都会返回一个解绑函数。对于已存在的项目,onAdd 会立即触发。
重新连接。 重新连接从 onLeave 中移出,成为独立的钩子;客户端也不再需要手动重新连接:
-async onLeave(client, consented: boolean) {
- try {
- if (consented) throw new Error("consented leave");
- await this.allowReconnection(client, 20);
- } catch (e) {
- this.state.players.delete(client.sessionId);
- }
-}
+onDrop(client: Client, code: number) {
+ this.allowReconnection(client, 30); // no await; the outcome routes to the hooks below
+}
+onReconnect(client: Client) { /* mark the player connected again */ }
+onLeave(client: Client, code: number) {
+ this.state.players.delete(client.sessionId); // gone for good
+}
-room.onLeave(async () => {
- room = await client.reconnect(room.reconnectionToken); // new instance: re-attach everything
-});
+room.onDrop(() => showReconnectingUI()); // the SDK is already retrying
+room.onReconnect(() => hideReconnectingUI()); // same instance, callbacks intact
+room.onLeave((code) => {
+ if (code === CloseCode.FAILED_TO_RECONNECT) offerRejoin();
+});
页面重新加载时仍可使用 client.reconnect(token)。它会返回一个新的 Room,因此在这种情况下需要重新绑定回调。
生命周期如何运行
房间:onCreate → … → onDispose。每个客户端:
onAuth → onJoin → (onDrop → onReconnect)* → onLeave。
- 任何未经同意的连接关闭都会在双方触发
onDrop。服务端调用allowReconnection(client, seconds);SDK 会采用退避策略在同一个room实例上重试。 - 成功时,双方都会触发
onReconnect。服务端会发送完整快照,并在原位进行协调:现有回调会继续绑定,onAdd和onRemove会针对离线期间发生的变化触发,排队等待的room.send()调用会被刷新。 - 超时或拒绝会在双方触发
onLeave;客户端收到FAILED_TO_RECONNECT(4003)。onLeave表示永久离开:应在此处移除玩家,而不要在onDrop中移除。
room.leave()(CONSENTED,4000)会跳过 onDrop。没有 onDrop 的房间会在 onLeave 中收到每次断开连接,并且在 code !== CloseCode.CONSENTED 时可在那里调用 allowReconnection()。
步骤 3:规范的 0.18 代码结构
每个代码块末尾都附有完整说明该内容的参考章节。references/<file> § "Heading" 形式指向捆绑参考文档中的某个标题;可使用 grep -n 跳转到该标题,而不必阅读整个文件。
服务端
src/index.ts:
import { listen } from "@colyseus/tools";
import app from "./app.config.js";
listen(app);
src/app.config.ts:
import { defineServer, defineRoom } from "colyseus";
import { MyRoom } from "./rooms/MyRoom.js";
export const server = defineServer({
rooms: {
my_room: defineRoom(MyRoom),
},
});
export default server;
命名导出可供 TypeScript 客户端导入,用于端到端类型检查。
参考:references/room.md § "Defining a Room";服务端选项、
传输、presence 和 driver:https://docs.colyseus.io/server.md。
状态
src/rooms/schema/MyRoomState.ts:
import { schema, t, type SchemaType } from "@colyseus/schema";
export const Player = schema({
x: t.number(),
y: t.number(),
connected: t.boolean().default(true),
}, "Player");
export type Player = SchemaType<typeof Player>;
export const MyRoomState = schema({
players: t.map(Player),
}, "MyRoomState");
export type MyRoomState = SchemaType<typeof MyRoomState>;
schema() 返回一个真正的类,因此 new MyRoomState() 和 instanceof 都可用;export type 别名让同一个名称也能用作类型。每种结构都要传入名称:序列化器、调试输出和代码生成都通过这个名称标识类。集合在没有默认值时会以空集合开始。
参考:references/schema.md § "Defining a Schema structure";字段
类型和带宽:references/schema.md § "Data Types";每客户端
过滤:references/schema.md § "View tags"。
房间
src/rooms/MyRoom.ts:
import { Room, Client, CloseCode, validate } from "colyseus";
import { z } from "zod";
import { MyRoomState, Player } from "./schema/MyRoomState.js";
export class MyRoom extends Room {
maxClients = 4;
state = new MyRoomState();
messages = {
// validate() rejects a malformed payload before it reaches the handler
move: validate(z.object({ x: z.number(), y: z.number() }), (client: Client, { x, y }) => {
const player = this.state.players.get(client.sessionId);
player.x = x;
player.y = y;
}),
// return a value to answer a client's room.request()
"get-profile": async (client: Client, { userId }: { userId: number }) => {
return await db.profiles.findById(userId);
},
};
onCreate(options: any) {}
onJoin(client: Client, options: any) {
this.state.players.set(client.sessionId, new Player());
}
onDrop(client: Client, code: CloseCode) {
this.allowReconnection(client, 30);
this.state.players.get(client.sessionId).connected = false;
}
onReconnect(client: Client) {
this.state.players.get(client.sessionId).connected = true;
}
onLeave(client: Client, code: CloseCode) {
this.state.players.delete(client.sessionId);
}
onDispose() {}
}
参考:references/room.md § "Message Handling";重新连接:
references/room.md § "Complete Server Example";计时器:
references/room.md § "Clock"。
客户端
import { Client, Callbacks, CloseCode } from "@colyseus/sdk";
import type { server } from "../../server/src/app.config.js";
// typeof server types room names, options, state and messages end to end
const client = new Client<typeof server>("http://localhost:2567");
const room = await client.joinOrCreate("my_room");
const callbacks = Callbacks.get(room);
callbacks.onAdd("players", (player, sessionId) => {
callbacks.listen(player, "x", (x) => { /* move the sprite */ });
});
callbacks.onRemove("players", (player, sessionId) => { /* destroy the sprite */ });
room.send("move", { x: 1, y: 2 });
const profile = await room.request("get-profile", { userId: 42 });
room.onDrop(() => { /* show reconnecting UI */ });
room.onReconnect(() => { /* hide it */ });
room.onLeave((code) => {
if (code === CloseCode.FAILED_TO_RECONNECT) { /* offer a rejoin */ }
});
参考:references/client-sdk.md § "State Sync Callbacks (Recommended)";
references/client-sdk.md § "Request/Response";
references/client-sdk.md § "Automatic Reconnection";页面重新加载的情况:
references/client-sdk.md § "Manual Reconnection"。
第 4 步:查看位置
在该领域编写非简单代码之前,先阅读随附的参考文档。这些文档是从网站生成的完整文档文本,每份有 1,200–2,200 行;上方的章节指引会带你找到正确部分。
| 任务 | 阅读 |
|---|---|
| 定义或更改同步状态、集合、筛选 | references/schema.md |
| 房间生命周期、消息、请求/响应、计时器、重新连接 | references/room.md |
| 在客户端连接、加入、发送、处理状态回调、重新连接 | references/client-sdk.md |
| 客户端预测、协调、延迟补偿、固定时间步长 | references/netcode.md |
其他所有内容都在网站上,每个页面都可以通过在 URL 后附加 .md 来获取原始 Markdown:
| 主题 | URL |
|---|---|
| 匹配、大厅、队列、可见性 | https://docs.colyseus.io/matchmaker.md |
HTTP 路由:createEndpoint、createRouter、中间件、CORS | https://docs.colyseus.io/server/http-routes.md |
| 身份验证 | https://docs.colyseus.io/auth.md |
| 数据库和持久化 | https://docs.colyseus.io/database.md |
| 服务端配置、传输、presence、驱动程序 | https://docs.colyseus.io/server.md |
| 横向扩展 | https://docs.colyseus.io/scalability.md |
| 部署 | https://docs.colyseus.io/deployment.md |
| Colyseus Cloud | https://docs.colyseus.io/cloud.md |
使用 @colyseus/testing 进行单元测试 | https://docs.colyseus.io/tools/unit-testing.md |
| Playground、监控、负载测试 | https://docs.colyseus.io/tools.md |
命令模式(@colyseus/command) | https://docs.colyseus.io/recipes/command-pattern.md |
| 从早期版本升级 | https://docs.colyseus.io/migrating/0.18.md |
| 所有页面的完整索引 | https://docs.colyseus.io/llms.txt |
如果任务需要的页面既未随附也未列出,请获取 https://docs.colyseus.io/llms.txt 并从中选择,而不要猜测 URL。
现有项目中的症状
查看在 0.18 中运行时,0.17 之前的代码会是什么表现。要留意那些没有明显提示的症状。
| 你看到的情况 | 原因 | 修复方法 |
|---|---|---|
| 玩家始终无法重新连接;没有错误 | onLeave 的第二个参数是关闭代码,因此 if (!consented) 永远不会执行 | 使用 onDrop(),或比较 code !== CloseCode.CONSENTED |
调用 setMetadata({ x }) 后,元数据字段消失 | 0.18 会替换而非合并 | 展开 this.metadata |
client.id 的值为 undefined | 已移除 | 使用 client.sessionId |
Cannot find module "colyseus.js",或未导出 getStateCallbacks | 包已重命名,处理程序 API 已更改 | 使用 @colyseus/sdk、Callbacks.get(room) |
定义架构时抛出错误,提及 "string" 或其他类型名称 | 字段类型使用了原始字符串 | 使用 t.string() 等方法 |
| 定义架构时因字段数量抛出错误 | 字段上限为 63 个,包含父级字段 | 嵌套一个子架构 |
Room<MyState> 不再通过类型检查 | 泛型是一个选项对象 | 移除它并赋值 state = new MyState() |
| 生产环境中 Playground 返回 404 | 0.18 中已限制访问 | 通过 use 传入一个保护中间件 |
工作规则
- 服务端具有权威性。客户端代码绝不能成为执行规则的唯一手段;应在房间中进行验证。
- 只有服务端可以修改
state。客户端发送消息或输入,并响应回调。 - 优先使用声明式的
messages映射,而不是命令式的onMessage()调用,并验证负载。绝不信任消息正文中提供的sessionId;应使用处理程序收到的client参数。 - Schema 仅用于同步状态。消息和请求负载都是普通对象。
- 保持同步状态精简。每次状态补丁都会广播,因此客户端不需要的内容应放在 Schema 之外。
- 更改状态结构后,检查所有受影响类(包括父类)是否达到 63 个字段的上限。
- 使用
this.clock.setTimeout()和this.clock.setInterval(),而非全局函数;房间计时器会在释放时清除。 - 在
onLeave中移除玩家,绝不要在onDrop中移除;掉线的客户端可能会重新连接。