Colyseus完整接入指南:基于Phaser 4、Bun与Hono构建实时联机游戏

系统讲解Colyseus在Phaser 4游戏项目中的完整接入流程,涵盖Room生命周期、Schema状态同步、消息协议、服务端权威、身份认证、断线重连、Redis多实例扩展、数据持久化、测试与生产部署。

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 支持 onAuthonCreateonJoinonLeaveonDispose 等生命周期;消息可以使用 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),通过 onAddonRemovelistenonChange 监听同步状态。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 接入中最重要、也最应长期保持稳定的主链路。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页