Skip to content

匹配与开房

匹配相关逻辑集中在房间 matchmaker_room,服务端实现类为 MatchmakerRoom

用途使用的消息
大厅排队(自动匹配)match:findmatch:cancel
房间码组队party:*

成局后服务端统一下发 match:found;客户端用 joinById(roomId, joinOptions) 进入 game_room(默认绑定 GameRoomExample,见 帧同步与游戏房)。

本地联调:启动服务后打开 MatchmakingDemo.html,流程说明见 多 V 多自动匹配 · 先用浏览器跑通

客户端接入步骤

按顺序实现即可;换玩法通常只改请求字段,不改变「收 match:foundjoinById」这一段。

  1. joinOrCreate("matchmaker_room", { token }),等待 mm:ready
  2. 发送 match:find(排队)或 party:*(组队);字段含义见下文「主动匹配」「被动开房」两节。
  3. 监听 match:found,用 roomIdjoinOptions 调用 joinById 进入 game_room(多数项目需在 options 中再次带上 token,与 鉴权 一致)。

统一契约与各场景

所有成局路径的终点相同:match:foundjoinByIdgame_room

场景客户端动作工程含义
大厅排队match:find / match:cancelmodeId + playersPerMatch + region 与可选 skill / tags 共同决定排队落在哪条队列(详见下文 队列维度与 region);match:cancelmatch:cancelled 对应取消结果。
Partyparty:createparty:start与排队共用 match:found,客户端可共用同一套进局代码;房主与人数限制由服务端校验。
人数playersPerMatch(2~100)凑满几人成局,且即开局时创建的 game_room 本局玩家总数(主动排队与 Party 同义)。常见写法:1v1 → 22v2 → 44v4 → 8,依此类推,上限 100。同一套消息承载不同人数;队列如何分段见 MatchmakingService
多实例客户端仍只连接 matchmaker_room队列与 match:found 跨进程投递依赖 Redis(含频道 colyseus:mm:notify);详见「前置条件」。
进对局合并 joinOptionsjoinByIdmatchIdseatIndexreconnectKey 等与 帧同步与游戏房 契约对齐;对局房在 onJoin 中校验 options。
鉴权匹配房与对局房均使用 JWT鉴权 一致;成局参数由服务端写入 joinOptions,客户端勿自行编造 seatIndex / matchId

小结:多个入口(随机匹配、房间码等)可以共用同一 onMessage("match:found") + joinById 分支,日志与监控也可围绕 match:queued / match:found / 进房失败 统一打点。

前置条件

  • 客户端使用 joinOrCreate("matchmaker_room", options),options 与 鉴权 一致:需携带有效 JWT(tokenaccessToken)。
  • 服务端需 Redis 可用:排队、分布式锁、跨实例 match:found 通知均依赖 Redis;Redis 不可用时,同进程内仍可收到匹配结果,多实例场景下可能无法把结果投递到其它节点上的客户端。

加入成功后,服务端会向该客户端发送 mm:ready,payload 含 sessionId

主动匹配(排队)

客户端发送 match:find,body 要点(与源码 MatchFindRequest 一致):

字段说明
modeId玩法 / 模式标识,默认 "default"
playersPerMatch本局玩家总数(整数 2~100)。队列凑满该人数后创建 game_room 并下发 match:found,对局房规模与此一致。典型:1v1 填 22v2 填 44v4 填 8,以此类推。与 Party 的 party:create 中同名字段语义相同。
region分区字符串,与 modeIdplayersPerMatch 拼成一条逻辑队列;不表示客户端自动按地理位置选服,含义完全由业务约定。未传时服务端通常按 global 入队。详见 队列维度与 region
skill可选,预留段位或匹配分;是否参与「分桶 / 扩队列」以服务端 MatchmakingService 实现为准(可能只挂在票据上供后续扩展)。
tags可选,字符串标签等;同上,是否并入 Redis 排队键以源码为准。

队列维度与 region

路人 match:find 时,只有 排队键一致 的玩家才会被凑成同一局。实现上常见 Redis 队列键形如 queue:{modeId}:{playersPerMatch}:{region}(与 多 V 多自动匹配 · 三个参数 一致;精确格式以仓库 MatchmakingService 为准)。

