Colyseus 完整接入指南
适用项目: Phaser 4 + Colyseus + Bun + Hono
目标版本: Colyseus 0.17
文档状态: Implementation Guide
适用模式: 1~8 人房间制实时联机游戏
推荐架构: 服务端权威、客户端表现、Schema 增量同步
1. 接入目标
Colyseus 在项目中负责:
- 创建和销毁游戏房间。
- 玩家匹配、加入和退出。
- WebSocket 长连接。
- 服务端权威游戏状态。
- 房间状态增量同步。
- 玩家断线重连。
- 房间消息收发。
- 多进程房间发现。
- 多实例之间的 Presence 通信。
- 房间运行指标与负载测试。
Colyseus 不负责:
- Phaser 场景和渲染。
- 用户注册、登录页面和资料管理。
- 支付、商城和运营后台。
- 长期数据查询。
- 复杂的数据库业务。
- Steam 平台接口。
- 静态资源分发。
整体职责划分:
Phaser 4 Client
├── 渲染
├── 输入
├── UI
├── 音频
├── 插值
└── 客户端预测
Colyseus Realtime
├── 房间
├── 权威状态
├── 游戏循环
├── 消息验证
├── 状态同步
├── 重连
└── 对局结算触发
Hono API
├── 登录
├── 用户资料
├── Realtime Ticket
├── 存档
├── 历史记录
└── 排行榜
PostgreSQL
└── 永久数据
Redis
├── Presence
├── Driver
├── 临时票据
└── 分布式协调
2. 核心设计原则
2.1 服务端权威
客户端只能提交“操作意图”,不能提交最终结果。
正确:
place_node
upgrade_tower
activate_ability
select_technology
move_unit
ready
错误:
set_lumen_9999
enemy_dead
match_win
set_damage_500
unlock_reward
服务端需要重新验证:
- 当前玩家身份。
- 当前房间阶段。
- 资源是否足够。
- 目标是否存在。
- 操作距离是否合法。
- 冷却时间是否结束。
- 操作频率是否异常。
- 坐标是否合法。
- 是否重复提交。
- 玩家是否拥有操作权限。
2.2 State 与 Message 分离
Colyseus 的同步状态采用 Schema。服务端修改 State,客户端监听变化;客户端不能直接修改服务端 State,只能发送消息请求服务端处理。Colyseus 会追踪属性级变化,并在 patchRate 周期内发送变更。
进入 State 的内容:
- 玩家列表。
- 房间阶段。
- 当前资源。
- 建筑状态。
- Beacon 网络状态。
- 可见敌人。
- 波次信息。
- 对局时间。
- 胜负状态。
使用 Message 的内容:
- 点击失败提示。
- 临时音效。
- 命中特效。
- 镜头震动。
- 浮动伤害数字。
- 教程提示。
- 临时动画事件。
- 命令拒绝原因。
判断标准:
新加入房间的玩家是否需要立即知道这项信息?
需要立即知道:放入 State。
只发生一次:使用 Message。
2.3 Room 不直接承载全部业务逻辑
推荐结构:
BattleRoom
├── 处理房间生命周期
├── 处理网络消息
├── 管理玩家连接
└── 调用 BattleSimulation
BattleSimulation
├── 经济系统
├── 建筑系统
├── 能量网络
├── 战斗系统
├── 敌人系统
├── 波次系统
└── 胜负判定
Room 应作为网络和生命周期适配层,而不是巨型游戏类。
3. 推荐目录结构
apps/
├── client/
│ └── src/
│ ├── game/
│ ├── scenes/
│ ├── network/
│ ├── entities/
│ └── ui/
│
├── realtime/
│ ├── src/
│ │ ├── rooms/
│ │ │ ├── BattleRoom.ts
│ │ │ └── LobbyRoom.ts
│ │ ├── schema/
│ │ │ ├── BattleState.ts
│ │ │ ├── PlayerState.ts
│ │ │ ├── NodeState.ts
│ │ │ └── EnemyState.ts
│ │ ├── simulation/
│ │ │ ├── BattleSimulation.ts
│ │ │ ├── SimulationClock.ts
│ │ │ └── RandomSource.ts
│ │ ├── commands/
│ │ │ ├── PlaceNodeCommand.ts
│ │ │ ├── UpgradeNodeCommand.ts
│ │ │ └── CommandDispatcher.ts
│ │ ├── auth/
│ │ │ └── verifyRealtimeTicket.ts
│ │ ├── persistence/
│ │ │ └── MatchResultRepository.ts
│ │ ├── config/
│ │ │ └── env.ts
│ │ └── app.config.ts
│ └── package.json
│
└── api/
└── src/
packages/
├── contracts/
│ ├── messages.ts
│ ├── errors.ts
│ └── room-options.ts
├── game-core/
├── game-config/
└── test-kit/
依赖方向:
client
└── contracts/public
realtime
├── contracts
├── game-core
└── game-config
api
├── contracts
└── persistence
禁止:
client → realtime
client → server-only game-core
client → database
4. 安装依赖
4.1 服务端依赖
cd apps/realtime
bun add colyseus
bun add @colyseus/schema
bun add @colyseus/bun-websockets
bun add zod
测试和开发工具:
bun add -d @colyseus/testing
bun add -d @colyseus/loadtest
bun add -d @colyseus/playground
bun add -d @colyseus/monitor
多实例部署:
bun add @colyseus/redis-presence
bun add @colyseus/redis-driver
Colyseus 0.17 官方文档列出的核心版本体系包括 colyseus 0.17、@colyseus/schema 4.x、前端 @colyseus/sdk 0.17,以及可选的 Redis、测试、监控和负载测试包。
4.2 客户端依赖
cd apps/client
bun add @colyseus/sdk
旧项目不要继续安装:
colyseus.js
Colyseus 0.17 前端推荐包是:
@colyseus/sdk
5. TypeScript 配置
Schema 使用装饰器时,需要启用:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true
}
}
官方文档明确要求使用 TypeScript 装饰器 Schema 时启用 experimentalDecorators,并将 useDefineForClassFields 设为 false。
6. 定义同步状态
6.1 PlayerState
// src/schema/PlayerState.ts
import { Schema, type } from "@colyseus/schema";
export class PlayerState extends Schema {
@type("string")
playerId = "";
@type("string")
displayName = "";
@type("boolean")
connected = true;
@type("boolean")
ready = false;
@type("float32")
cursorX = 0;
@type("float32")
cursorY = 0;
}
6.2 NodeState
// src/schema/NodeState.ts
import { Schema, type } from "@colyseus/schema";
export class NodeState extends Schema {
@type("string")
id = "";
@type("string")
ownerId = "";
@type("uint8")
nodeType = 0;
@type("float32")
x = 0;
@type("float32")
y = 0;
@type("uint8")
level = 1;
@type("float32")
health = 100;
@type("boolean")
powered = false;
}
6.3 EnemyState
// src/schema/EnemyState.ts
import { Schema, type } from "@colyseus/schema";
export class EnemyState extends Schema {
@type("string")
id = "";
@type("uint8")
enemyType = 0;
@type("float32")
x = 0;
@type("float32")
y = 0;
@type("float32")
health = 100;
@type("uint8")
status = 0;
}
6.4 BattleState
// src/schema/BattleState.ts
import { MapSchema, Schema, type } from "@colyseus/schema";
import { PlayerState } from "./PlayerState";
import { NodeState } from "./NodeState";
import { EnemyState } from "./EnemyState";
export enum MatchPhase {
WAITING = 0,
PREPARING = 1,
RUNNING = 2,
VICTORY = 3,
DEFEAT = 4,
ABORTED = 5,
}
export class BattleState extends Schema {
@type("uint8")
phase = MatchPhase.WAITING;
@type("uint32")
serverTick = 0;
@type("float32")
elapsedSeconds = 0;
@type("int32")
lumen = 100;
@type("float32")
coreHealth = 100;
@type({ map: PlayerState })
players = new MapSchema<PlayerState>();
@type({ map: NodeState })
nodes = new MapSchema<NodeState>();
@type({ map: EnemyState })
visibleEnemies = new MapSchema<EnemyState>();
}
6.5 Schema 设计要求
Colyseus Schema 单个结构最多支持 64 个序列化字段;复杂状态应通过嵌套 Schema 拆分。此外,服务端和客户端的 Schema 字段定义顺序必须保持一致。
实践要求:
- 不保存数据库实体。
- 不保存函数。
- 不保存 Phaser 对象。
- 不保存完整静态配置。
- 不保存客户端看不到的敌人。
- 不保存不需要同步的临时变量。
- 高频字段优先使用数值类型。
- 不要使用大量高频字符串。
- 集合元素必须有稳定 ID。
错误:
class EnemyState extends Schema {
@type("string")
fullJsonConfig = JSON.stringify(config);
}
正确:
class EnemyState extends Schema {
@type("uint8")
enemyType = 2;
}
客户端根据 enemyType 读取本地静态配置。
7. 定义消息协议
共享协议:
// packages/contracts/src/messages.ts
export const ClientMessage = {
READY: "player.ready",
PLACE_NODE: "build.place-node",
UPGRADE_NODE: "build.upgrade-node",
ACTIVATE_ABILITY: "ability.activate",
PING: "system.ping",
} as const;
export const ServerMessage = {
COMMAND_REJECTED: "command.rejected",
EFFECT: "game.effect",
PONG: "system.pong",
MATCH_RESULT: "match.result",
} as const;
export interface PlaceNodePayload {
requestId: string;
nodeType: number;
x: number;
y: number;
rotation: number;
}
export interface CommandRejectedPayload {
requestId: string;
code:
| "INVALID_PHASE"
| "INVALID_PAYLOAD"
| "INSUFFICIENT_LUMEN"
| "POSITION_BLOCKED"
| "OUT_OF_RANGE"
| "RATE_LIMITED";
}
消息名称建议使用:
领域.动作
例如:
player.ready
build.place-node
build.upgrade-node
ability.activate
match.surrender
system.ping
不要使用:
msg1
action
event
update
command
8. 实现 BattleRoom
// src/rooms/BattleRoom.ts
import {
Client,
Room,
validate,
type AuthContext,
} from "colyseus";
import { z } from "zod";
import { BattleState, MatchPhase } from "../schema/BattleState";
import { PlayerState } from "../schema/PlayerState";
import { NodeState } from "../schema/NodeState";
const placeNodeSchema = z.object({
requestId: z.string().min(8).max(64),
nodeType: z.number().int().min(0).max(10),
x: z.number().finite().min(0).max(4096),
y: z.number().finite().min(0).max(4096),
rotation: z.number().finite().min(-Math.PI * 2).max(Math.PI * 2),
});
interface RoomAuth {
playerId: string;
displayName: string;
}
interface BattleRoomOptions {
mapId?: string;
difficulty?: number;
realtimeTicket?: string;
}
export class BattleRoom extends Room {
state = new BattleState();
maxClients = 4;
autoDispose = true;
patchRate = 100;
private nextNodeId = 1;
async onAuth(
client: Client,
options: BattleRoomOptions,
context: AuthContext,
): Promise<RoomAuth> {
const auth = await verifyRealtimeTicket(options.realtimeTicket);
if (!auth) {
throw new Error("UNAUTHORIZED");
}
return {
playerId: auth.playerId,
displayName: auth.displayName,
};
}
onCreate(options: BattleRoomOptions): void {
this.setMetadata({
mapId: options.mapId ?? "seedfall_standard",
difficulty: options.difficulty ?? 1,
});
this.setSimulationInterval((deltaTime) => {
this.updateSimulation(deltaTime);
}, 50);
}
onJoin(
client: Client,
_options: BattleRoomOptions,
auth: RoomAuth,
): void {
const player = new PlayerState();
player.playerId = auth.playerId;
player.displayName = auth.displayName;
player.connected = true;
this.state.players.set(client.sessionId, player);
}
messages = {
"player.ready": (
client: Client,
payload: { ready?: boolean },
) => {
const player = this.state.players.get(client.sessionId);
if (!player) {
return;
}
player.ready = payload.ready === true;
this.tryStartMatch();
},
"build.place-node": validate(
placeNodeSchema,
(
client: Client,
payload: z.infer<typeof placeNodeSchema>,
) => {
this.handlePlaceNode(client, payload);
},
),
"system.ping": (
client: Client,
payload: { timestamp?: number },
) => {
client.send("system.pong", {
timestamp: payload.timestamp ?? Date.now(),
serverTime: Date.now(),
});
},
};
async onLeave(
client: Client,
code: number,
): Promise<void> {
const player = this.state.players.get(client.sessionId);
if (!player) {
return;
}
player.connected = false;
const consented = code === 1000;
if (consented) {
this.state.players.delete(client.sessionId);
return;
}
try {
await this.allowReconnection(client, 60);
player.connected = true;
} catch {
this.state.players.delete(client.sessionId);
}
}
onDispose(): void {
// 释放非 Colyseus 管理的资源:
// 数据库连接句柄、订阅、文件、外部计时器等。
}
private tryStartMatch(): void {
if (this.state.phase !== MatchPhase.WAITING) {
return;
}
if (this.state.players.size === 0) {
return;
}
const allReady = Array.from(this.state.players.values())
.every((player) => player.ready);
if (allReady) {
this.state.phase = MatchPhase.RUNNING;
}
}
private handlePlaceNode(
client: Client,
payload: z.infer<typeof placeNodeSchema>,
): void {
if (this.state.phase !== MatchPhase.RUNNING) {
client.send("command.rejected", {
requestId: payload.requestId,
code: "INVALID_PHASE",
});
return;
}
const player = this.state.players.get(client.sessionId);
if (!player) {
return;
}
const cost = 20;
if (this.state.lumen < cost) {
client.send("command.rejected", {
requestId: payload.requestId,
code: "INSUFFICIENT_LUMEN",
});
return;
}
if (this.isPositionBlocked(payload.x, payload.y)) {
client.send("command.rejected", {
requestId: payload.requestId,
code: "POSITION_BLOCKED",
});
return;
}
this.state.lumen -= cost;
const node = new NodeState();
node.id = `node-${this.nextNodeId++}`;
node.ownerId = player.playerId;
node.nodeType = payload.nodeType;
node.x = payload.x;
node.y = payload.y;
this.state.nodes.set(node.id, node);
}
private isPositionBlocked(x: number, y: number): boolean {
for (const node of this.state.nodes.values()) {
const dx = node.x - x;
const dy = node.y - y;
if (dx * dx + dy * dy < 64 * 64) {
return true;
}
}
return false;
}
private updateSimulation(deltaTimeMs: number): void {
if (this.state.phase !== MatchPhase.RUNNING) {
return;
}
this.state.serverTick += 1;
this.state.elapsedSeconds += deltaTimeMs / 1000;
// 后续改为:
// this.simulation.update(deltaTimeMs);
// this.statePresenter.apply(simulationSnapshot, this.state);
}
}
async function verifyRealtimeTicket(
ticket: string | undefined,
): Promise<RoomAuth | null> {
if (!ticket) {
return null;
}
// 示例占位。
// 正式实现应校验:
// - 签名
// - exp
// - scope
// - nonce
// - region
// - 是否已经使用
return {
playerId: `player-${ticket.slice(0, 8)}`,
displayName: "Keeper",
};
}
Colyseus 0.17 的 Room 支持 onAuth、onCreate、onJoin、onLeave 和 onDispose 等生命周期;消息可以使用 Zod 验证,验证失败的消息会被忽略。Room 的 State 应直接修改,而不应重新赋值为另一个对象。
9. 注册服务器
9.1 Bun Transport 配置
// src/app.config.ts
import { defineRoom, defineServer } from "colyseus";
import { BunWebSockets } from "@colyseus/bun-websockets";
import { BattleRoom } from "./rooms/BattleRoom";
const port = Number(process.env.PORT ?? 2567);
export default defineServer({
transport: new BunWebSockets({
port,
hostname: process.env.HOST ?? "0.0.0.0",
}),
rooms: {
battle: defineRoom(BattleRoom),
},
express: (app) => {
app.get("/health/live", (_req, res) => {
res.json({
status: "ok",
service: "realtime",
});
});
app.get("/health/ready", (_req, res) => {
res.json({
status: "ready",
});
});
},
});
Colyseus 通过 @colyseus/bun-websockets 使用 Bun 原生 WebSocket,并提供 Express 兼容层。不过,官方当前仍将 Bun Transport 标记为实验性支持,因此生产前必须完成兼容测试、长时间运行测试和回退验证。
9.2 启动命令
{
"scripts": {
"dev": "colyseus start --watch",
"start": "colyseus start",
"typecheck": "tsc --noEmit",
"test": "bun test"
}
}
启动:
bun run dev
默认连接地址:
http://localhost:2567
WebSocket 会由客户端 SDK 自动处理。
10. Phaser 客户端接入
10.1 网络服务
// apps/client/src/network/RealtimeClient.ts
import {
Callbacks,
Client,
type Room,
} from "@colyseus/sdk";
import type { BattleState } from "@beacon/contracts/schema";
export class RealtimeClient {
private readonly client: Client;
private room: Room<BattleState> | null = null;
constructor(endpoint: string) {
this.client = new Client(endpoint);
}
async joinBattle(options: {
realtimeTicket: string;
mapId: string;
difficulty: number;
}): Promise<Room<BattleState>> {
if (this.room) {
await this.leave();
}
this.room = await this.client.joinOrCreate<BattleState>(
"battle",
options,
);
return this.room;
}
getRoom(): Room<BattleState> {
if (!this.room) {
throw new Error("REALTIME_ROOM_NOT_JOINED");
}
return this.room;
}
send<T>(type: string, payload?: T): void {
this.getRoom().send(type, payload);
}
async leave(): Promise<void> {
const room = this.room;
this.room = null;
if (room) {
await room.leave(true);
}
}
}
10.2 环境地址
const endpoint = import.meta.env.PROD
? "https://play.example.com"
: "http://localhost:2567";
export const realtimeClient = new RealtimeClient(endpoint);
浏览器会自动将:
http → ws
https → wss
正式环境必须使用 HTTPS/WSS。
11. 在 Phaser Scene 中监听状态
// BattleScene.ts
import Phaser from "phaser";
import { Callbacks, type Room } from "@colyseus/sdk";
import type {
BattleState,
NodeState,
PlayerState,
} from "@beacon/contracts/schema";
export class BattleScene extends Phaser.Scene {
private room!: Room<BattleState>;
private playerViews = new Map<string, Phaser.GameObjects.Container>();
private nodeViews = new Map<string, Phaser.GameObjects.Container>();
async create(): Promise<void> {
this.room = await realtimeClient.joinBattle({
realtimeTicket: await this.fetchRealtimeTicket(),
mapId: "seedfall_standard",
difficulty: 1,
});
this.bindRoomState();
this.bindRoomMessages();
this.bindRoomLifecycle();
this.bindInput();
}
private bindRoomState(): void {
const callbacks = Callbacks.get(this.room);
callbacks.onAdd(
"players",
(player: PlayerState, sessionId: string) => {
const view = this.createPlayerView(player);
this.playerViews.set(sessionId, view);
callbacks.listen(player, "connected", (connected) => {
view.setAlpha(connected ? 1 : 0.4);
});
callbacks.listen(player, "ready", (ready) => {
this.updateReadyIndicator(view, ready);
});
},
);
callbacks.onRemove(
"players",
(_player: PlayerState, sessionId: string) => {
this.playerViews.get(sessionId)?.destroy();
this.playerViews.delete(sessionId);
},
);
callbacks.onAdd(
"nodes",
(node: NodeState, nodeId: string) => {
const view = this.createNodeView(node);
this.nodeViews.set(nodeId, view);
callbacks.listen(node, "health", (health) => {
this.updateNodeHealth(view, health);
});
callbacks.listen(node, "powered", (powered) => {
this.updateNodePower(view, powered);
});
},
);
callbacks.onRemove(
"nodes",
(_node: NodeState, nodeId: string) => {
this.nodeViews.get(nodeId)?.destroy();
this.nodeViews.delete(nodeId);
},
);
callbacks.listen("lumen", (value) => {
this.events.emit("hud:lumen", value);
});
callbacks.listen("coreHealth", (value) => {
this.events.emit("hud:core-health", value);
});
callbacks.listen("phase", (value) => {
this.events.emit("match:phase", value);
});
}
private bindRoomMessages(): void {
this.room.onMessage(
"command.rejected",
(message: { requestId: string; code: string }) => {
this.events.emit("command:rejected", message);
},
);
this.room.onMessage(
"game.effect",
(message: {
effectType: string;
x: number;
y: number;
}) => {
this.playEffect(message);
},
);
}
private bindRoomLifecycle(): void {
this.room.onError((code, message) => {
console.error("Colyseus room error", {
code,
message,
});
});
this.room.onLeave((code) => {
console.warn("Left room", { code });
this.scene.start("ReconnectScene");
});
}
private bindInput(): void {
this.input.on(
"pointerdown",
(pointer: Phaser.Input.Pointer) => {
this.room.send("build.place-node", {
requestId: crypto.randomUUID(),
nodeType: 1,
x: pointer.worldX,
y: pointer.worldY,
rotation: 0,
});
},
);
}
private async fetchRealtimeTicket(): Promise<string> {
const response = await fetch("/api/v1/realtime/ticket", {
method: "POST",
credentials: "include",
});
if (!response.ok) {
throw new Error("FAILED_TO_GET_REALTIME_TICKET");
}
const body = await response.json() as { ticket: string };
return body.ticket;
}
private createPlayerView(
_player: PlayerState,
): Phaser.GameObjects.Container {
return this.add.container();
}
private createNodeView(
node: NodeState,
): Phaser.GameObjects.Container {
return this.add.container(node.x, node.y);
}
private updateReadyIndicator(
_view: Phaser.GameObjects.Container,
_ready: boolean,
): void {}
private updateNodeHealth(
_view: Phaser.GameObjects.Container,
_health: number,
): void {}
private updateNodePower(
_view: Phaser.GameObjects.Container,
_powered: boolean,
): void {}
private playEffect(
_message: {
effectType: string;
x: number;
y: number;
},
): void {}
}
Colyseus 0.17 客户端推荐使用 Callbacks.get(room),通过 onAdd、onRemove、listen 和 onChange 监听同步状态。room.state 始终代表客户端当前接收到的最新状态。
12. 视图对象管理
不要在每一帧遍历完整 Colyseus State 后重建 Phaser 对象。
错误:
update(): void {
for (const [id, node] of this.room.state.nodes) {
this.destroyAndRecreateNode(id, node);
}
}
正确:
Schema onAdd
→ 创建 Phaser View
Schema listen/onChange
→ 更新 View 目标值
Schema onRemove
→ 销毁 View
推荐维护:
const nodeViews = new Map<string, NodeView>();
const enemyViews = new Map<string, EnemyView>();
const playerViews = new Map<string, PlayerView>();
View 模型:
interface NetworkTransform {
currentX: number;
currentY: number;
targetX: number;
targetY: number;
}
渲染循环只负责从当前坐标平滑到目标坐标:
view.x = Phaser.Math.Linear(
view.x,
view.targetX,
0.2,
);
13. Tick、Patch 与渲染频率
三类频率必须分开:
Phaser Render Rate
服务端 Simulation Rate
Colyseus Patch Rate
建议初始配置:
Phaser 渲染:60 FPS
服务端模拟:20 Hz
Patch Rate:10 Hz
敌人决策:5 Hz
经济聚合:5 Hz
示例:
export class BattleRoom extends Room {
patchRate = 100;
onCreate(): void {
this.setSimulationInterval(
(deltaTime) => this.updateSimulation(deltaTime),
50,
);
}
}
含义:
Simulation Interval 50 ms = 20 Hz
Patch Rate 100 ms = 10 Hz
不要默认使用 60 Hz 服务端 Tick。
Beacon 这类策略游戏更适合:
- 10~20 Hz 权威模拟。
- 客户端 60 FPS 表现。
- 单位移动使用插值。
- UI 数值使用平滑动画。
- 高频视觉事件不进入 Schema。
官方 Phaser 教程也将固定 Tick、插值和客户端预测作为独立主题处理,而不是将网络 Tick 与渲染帧率绑定。
14. 客户端预测
Beacon 不需要 FPS 级回滚预测,但仍建议对以下操作提供即时表现:
- 建筑放置预览。
- 光缆路径预览。
- 鼠标选择。
- 高亮反馈。
- 技能范围预览。
- 按钮点击反馈。
- 本地角色光标。
- UI 过渡。
放置流程:
1. 玩家点击地图
2. 客户端显示半透明 Pending 建筑
3. 客户端发送 place-node
4. 服务端验证
5A. 成功:Schema 中增加正式 Node
5B. 失败:返回 command.rejected
6. 客户端移除 Pending 建筑
客户端必须通过 requestId 将本地 Pending 操作与服务端响应关联。
15. 鉴权接入
推荐流程:
1. 用户登录 Hono API
2. API 返回 Access Token 或安全 Cookie
3. 客户端请求 POST /v1/realtime/ticket
4. API 签发短期 Ticket
5. 客户端使用 Ticket 加入 Colyseus Room
6. BattleRoom.onAuth 校验 Ticket
7. 服务端把 playerId 写入 auth context
Realtime Ticket 示例:
interface RealtimeTicketPayload {
sub: string;
sessionId: string;
scope: "room:join";
region: string;
nonce: string;
iat: number;
exp: number;
}
建议有效期:
30~120 秒
Ticket 必须验证:
- 数字签名。
- 过期时间。
- Scope。
- Region。
- Nonce。
- 是否已经使用。
- 会话是否撤销。
- 玩家是否被封禁。
客户端传入的以下字段不可直接信任:
playerId
displayName
role
level
inventory
entitlements
admin
16. 断线重连
16.1 服务端
async onLeave(
client: Client,
code: number,
): Promise<void> {
const player = this.state.players.get(client.sessionId);
if (!player) {
return;
}
player.connected = false;
const normalLeave = code === 1000;
if (normalLeave) {
this.removePlayer(client.sessionId);
return;
}
try {
await this.allowReconnection(client, 60);
player.connected = true;
} catch {
this.removePlayer(client.sessionId);
}
}
allowReconnection() 必须在 onLeave() 中调用,用于保留断线玩家的席位并等待其恢复连接。
16.2 客户端保存重连信息
加入房间后保存:
localStorage.setItem(
"colyseus.reconnectionToken",
room.reconnectionToken,
);
重连:
const token = localStorage.getItem(
"colyseus.reconnectionToken",
);
if (token) {
try {
const room = await client.reconnect(token);
return room;
} catch {
localStorage.removeItem(
"colyseus.reconnectionToken",
);
}
}
16.3 推荐行为
| 断线时间 | 推荐行为 |
|---|---|
| 0~10 秒 | 保留玩家,立即重连 |
| 10~60 秒 | 保留席位,AI 或安全状态接管 |
| 超过 60 秒 | 移除玩家或转为永久 AI |
| 主动退出 | 不等待重连 |
| 服务进程崩溃 | 标记比赛中止或从快照恢复 |
需要明确:
Redis Presence 不能自动恢复已经崩溃进程中的 Room 内存状态。
要实现 Room 崩溃恢复,需要额外实现:
- 周期快照。
- 输入日志。
- Room 恢复。
- 确定性模拟。
- 对局迁移。
17. 战争迷雾与私有状态
不要把所有敌人同步到客户端后再由客户端隐藏。
错误:
服务端发送全部敌人
客户端判断是否显示
攻击者仍可以读取内存中的敌人数据。
正确:
服务端计算视野
只同步客户端允许看到的数据
可选方案:
方案一:团队共享视野
BattleState.visibleEnemies
所有合作玩家共享同一组可见敌人。
优点:
- 实现简单。
- Patch 小。
- 适合合作 Beacon。
方案二:State View
根据客户端身份过滤同步字段。
适合:
- 对抗模式。
- 玩家私有手牌。
- 玩家独立视野。
- 隐藏单位。
方案三:私有 Message
服务端单独向玩家发送短期私有信息。
适合:
- 临时侦察结果。
- 私人提示。
- 私有选择。
- 非长期状态。
18. 房间匹配方式
客户端常见方法:
await client.create("battle", options);
await client.join("battle", options);
await client.joinOrCreate("battle", options);
await client.joinById(roomId, options);
推荐使用场景:
| 方法 | 场景 |
|---|---|
create | 玩家明确创建私人房间 |
join | 加入符合筛选条件的现有房间 |
joinOrCreate | 快速匹配 |
joinById | 好友邀请码或房间 ID |
Beacon 推荐:
快速合作
→ joinOrCreate
私人房间
→ create + joinById
好友邀请
→ 保存 roomId + joinById
大厅列表
→ LobbyRoom 或业务 API
19. 房间元数据
房间元数据用于匹配和大厅展示:
onCreate(options: {
mapId?: string;
difficulty?: number;
private?: boolean;
}): void {
this.setMetadata({
mapId: options.mapId ?? "seedfall_standard",
difficulty: options.difficulty ?? 1,
private: options.private ?? false,
phase: "waiting",
});
}
开始比赛后:
await this.setMetadata({
...this.metadata,
phase: "running",
});
匹配时可以根据:
- 地图。
- 难度。
- 游戏模式。
- 地区。
- 是否私人。
- 房间阶段。
- 玩家等级范围。
进行筛选。
不要把大量动态战斗状态写入 metadata。
20. Redis 多实例扩展
单进程开发环境可以使用:
LocalPresence
LocalDriver
多进程或多机器必须使用共享 Presence 和 Driver。Presence 用于实例间通信和共享键值数据;Driver 用于保存和查询匹配所需的房间信息。
20.1 安装
bun add @colyseus/redis-presence
bun add @colyseus/redis-driver
20.2 配置
import { defineRoom, defineServer } from "colyseus";
import { BunWebSockets } from "@colyseus/bun-websockets";
import { RedisPresence } from "@colyseus/redis-presence";
import { RedisDriver } from "@colyseus/redis-driver";
import { BattleRoom } from "./rooms/BattleRoom";
const redisUrl = process.env.REDIS_URL;
if (!redisUrl) {
throw new Error("REDIS_URL_IS_REQUIRED");
}
export default defineServer({
transport: new BunWebSockets({
port: Number(process.env.PORT ?? 2567),
hostname: "0.0.0.0",
}),
presence: new RedisPresence(redisUrl),
driver: new RedisDriver(redisUrl),
rooms: {
battle: defineRoom(BattleRoom),
},
});
RedisDriver 是当前官方推荐的多进程生产 Driver;PostgreSQL Drizzle Driver 当前仍被标记为实验性,Mongo Driver 则不建议用于新的生产项目。
20.3 Redis 职责
Redis 中保存:
- 房间查询信息。
- 实例间 Presence。
- Pub/Sub。
- 短期 Ticket。
- 限流计数。
- 临时锁。
- 可重建缓存。
Redis 中不应保存:
- 唯一永久存档。
- 唯一结算记录。
- 唯一购买记录。
- 唯一用户资料。
- 无法重建的重要数据。
21. 反向代理与多实例路由
Colyseus 每个 Room 属于一个具体进程,客户端连接需要到达创建该 Room 的实例。简单的轮询负载均衡不足以保证正确的 WebSocket 路由。官方扩展模型也明确指出,增加进程会增加能够承载的 Room 数量,但单个 Room 不会自动分布到多个进程。
生产拓扑:
Client
│
▼
Dynamic Proxy
├── realtime-1
├── realtime-2
└── realtime-3
│
├── Redis Presence
└── Redis Driver
推荐:
- Traefik 动态路由。
- Colyseus Cloud。
- 自建能够识别实例地址的代理层。
官方提供了基于 Traefik 的扩展方案,要求共享 Redis Presence,并通过动态 Provider 管理实例路由。
22. 数据持久化
Colyseus State 是运行时状态,不是永久数据库。
推荐流程:
房间创建
→ 加载玩家和关卡配置
房间运行
→ 内存权威状态
每 15~30 秒
→ 保存恢复快照,可选
比赛结束
→ 生成 MatchResult
MatchResult
→ Hono 内部 API 或 Outbox
Persistence Worker
→ PostgreSQL 幂等写入
结算结构:
interface MatchResult {
matchId: string;
settlementVersion: number;
status: "victory" | "defeat" | "aborted";
startedAt: string;
endedAt: string;
durationSeconds: number;
mapId: string;
difficulty: number;
players: Array<{
playerId: string;
reward: number;
score: number;
}>;
}
幂等唯一键:
match_id + settlement_version
不要在每次:
- 敌人移动。
- 子弹命中。
- Lumen 增减。
- 节点状态变化。
时写 PostgreSQL。
23. 优雅关闭
服务器发布时应:
1. 停止创建新房间
2. Readiness 返回不可用
3. 等待现有房间结束
4. 保存必要快照
5. 通知客户端
6. 关闭连接
7. 进程退出
Room 的 onDispose() 负责:
- 取消外部订阅。
- 停止非 Room Clock 的定时器。
- 释放资源。
- 提交最终结算。
- 写入异常结束状态。
不要只依赖:
process.on("SIGTERM", () => process.exit(0));
直接退出会丢失:
- 未提交结算。
- 最后快照。
- 房间清理。
- 客户端错误提示。
24. 错误处理
24.1 客户端错误类别
export type RealtimeErrorCode =
| "AUTH_FAILED"
| "ROOM_NOT_FOUND"
| "ROOM_FULL"
| "MATCH_STARTED"
| "VERSION_MISMATCH"
| "RATE_LIMITED"
| "NETWORK_ERROR"
| "RECONNECT_FAILED";
24.2 房间命令错误
服务端不要直接发送内部异常栈:
错误:
client.send("error", error.stack);
正确:
client.send("command.rejected", {
requestId,
code: "POSITION_BLOCKED",
});
日志中记录详细信息:
logger.error({
event: "place_node_failed",
roomId: this.roomId,
sessionId: client.sessionId,
playerId,
error,
});
客户端只收到稳定错误码。
25. 安全要求
25.1 消息验证
每个客户端消息必须验证:
- 数据类型。
- 数值范围。
- 字符串长度。
- 数组长度。
- 对象层级。
- 请求频率。
- 当前状态。
- 玩家权限。
Colyseus 0.17 支持通过 validate() 与 Zod 对消息输入执行验证。
25.2 Payload 限制
必须配置并验证:
- 最大 WebSocket 消息大小。
- 每秒最大命令数量。
- 每个命令最大数组长度。
- Join Options 大小。
- 房间创建频率。
- 重连频率。
- Ping 频率。
25.3 禁止信任客户端时间
错误:
const cooldownComplete =
payload.clientTime > ability.readyAt;
正确:
const cooldownComplete =
this.clock.elapsedTime >= ability.readyAt;
25.4 防重复请求
private processedRequests = new Map<string, number>();
private isDuplicate(requestId: string): boolean {
if (this.processedRequests.has(requestId)) {
return true;
}
this.processedRequests.set(
requestId,
Date.now(),
);
return false;
}
需要定期清理旧 requestId,避免无限增长。
26. 性能优化
26.1 控制 Schema 规模
不要同步:
- 静态地图 Tile。
- 完整技能配置。
- 完整敌人配置。
- 不可见敌人。
- 服务端 AI 中间状态。
- 路径搜索节点。
- 调试信息。
- 历史事件列表。
客户端提前加载静态配置:
mapId
enemyType
nodeType
abilityId
State 只同步动态值。
26.2 降低高频字符串
错误:
enemy.status = "moving_towards_beacon_core";
正确:
enemy.status = EnemyStatus.MOVING;
26.3 子系统降频
基础模拟:20 Hz
敌人目标选择:5 Hz
路径重算:2 Hz
经济统计:5 Hz
排行榜统计:1 Hz
数据库快照:每 15 秒
26.4 对象数量预算
P0 应明确压力预算:
每 Room 玩家:4
每 Room 节点:100
每 Room 建筑:200
每 Room 可见敌人峰值:500
每 Room 同步对象峰值:800
然后通过负载测试验证,而不是凭语言性能推断。
27. 单元测试
推荐将主要游戏逻辑放在纯模拟层:
export class BattleSimulation {
constructor(
private readonly random: RandomSource,
) {}
update(deltaMs: number): void {
// 纯游戏规则
}
placeNode(command: PlaceNodeCommand): CommandResult {
// 验证和状态修改
}
}
测试:
import { describe, expect, test } from "bun:test";
describe("BattleSimulation.placeNode", () => {
test("资源不足时拒绝建造", () => {
const simulation = createSimulation({
lumen: 10,
});
const result = simulation.placeNode({
playerId: "player-1",
nodeType: 1,
x: 100,
y: 100,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(
"INSUFFICIENT_LUMEN",
);
}
});
});
Room 测试重点:
- 生命周期。
- 消息路由。
- 鉴权。
- 重连。
- State 映射。
- 错误响应。
Simulation 测试重点:
- 游戏规则。
- 数值计算。
- 随机逻辑。
- 战斗结果。
- 能量网络。
- 胜负判定。
28. 集成测试
最低测试流程:
1. 启动测试服务器
2. 创建第一个客户端
3. joinOrCreate battle
4. 创建第二个客户端
5. 加入同一房间
6. 两个玩家 Ready
7. 检查 phase 变为 RUNNING
8. 玩家发送 place-node
9. 检查 nodes 增加
10. 检查 lumen 减少
11. 发送非法命令
12. 检查 command.rejected
13. 模拟断线
14. 完成重连
15. 结束房间
不要只测试:
WebSocket 能连接
必须测试实际房间行为。
29. 负载测试
负载场景:
场景 A:连接压力
1,000 个客户端
每个客户端保持连接
不发送游戏命令
检查:
- 内存。
- 心跳。
- Event Loop Lag。
- 重连风暴。
场景 B:真实游戏压力
100 个 Room
每 Room 4 人
每秒每玩家 2 个命令
每 Room 300 个敌人
Simulation 20 Hz
Patch 10 Hz
检查:
- Tick P95。
- Patch 大小。
- CPU。
- 内存。
- 网络出口。
- Redis 延迟。
场景 C:异常压力
大量非法消息
超大 Payload
重复请求
频繁加入退出
Redis 短暂不可用
单实例重启
Colyseus 官方提供 @colyseus/loadtest 和 @colyseus/testing,适合纳入 CI 和发布前性能验证。
30. 可观测性
日志字段:
timestamp
level
service
environment
version
region
instanceId
roomId
matchId
sessionId
playerId
requestId
event
durationMs
errorCode
核心指标:
colyseus_active_connections
colyseus_active_rooms
colyseus_room_create_total
colyseus_room_dispose_total
colyseus_join_failure_total
colyseus_reconnect_success_total
colyseus_reconnect_failure_total
colyseus_tick_duration_ms
colyseus_patch_bytes
colyseus_messages_total
colyseus_command_rejected_total
runtime_event_loop_lag_ms
runtime_heap_bytes
游戏指标:
match_duration_seconds
match_completion_total
match_abort_total
enemy_peak_count
node_peak_count
lumen_generated_total
lumen_spent_total
core_damage_total
31. 环境变量
NODE_ENV=development
HOST=0.0.0.0
PORT=2567
REDIS_URL=redis://localhost:6379/0
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/beacon
REALTIME_TICKET_SECRET=replace-me
INTERNAL_API_URL=http://api:3000
INTERNAL_API_TOKEN=replace-me
LOG_LEVEL=debug
REGION=local
BUILD_VERSION=dev
配置读取:
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum([
"development",
"test",
"production",
]),
PORT: z.coerce.number().int().positive(),
REDIS_URL: z.string().url(),
REALTIME_TICKET_SECRET: z.string().min(32),
REGION: z.string().min(1),
});
export const env = envSchema.parse(
process.env,
);
生产环境不允许:
- 使用默认 Secret。
- 缺少 Redis 时自动退回内存模式。
- 环境变量校验失败后继续启动。
- 日志打印完整 Token。
- 将 Secret 放入客户端 Bundle。
32. Docker 化
FROM oven/bun:1 AS base
WORKDIR /app
COPY package.json bun.lock ./
COPY apps/realtime/package.json ./apps/realtime/
COPY packages ./packages
RUN bun install --frozen-lockfile
COPY apps/realtime ./apps/realtime
COPY tsconfig.json ./
WORKDIR /app/apps/realtime
ENV NODE_ENV=production
ENV PORT=2567
EXPOSE 2567
CMD ["bun", "run", "start"]
健康检查:
HEALTHCHECK \
--interval=10s \
--timeout=3s \
--retries=3 \
CMD bun -e \
"const r=await fetch('http://localhost:2567/health/live'); process.exit(r.ok?0:1)"
33. 开发阶段接入顺序
P0:基础连通
- 安装 Colyseus。
- 创建 BattleRoom。
- 创建简单 BattleState。
- Phaser 加入房间。
- 同步一个对象。
- 监听加入和离开。
验收:
两个浏览器能够进入同一房间
State 能够同步
玩家离开后对象删除
P1:服务端权威
- 输入消息。
- 服务端验证。
- 权威资源。
- 权威建造。
- 命令拒绝。
- requestId。
验收:
修改客户端资源不能作弊
非法坐标被拒绝
重复请求不重复执行
P2:重连与鉴权
- Hono Ticket。
onAuth。allowReconnection。- 客户端 reconnect。
- 封禁检查。
- 版本检查。
验收:
断线 60 秒内恢复
过期 Ticket 无法加入
伪造 playerId 无效
P3:多实例
- RedisPresence。
- RedisDriver。
- 动态代理。
- 两个 Realtime 实例。
- 发布排空。
- 负载测试。
验收:
不同实例可以共享房间查询
客户端能连接到房间所属实例
停止一个实例不影响其他实例的新房间
34. 常见错误
错误一:客户端直接修改 State
room.state.lumen = 9999;
客户端即使修改本地对象,也不会修改服务端权威状态。
正确方式:
room.send("build.place-node", payload);
错误二:Room 内写全部游戏代码
BattleRoom.ts
超过 5,000 行
应拆分:
Room
Command
Simulation
System
Repository
Presenter
错误三:同步全部地图敌人
会破坏战争迷雾并暴露隐藏信息。
应由服务端过滤。
错误四:每帧发送鼠标坐标
策略游戏通常没有必要每个渲染帧发送一次输入。
可以:
位置显著变化时发送
固定 10~20 Hz 发送
停止移动时发送最终坐标
错误五:多实例仍使用 LocalPresence
结果:
- 实例之间看不到房间。
- 匹配结果错误。
- 房间查询不完整。
- 跨实例通信失败。
错误六:把 Redis 当永久数据库
Redis 故障后可能导致重要数据丢失。
永久数据进入 PostgreSQL。
错误七:认为多实例能拆分单个 Room
Colyseus 的扩展方式主要是增加可承载的 Room 数量。单个 Room 仍归属于一个进程。
错误八:Bun 实验性支持不做回退
正式上线前必须:
- 固定 Bun 版本。
- 完成长时间运行测试。
- 测试 Redis。
- 测试 Graceful Shutdown。
- 保留 Node.js 运行适配能力。
35. 最终验收清单
基础连接
- Phaser 能连接 Realtime 服务。
- 能创建和加入 BattleRoom。
- HTTPS/WSS 正常。
- 房间满员时返回明确错误。
- 版本不一致时拒绝加入。
状态同步
- 玩家添加和删除正常。
- Node 添加和删除正常。
- 属性变化正常。
- Patch Rate 明确。
- 不同步隐藏敌人。
- 不同步无用静态配置。
服务端权威
- 资源由服务端计算。
- 放置由服务端验证。
- 伤害由服务端判定。
- 胜负由服务端判定。
- 重复请求具备幂等性。
- 每条命令有速率限制。
鉴权
- 使用短期 Realtime Ticket。
-
onAuth校验签名和过期时间。 - 客户端 playerId 不可信。
- Ticket 可撤销或一次性使用。
- 日志不包含敏感 Token。
重连
- 异常断线进入重连窗口。
- 主动退出不进入重连窗口。
- 客户端保存 reconnection token。
- 重连失败有降级界面。
- 超时后玩家正确移除。
多实例
- 使用 RedisPresence。
- 使用 RedisDriver。
- 动态代理正确路由。
- 两个实例可以共同匹配。
- Redis 故障有明确行为。
- 单 Room 不依赖跨进程计算。
测试
- Simulation 单元测试。
- Room 集成测试。
- 双客户端 E2E。
- 重连测试。
- 非法 Payload 测试。
- 真实负载测试。
- 24 小时 Soak Test。
运维
- Liveness。
- Readiness。
- 结构化日志。
- Tick 指标。
- Patch 大小指标。
- Event Loop Lag。
- Graceful Shutdown。
- 版本排空流程。
36. 推荐的最终实现边界
apps/realtime/src/rooms/BattleRoom.ts
只负责:
- 生命周期。
- 鉴权。
- 消息注册。
- 玩家连接。
- 调用 CommandDispatcher。
- 调用 Simulation。
- State 映射。
packages/game-core
负责:
- 游戏规则。
- 数值计算。
- 能量网络。
- 战斗。
- 波次。
- 敌人。
- 胜负。
- 确定性随机。
apps/client/src/network
负责:
- Client。
- Room。
- Join。
- Reconnect。
- Callbacks。
- 消息发送。
- 错误映射。
apps/client/src/scenes
负责:
- Phaser 对象。
- 动画。
- UI。
- 插值。
- 输入。
- 预测表现。
apps/api
负责:
- 登录。
- Realtime Ticket。
- 用户资料。
- 永久存档。
- 比赛历史。
- 排行榜。
最终数据流:
Phaser Input
→ Colyseus Message
→ Message Validation
→ Command Dispatcher
→ BattleSimulation
→ Authoritative State
→ Schema Patch
→ Phaser Callback
→ View Update
这是整个 Colyseus 接入中最重要、也最应长期保持稳定的主链路。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。