region 一起决定「排哪条队」的字段作用(直观理解)
modeId哪种玩法 / 哪条模式池。"ranked""casual""duel" 互不混排。
playersPerMatch这一局要凑几个人。4 人与 10 人即使用同一 modeId,也会因人数不同而 不同队列
region在「同玩法、同人数」下再切一刀。"global" = 全服一条队;"cn" / "na" = 按运营划区;行会 ID、赛事服 ID、分片号 = 只和同一字符串的人匹配,避免路人混进行会内战池。
skill(可选)协议预留;若服务端把它纳入队列维度,则相近分段才会同桌(具体看实现)。
tags(可选)协议预留;若实现把标签算进键或过滤条件,则用于跨模式标签匹配等扩展。

记法:把 region 想成「队列名的第三段后缀」,而不是地图 API 里的「省市区」。不传时多数实现会落成 global,与显式传 "global" 通常等价。

Party(party:create:同样携带 modeIdplayersPerMatchregion,语义与主动匹配一致——便于队伍数据与后续 game_room 落在同一套模式 / 分区标签下;好友建房时请与约定玩法使用 同一 region 字符串,避免「创建了房但统计或扩展逻辑按区过滤时对不上号」。

match:queued 里的 queueKey:服务端可把当前票据所在的队列标识回给客户端,便于日志与调试;取消排队 match:cancel 应在 同一 matchmaker_room 连接 上发送,以便摘除正确票据。

服务端行为概要:

  1. 写入 Redis(队列 ZSET + 票据 KV),回 match:queued(含 queueKey)。
  2. 用短锁 + Lua 从队列头取出 playersPerMatch 张票据(该值即本局 game_room 玩家总数),组成 matchId 并创建 game_room
  3. 向相关客户端发 match:found,并通过 Redis 频道 colyseus:mm:notify 广播,保证多进程节点也能收到。

取消排队:发送 match:cancel,服务端回 match:cancelledok 表示是否成功移除)。

被动开房(Party / 房间码)

消息方向说明
party:create客户端 → 服务端body:modeIdplayersPerMatch2~100:队伍满员人数,房主仅在达到该人数后可 party:start同时也是随后创建的 game_room 玩家规模,与 match:find 同义;例 1v1=2、2v2=4、4v4=8)、可选 region(与 match:findregion 同义,见 队列维度与 region)。
party:created服务端 → 客户端partyIdpartyCode(6 位易读码)、modeIdplayersPerMatchleaderUserIdisLeader 等。
party:join客户端 → 服务端body:partyCode(不区分大小写,服务端会 trim 并转大写)。
party:joined服务端 → 客户端当前人数 countplayersPerMatch、房主信息等。
party:update服务端 → 客户端成员变化时向在线成员广播人数与房主标识。
party:leave客户端 → 服务端离开队伍;若队伍为空则关闭 party。
party:left服务端 → 客户端ok
party:start客户端 → 服务端仅房主可发;人数达到 playersPerMatch 后创建 game_room 并下发 match:found,随后关闭 party。
party:error服务端 → 客户端业务错误说明,如房间码无效、非房主开始、人数不足等。

Party 数据在 Redis 中有 TTL(与实现一致,约 30 分钟量级),过期后房间码失效。

match:found 与进入 game_room

匹配成功或 party 开局后,客户端会收到 match:found,核心字段:

字段说明
roomName固定 game_room
roomIdColyseus 房间 ID,用于 joinById
matchId对局 ID。
seatIndex座位序号。
reconnectKey断线重连校验用(见 帧同步与游戏房)。
joinOptions建议原样作为加入 game_room 的 options(含 matchIdseatIndexreconnectKey 等)。

推荐客户端按同一顺序实现(与上文「客户端接入步骤」一致):

  1. joinOrCreate("matchmaker_room", { token })
  2. 发送 match:find 或 Party 系列消息
  3. 监听 match:found
  4. joinById(roomId, { ...joinOptions, token })(若项目封装已在底层附带鉴权字段,可按封装省略重复 token

服务端源码位置

MatchmakerRoom 完整实现不在本文粘贴,请直接读服务端仓库:

生命周期与消息注册顺序见 生命周期

继承 MatchmakerRoom(服务端扩展)

父类已注册 match:*party:*、Redis 通知桥与 match:found 投递。子类扩展时,先执行 super.onCreate(options),再叠加自定义逻辑。

约束:tryMatchdeliverNotifications 等为 private,子类不能覆盖。涉及凑桌算法、队列维度、Redis 数据结构时,请改 MatchmakingService 或复制改写 MatchmakerRoom

下面给三个分开的扩展示例:按段位主动匹配被动开房邀约匹配主动开房匹配

示例 A:按段位主动匹配(match:find

目标:把客户端传来的段位分,按区间映射到 skill,再交给父类现有排队流程。

游戏场景:实时竞技排位(如 1v1 天梯、3v3 赛季)、需要按段位或隐藏分分池,优先保证对局公平性。

typescript
// src/rooms/RankedMatchmakerRoom.ts
import { Client } from "colyseus";
import { MatchmakerRoom } from "./MatchmakerRoom";

function bucketByRating(rating: number): string {
  if (rating < 1200) return "bronze";
  if (rating < 1800) return "silver";
  if (rating < 2400) return "gold";
  return "diamond";
}

export class RankedMatchmakerRoom extends MatchmakerRoom {
  onCreate(options: any) {
    super.onCreate(options);

    // 新增一个入口消息,内部转发到父类已支持的 match:find
    this.onMessage("ranked:find", (client: Client, body: any) => {
      const rating = Number(body?.rating ?? 0);
      const skill = bucketByRating(Number.isFinite(rating) ? rating : 0);
      client.send("ranked:accepted", { skill });

      client.send("mm:hint", {
        forwardTo: "match:find",
        payload: {
          modeId: body?.modeId || "ranked",
          playersPerMatch: body?.playersPerMatch ?? 2,
          region: body?.region || "global",
          skill, // 进入父类排队维度
          tags: body?.tags ?? [],
        },
      });
    });
  }
}

说明:生产里通常直接在客户端发 match:find 并带 skill。这里示例 ranked:find 是为了演示“继承后增加业务入口,再落回统一匹配协议”。

客户端最小示例(对应 A)

ts
import Colyseus from "colyseus.js";

const client = new Colyseus.Client("ws://127.0.0.1:2567");
const token = "YOUR_JWT_ACCESS_TOKEN";

async function runRanked() {
  // 连接到示例 A 对应的房间名
  const mm = await client.joinOrCreate("matchmaker_ranked_room", { token });

  // 所有扩展入口最终仍收敛到 match:found
  const found = await new Promise<any>((resolve) => {
    mm.onMessage("match:found", resolve);
    mm.send("ranked:find", {
      rating: 1670,
      modeId: "ranked",
      playersPerMatch: 2,
      region: "global",
    });
  });

  const game = await client.joinById(found.roomId, { ...found.joinOptions, token });
  console.log("ranked joined:", game.roomId, found.matchId);
}
csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;

public static class RankedClientExample
{
    public static async Task Run(Colyseus.Client client, string token)
    {
        // 1) 连接到 A 场景房间(按段位入口)
        var mm = await client.JoinOrCreate<DynamicSchema>(
            "matchmaker_ranked_room",
            new Dictionary<string, object> { { "token", token } });

        // 2) 先挂 match:found,再发 ranked:find(避免先到消息被漏接)
        var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
        mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));

        // 3) 发起段位匹配请求
        mm.Send("ranked:find", new Dictionary<string, object> {
            { "rating", 1670 },
            { "modeId", "ranked" },
            { "playersPerMatch", 2 },
            { "region", "global" },
        });

        // 4) 收到成局后进入 game_room(joinOptions 原样合并 token)
        var found = await foundTcs.Task;
        var roomId = found["roomId"]?.ToString();
        var joinOptions = found["joinOptions"] as Dictionary<string, object> ?? new Dictionary<string, object>();
        var gameOptions = new Dictionary<string, object>(joinOptions) { { "token", token } };
        var game = await client.JoinById<DynamicSchema>(roomId, gameOptions);
        Console.WriteLine($"ranked joined: {game.RoomId}");
    }
}
gdscript
var _client: Colyseus.Client
var _mm: Colyseus.Room
var _token := "YOUR_JWT_ACCESS_TOKEN"

func run_ranked() -> void:
	# 1)连接到 A 场景房间(按段位入口)
	_client = Colyseus.Client.new("ws://127.0.0.1:2567")
	_mm = _client.join_or_create("matchmaker_ranked_room", {"token": _token})
	if _mm:
		# 2)先绑定消息处理,再发送 ranked:find,避免漏接 match:found
		_mm.message_received.connect(_on_ranked_message)
		_mm.send_message("ranked:find", {
			"rating": 1670,
			"modeId": "ranked",
			"playersPerMatch": 2,
			"region": "global",
		})

func _on_ranked_message(type: Variant, data: Variant) -> void:
	if str(type) == "match:found" and typeof(data) == TYPE_DICTIONARY:
		# 3)统一收口:收到 match:found 后 join_by_id 进入 game_room
		var d: Dictionary = data
		var opts := {"token": _token}
		var join_options: Variant = d.get("joinOptions", {})
		if typeof(join_options) == TYPE_DICTIONARY:
			for k in (join_options as Dictionary):
				opts[k] = (join_options as Dictionary)[k]
		_client.join_by_id(str(d.get("roomId", "")), opts)

示例 B:被动开房邀约匹配(party:join

目标:对受邀加入者做额外校验(例如 inviteToken),校验通过再允许继续 Party 流程。

游戏场景:好友邀约组队(如副本车队、工会活动、主播开黑房)、需要“仅受邀可进”与房间码并存。

typescript
// src/rooms/InviteMatchmakerRoom.ts
import { Client } from "colyseus";
import { MatchmakerRoom } from "./MatchmakerRoom";

function verifyInviteToken(token: string): boolean {
  // 按你的业务替换:JWT、签名串、数据库白名单等
  return token.length >= 16;
}

export class InviteMatchmakerRoom extends MatchmakerRoom {
  onCreate(options: any) {
    super.onCreate(options);

    this.onMessage("invite:join", (client: Client, body: any) => {
      const inviteToken = String(body?.inviteToken || "");
      if (!verifyInviteToken(inviteToken)) {
        client.send("party:error", { message: "邀约无效或已过期" });
        return;
      }

      // 校验通过后,引导客户端按既有协议执行 party:join
      client.send("mm:hint", {
        forwardTo: "party:join",
        payload: { partyCode: String(body?.partyCode || "").toUpperCase() },
      });
    });
  }
}

说明:这里把“受邀资格”与“加入 Party”拆开;Party 成员管理、party:updateparty:start 仍走父类逻辑。

客户端最小示例(对应 B)

ts
import Colyseus from "colyseus.js";

const client = new Colyseus.Client("ws://127.0.0.1:2567");
const token = "YOUR_JWT_ACCESS_TOKEN";

async function runInviteJoin() {
  // 连接到示例 B 对应的房间名
  const mm = await client.joinOrCreate("matchmaker_invite_room", { token });

  // 1) 邀约校验入口
  mm.send("invite:join", {
    partyCode: "ABCD23",
    inviteToken: "INVITE_TOKEN_123456",
  });

  // 2) 若服务端返回 mm:hint -> party:join,则按提示继续
  mm.onMessage("mm:hint", (hint: any) => {
    if (hint?.forwardTo === "party:join") {
      mm.send("party:join", hint.payload);
    }
  });

  // 3) 人满并由房主 party:start 后,统一收到 match:found
  const found = await new Promise<any>((resolve) => mm.onMessage("match:found", resolve));
  const game = await client.joinById(found.roomId, { ...found.joinOptions, token });
  console.log("invite joined:", game.roomId, found.matchId);
}
csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;

public static class InviteClientExample
{
    public static async Task Run(Colyseus.Client client, string token)
    {
        // 1) 连接到 B 场景房间(邀约入口)
        var mm = await client.JoinOrCreate<DynamicSchema>(
            "matchmaker_invite_room",
            new Dictionary<string, object> { { "token", token } });

        // 2) 监听 mm:hint,按服务端提示转发到 party:join
        mm.OnMessage<Dictionary<string, object>>("mm:hint", hint =>
        {
            if (hint.TryGetValue("forwardTo", out var f) && f?.ToString() == "party:join")
            {
                var payload = hint["payload"] as Dictionary<string, object> ?? new Dictionary<string, object>();
                mm.Send("party:join", payload);
            }
        });

        // 3) 先挂 match:found,再发 invite:join
        var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
        mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));

        mm.Send("invite:join", new Dictionary<string, object> {
            { "partyCode", "ABCD23" },
            { "inviteToken", "INVITE_TOKEN_123456" },
        });

        // 4) 收到成局后进入 game_room
        var found = await foundTcs.Task;
        var roomId = found["roomId"]?.ToString();
        var joinOptions = found["joinOptions"] as Dictionary<string, object> ?? new Dictionary<string, object>();
        var gameOptions = new Dictionary<string, object>(joinOptions) { { "token", token } };
        await client.JoinById<DynamicSchema>(roomId, gameOptions);
    }
}
gdscript
var _client: Colyseus.Client
var _mm: Colyseus.Room
var _token := "YOUR_JWT_ACCESS_TOKEN"

func run_invite_join() -> void:
	# 1)连接到 B 场景房间(邀约入口)
	_client = Colyseus.Client.new("ws://127.0.0.1:2567")
	_mm = _client.join_or_create("matchmaker_invite_room", {"token": _token})
	if _mm:
		# 2)先绑定消息处理,再发 invite:join
		_mm.message_received.connect(_on_invite_message)
		_mm.send_message("invite:join", {
			"partyCode": "ABCD23",
			"inviteToken": "INVITE_TOKEN_123456",
		})

func _on_invite_message(type: Variant, data: Variant) -> void:
	var t := str(type)
	if t == "mm:hint" and typeof(data) == TYPE_DICTIONARY:
		# 服务端提示需要转发到 party:join(保留统一 Party 流程)
		var hint: Dictionary = data
		if str(hint.get("forwardTo", "")) == "party:join":
			_mm.send_message("party:join", hint.get("payload", {}))
	elif t == "match:found" and typeof(data) == TYPE_DICTIONARY:
		# 3)统一收口:收到 match:found 后进 game_room
		var d: Dictionary = data
		var opts := {"token": _token}
		var join_options: Variant = d.get("joinOptions", {})
		if typeof(join_options) == TYPE_DICTIONARY:
			for k in (join_options as Dictionary):
				opts[k] = (join_options as Dictionary)[k]
		_client.join_by_id(str(d.get("roomId", "")), opts)

示例 C:主动开房匹配(party:create

目标:房主主动开房时注入业务参数(例如活动场次、固定区域),并限制可开的配置。

游戏场景:自定义房 / 活动房(如赛事服、教学房、约战房)、由房主决定模式与人数上限,再等待成员加入。

typescript
// src/rooms/HostCreateMatchmakerRoom.ts
import { Client } from "colyseus";
import { MatchmakerRoom } from "./MatchmakerRoom";

const ALLOWED_MODES = new Set(["ranked", "casual", "event_5v5"]);

export class HostCreateMatchmakerRoom extends MatchmakerRoom {
  onCreate(options: any) {
    super.onCreate(options);

    this.onMessage("host:create", (client: Client, body: any) => {
      const modeId = String(body?.modeId || "casual");
      if (!ALLOWED_MODES.has(modeId)) {
        client.send("party:error", { message: `不支持的 modeId: ${modeId}` });
        return;
      }

      const playersPerMatch = Number(body?.playersPerMatch ?? 2);
      if (!Number.isInteger(playersPerMatch) || playersPerMatch < 2 || playersPerMatch > 10) {
        client.send("party:error", { message: "playersPerMatch 仅允许 2~10" });
        return;
      }

      client.send("mm:hint", {
        forwardTo: "party:create",
        payload: {
          modeId,
          playersPerMatch,
          region: body?.region || "global",
        },
      });
    });
  }
}

说明:房主主动开房是“先建 Party 再等人”。真正开局仍由房主触发 party:start,并由父类统一下发 match:found

客户端最小示例(对应 C)

ts
import Colyseus from "colyseus.js";

const client = new Colyseus.Client("ws://127.0.0.1:2567");
const token = "YOUR_JWT_ACCESS_TOKEN";

async function runHostCreate() {
  // 连接到示例 C 对应的房间名(房主端)
  const mm = await client.joinOrCreate("matchmaker_host_room", { token });

  // 1) 房主主动创建 Party
  mm.send("host:create", {
    modeId: "event_5v5",
    playersPerMatch: 4,
    region: "global",
  });

  // 2) 若服务端返回 mm:hint -> party:create,则按提示继续
  mm.onMessage("mm:hint", (hint: any) => {
    if (hint?.forwardTo === "party:create") {
      mm.send("party:create", hint.payload);
    }
  });

  // 3) 人满后房主触发 party:start(这里只演示消息)
  mm.send("party:start", {});

  // 4) 统一收口:match:found -> joinById
  const found = await new Promise<any>((resolve) => mm.onMessage("match:found", resolve));
  const game = await client.joinById(found.roomId, { ...found.joinOptions, token });
  console.log("host joined:", game.roomId, found.matchId);
}
csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;

public static class HostCreateClientExample
{
    public static async Task Run(Colyseus.Client client, string token)
    {
        // 1) 连接到 C 场景房间(房主主动开房入口)
        var mm = await client.JoinOrCreate<DynamicSchema>(
            "matchmaker_host_room",
            new Dictionary<string, object> { { "token", token } });

        // 2) 服务端若返回 mm:hint -> party:create,则按提示执行
        mm.OnMessage<Dictionary<string, object>>("mm:hint", hint =>
        {
            if (hint.TryGetValue("forwardTo", out var f) && f?.ToString() == "party:create")
            {
                var payload = hint["payload"] as Dictionary<string, object> ?? new Dictionary<string, object>();
                mm.Send("party:create", payload);
            }
        });

        // 3) 先挂 match:found,再发 host:create / party:start
        var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
        mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));

        mm.Send("host:create", new Dictionary<string, object> {
            { "modeId", "event_5v5" },
            { "playersPerMatch", 4 },
            { "region", "global" },
        });
        mm.Send("party:start", new Dictionary<string, object>());

        // 4) 收到成局后进入 game_room
        var found = await foundTcs.Task;
        var roomId = found["roomId"]?.ToString();
        var joinOptions = found["joinOptions"] as Dictionary<string, object> ?? new Dictionary<string, object>();
        var gameOptions = new Dictionary<string, object>(joinOptions) { { "token", token } };
        await client.JoinById<DynamicSchema>(roomId, gameOptions);
    }
}
gdscript
var _client: Colyseus.Client
var _mm: Colyseus.Room
var _token := "YOUR_JWT_ACCESS_TOKEN"

func run_host_create() -> void:
	# 1)连接到 C 场景房间(房主主动开房入口)
	_client = Colyseus.Client.new("ws://127.0.0.1:2567")
	_mm = _client.join_or_create("matchmaker_host_room", {"token": _token})
	if _mm:
		# 2)先绑定消息处理,再发 host:create
		_mm.message_received.connect(_on_host_message)
		_mm.send_message("host:create", {
			"modeId": "event_5v5",
			"playersPerMatch": 4,
			"region": "global",
		})
		_mm.send_message("party:start", {})

func _on_host_message(type: Variant, data: Variant) -> void:
	var t := str(type)
	if t == "mm:hint" and typeof(data) == TYPE_DICTIONARY:
		# 服务端提示需要转发到 party:create
		var hint: Dictionary = data
		if str(hint.get("forwardTo", "")) == "party:create":
			_mm.send_message("party:create", hint.get("payload", {}))
	elif t == "match:found" and typeof(data) == TYPE_DICTIONARY:
		# 3)统一收口:收到 match:found 后进 game_room
		var d: Dictionary = data
		var opts := {"token": _token}
		var join_options: Variant = d.get("joinOptions", {})
		if typeof(join_options) == TYPE_DICTIONARY:
			for k in (join_options as Dictionary):
				opts[k] = (join_options as Dictionary)[k]
		_client.join_by_id(str(d.get("roomId", "")), opts)

注册方式(按场景拆房间名)

若三种流程都要并存,建议注册三个房间名,客户端按入口选择连接目标:

typescript
// src/app.config.ts — 片段
import { RankedMatchmakerRoom } from "./rooms/RankedMatchmakerRoom";
import { InviteMatchmakerRoom } from "./rooms/InviteMatchmakerRoom";
import { HostCreateMatchmakerRoom } from "./rooms/HostCreateMatchmakerRoom";

gameServer.define("matchmaker_ranked_room", RankedMatchmakerRoom);
gameServer.define("matchmaker_invite_room", InviteMatchmakerRoom);
gameServer.define("matchmaker_host_room", HostCreateMatchmakerRoom);

如果只保留一个房间名,也可以把三类入口消息都加在同一个子类里,然后统一 define("matchmaker_room", YourMatchmakerRoom)

系统自带默认(无需写服务端匹配规则扩展)

基线客户端示例(默认 matchmaker_room

前置:通过 HTTP 登录取得 JWT,作为 token 使用(见 JWT 与房间鉴权)。 本节展示默认入口;上文「继承 MatchmakerRoom」里的 A/B/C 只是把入口消息或房间名换掉,成局后仍统一到 match:found -> joinById。 目标:以最少改动跑通默认 matchmaker_room 完整链路(排队、成局、进房)。

游戏场景:通用快速接入(先跑通完整匹配链路),适用于原型验证、联调阶段与未细分匹配策略的中小型玩法。

消息顺序:必须先注册 match:found 监听,再发送 match:find,否则可能丢失最先到达的成局消息。

ts
import Colyseus from "colyseus.js";

/** 与 match:found 载荷对应,便于类型提示;字段以服务端实际为准 */
interface MatchFoundPayload {
  roomName: string;
  roomId: string;
  matchId: string;
  seatIndex: number;
  reconnectKey: string;
  joinOptions: Record<string, unknown>;
}

const ENDPOINT = "ws://127.0.0.1:2567";
/** HTTP 登录拿到的 JWT;进房字段名用 token(与 accessToken 等价见鉴权文档) */
const TOKEN = "YOUR_JWT_ACCESS_TOKEN";

/**
 * 只等某一条下行消息一次(收到后自动取消监听)。
 * 用于保证:先挂上 match:found,再发 match:find,避免极端情况下丢事件。
 */
function onceMessage<T>(room: Colyseus.Room, type: string, ms = 120000): Promise<T> {
  return new Promise((resolve, reject) => {
    let dispose: (() => void) | undefined;
    const timer = setTimeout(() => {
      dispose?.();
      reject(new Error(`${type} timeout`));
    }, ms);

    dispose = room.onMessage(type, (payload: T) => {
      clearTimeout(timer);
      dispose?.();
      resolve(payload);
    });
  });
}

/** 主动排队 → 收 match:found → joinById 进 game_room(最小闭环) */
async function runQueueMatch() {
  const client = new Colyseus.Client(ENDPOINT);

  // 1)进入匹配房;options 必须能通过 @RequireAuth(与 rooms/auth 一致)
  const mm = await client.joinOrCreate("matchmaker_room", { token: TOKEN });

  // 2)可选:监听调试下行(生产可按需保留)
  mm.onMessage("mm:ready", (p) => console.log("mm:ready", p)); // 进房成功握手
  mm.onMessage("match:queued", (p) => console.log("match:queued", p)); // 已入队,含 queueKey
  mm.onMessage("party:update", (p) => console.log("party:update", p)); // Party 流程用
  mm.onMessage("party:error", (p) => console.warn("party:error", p));

  // 3)关键:先注册 match:found,再发 match:find(顺序不要反)
  const foundP = onceMessage<MatchFoundPayload>(mm, "match:found");
  mm.send("match:find", {
    modeId: "ranked", // 玩法/池子标识,与队列维度相关
    playersPerMatch: 2, // 成局人数=本局 game_room 规模;1v1=2,2v2=4,4v4=8;2~100
    region: "global", // 队列键第三段:分区字符串,与 modeId、playersPerMatch 共同决定排哪条队;见「队列维度与 region」
  });

  const found = await foundP;

  // 4)进对局房:roomId + joinOptions 来自 match:found;务必合并 token 进最终 options
  const game = await client.joinById(found.roomId, {
    ...found.joinOptions,
    token: TOKEN,
  });
  console.log("joined game_room", game.roomId, found.matchId);
}

runQueueMatch().catch(console.error);
csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;

/// <summary>
/// 主动匹配最小示例:JoinOrCreate(matchmaker_room) → Send(match:find) → 等 match:found → JoinById(game_room)。
/// DynamicSchema 便于先跑通;上线建议对状态做 schema-codegen 后替换泛型。
/// </summary>
public static class MatchmakingExample
{
    public static async Task<(Room<DynamicSchema> mm, Room<DynamicSchema> game)> Run(
        Colyseus.Client client,
        string accessToken)
    {
        // 1)进入匹配房
        var mm = await client.JoinOrCreate<DynamicSchema>(
            "matchmaker_room",
            new Dictionary<string, object> { { "token", accessToken } });

        // 2)先订阅 match:found(用 TCS 在回调里完成异步等待)
        var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
        mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));

        // 3)再发起排队(避免竞态:监听晚于发送会丢 match:found)
        mm.Send("match:find", new Dictionary<string, object> {
            { "modeId", "ranked" },
            { "playersPerMatch", 2 }, // 成局人数=game_room 规模;1v1=2,2v2=4,4v4=8;2~100
            { "region", "global" }, // 分区键,与 modeId、playersPerMatch 组成排队维度
        });

        var found = await foundTcs.Task;

        // 4)解析 roomId / joinOptions;joinOptions 建议原样并入进房字典
        var roomId = found["roomId"]?.ToString();
        if (string.IsNullOrEmpty(roomId))
            throw new InvalidOperationException("match:found 缺少 roomId");

        var joinOptions = found["joinOptions"] as Dictionary<string, object> ?? new Dictionary<string, object>();
        var gameOptions = new Dictionary<string, object>(joinOptions) { { "token", accessToken } };

        // 5)进入 game_room
        var game = await client.JoinById<DynamicSchema>(roomId, gameOptions);
        return (mm, game);
    }
}
gdscript
# 主动匹配最小示例(API 以当前 Colyseus Godot 插件为准,见 Godot 接入页)
# 要点:join 后立刻 connect(message_received),再在 mm:ready 里发 match:find,避免 match:found 先到却无人处理。

var _client: Colyseus.Client
var _mm: Colyseus.Room
var _token := "YOUR_JWT_ACCESS_TOKEN"

func start_match_example() -> void:
	# 1)构造客户端并进入匹配房
	_client = Colyseus.Client.new("ws://127.0.0.1:2567")
	_mm = _client.join_or_create("matchmaker_room", {"token": _token})
	if _mm:
		# 2)统一在此处理所有下行(含 mm:ready / match:found)
		_mm.message_received.connect(_on_mm_message)

func _on_mm_message(type: Variant, data: Variant) -> void:
	var t := str(type)
	if t == "mm:ready":
		# 进匹配房成功后再发排队(message_received 已连接,故能收到后续 match:found)
		_mm.send_message("match:find", {
			"modeId": "ranked",
			"playersPerMatch": 2, # 成局人数=game_room 规模;1v1=2,2v2=4,4v4=8;2~100
			"region": "global", # 队列键第三段,全服一条队常用 global;见匹配文档「队列维度与 region」
		})
	elif t == "match:queued":
		# 已入队;data 内常有 queueKey,便于调试
		print("match:queued ", data)
	elif t == "match:found" and typeof(data) == TYPE_DICTIONARY:
		# 成局:用 roomId + joinOptions 进 game_room;opts 先放 token 再合并 joinOptions
		var d: Dictionary = data
		var room_id: String = str(d.get("roomId", ""))
		var join_options: Variant = d.get("joinOptions", {})
		var opts := {"token": _token}
		if typeof(join_options) == TYPE_DICTIONARY:
			for k in (join_options as Dictionary):
				opts[k] = (join_options as Dictionary)[k]
		var _game = _client.join_by_id(room_id, opts)
	elif t == "party:error":
		push_warning("party:error " + str(data))

延伸阅读