小游戏邀请好友对战(Party 邀约)
适用于微信/抖音等小游戏:玩家通过分享卡片或邀请链接拉好友进房,再由房主开局进入对战房。
场景目标
- 让“好友邀请”与
matchmaker_room的 Party 流程对齐,避免自建第二套开房协议。 - 控制加入权限(例如
inviteToken、有效期、来源校验),防止房间码被外部滥用。 - 全员最终仍走统一链路:
match:found→joinById(roomId)进对局房(默认game_room,可不传gameRoomName)。
推荐消息流
- 房主与队员都先
joinOrCreate("matchmaker_room", { token })。 - 房主
party:create,拿到partyCode(可写入分享参数)。 - 受邀玩家用
invite:join(自定义入口)或直接party:join(见下文 好友加入示例 中 「好友 · TypeScript」 等标签页)。 - 服务端校验邀请参数后转发到 Party 标准流程。
- 人数 ≥
playersPerMatch后房主party:start(不会满员自动开局),全员收到match:found并joinById进对局房(合并joinOptions、顶层reconnectKey与token)。
服务端扩展点(继承 MatchmakerRoom)
- 在子类
onCreate里先super.onCreate(options)。 - 新增
invite:join消息,校验inviteToken/ 时间戳 / 签名。 - 校验通过后,按标准流程转发
party:join,减少重复逻辑。
invite:join 等自定义入口的服务端转发与 mm:hint → party:join 的写法,可参考:匹配与开房 中「示例 B:被动开房邀约匹配(party:join)」。小游戏侧房主建 Party、好友凭房间码加入、开局进 game_room 的客户端骨架见本文 客户端示例代码(Party)。
客户端要点
- 邀请链接建议包含
partyCode与一次性inviteToken。 - 先注册
match:found,再发送邀请入口消息,避免竞态丢消息。 - 收到
mm:hint时按服务端提示转发(如party:join),最终统一joinById(roomId, { ...joinOptions, token })。
常见坑
- 只校验房间码,不校验邀请参数来源:容易被非邀请用户加入。
- 房主提前
party:start:人数不足会触发party:error。 - 忘记把
token合并到joinByIdoptions:可能在game_room鉴权失败。
客户端示例代码(Party)
以下均为 客户端 示例:连接框架提供的 matchmaker_room,按 Party 协议发 party:create / party:join / party:start,再按 match:found 携带的 roomId、joinOptions 调用 joinById 进入 game_room。不涉及在客户端实现匹配队列或成局算法。
匹配与 Party 成局 由框架内置的 MatchmakerRoom(matchmaker_room)处理:固定人数 Party、自动匹配等逻辑已带好,业务侧通常 不必再写一套匹配/组局服务。若要做邀请签名校验、自定义入口消息、与业务后台联动等,再在服务端 继承 MatchmakerRoom 覆写或增消息即可(见上文「服务端扩展点」与 匹配与开房)。
目标:房主创建 Party、好友加入、房主开局,最终统一 match:found -> joinById。
游戏场景:微信/抖音小游戏“分享拉人开黑”,常见 1v1 / 2v2 固定人数对战。
party:create 参数(PartyCreateRequest)
与 匹配与开房「被动开房」一致。进匹配房时的 token / accessToken 在 joinOrCreate("matchmaker_room", …) 的 options 里传(必填,见 JWT 与房间鉴权),不在 party:create body 里。
| 字段 | 必填 | 类型 | 说明 |
|---|---|---|---|
modeId | 是 | string | 玩法 / 模式池标识,业务自定义。例:mini_duel(小游戏单挑)。与路人 match:find 的 modeId 语义一致。 |
playersPerMatch | 是 | number | 成局人数,整数 2~100。Party 内人数 ≥ 该值后房主可 party:start;1v1=2,2v2=4。 |
gameRoomName | 否 | string | 对局 Colyseus 房间类型名(须在服务端 app.config 里 define)。不传默认 game_room。 |
region | 否 | string | 分区键第三段,与 modeId、playersPerMatch 组成逻辑队列;未传时服务端多按 global。见 队列维度与 region。 |
user | 否 | object | 展示用资料(昵称、头像、平台等),结构由业务自定。userId 以 JWT 为准,客户端若在 user 里传 userId 会被忽略。缺省时服务端可能用 JWT 里的 username 补进成员资料。 |
party:join 参数(PartyJoinRequest)
| 字段 | 必填 | 类型 | 说明 |
|---|---|---|---|
partyCode | 是 | string | 6 位房间码(不区分大小写,服务端会 trim 并转大写)。 |
user | 否 | object | 同 party:create 的 user,仅展示;userId 以好友 JWT 为准。 |
user 示例字段(均可选,按业务扩展)
| 字段 | 说明 |
|---|---|
nickname | 昵称,用于 party:update 的 members 展示。 |
avatarUrl | 头像 URL。 |
platform | 如 "wechat" / "douyin"。 |
level | 等级等数值展示。 |
不要传 userId
user 里请勿依赖 userId 鉴权或区分身份;身份以 joinOrCreate 时的 JWT 为准。传了 user.userId 不会报错,但会被服务端 忽略,最终以好友自己的 JWT 为准。
好友加入流程(party:join)
- 用好友账号的 JWT
joinOrCreate("matchmaker_room", { token }),等mm:ready。 - 先监听
party:joined/party:update/party:error/match:found(避免丢消息)。 - 发送
party:join:partyCode必填(来自分享或房主party:created);user可选(展示资料)。 - 收到
party:joined:确认count、playersPerMatch、members;房主侧会收到party:update。 - 房主
party:start后,好友与房主一样收到match:found→joinById(roomId, …)。
好友侧下行(节选)
| 消息 | 说明 |
|---|---|
party:joined | 加入成功:partyId、partyCode、count、members 等。 |
party:update | 人数或成员变化(含房主端广播)。 |
party:error | 如房间码无效、已满、已在其他队伍等。 |
match:found | 房主开局后下发,再进对局房。 |
import Colyseus from "colyseus.js";
const ENDPOINT = "ws://127.0.0.1:2567";
const HOST_TOKEN = "HOST_JWT_TOKEN";
async function hostCreateAndStart() {
const hostClient = new Colyseus.Client(ENDPOINT);
const mm = await hostClient.joinOrCreate("matchmaker_room", { token: HOST_TOKEN });
// 1)先监听关键消息
const foundP = new Promise<any>((resolve) => mm.onMessage("match:found", resolve));
const createdP = new Promise<any>((resolve) => mm.onMessage("party:created", resolve));
// 2)房主创建 Party(必填 modeId、playersPerMatch;其余可选)
mm.send("party:create", {
// —— 必填 ——
modeId: "mini_duel",
playersPerMatch: 2,
// —— 可选 ——
gameRoomName: "game_room", // 不传则默认 game_room,或由 MATCH_MODE_GAME_ROOM_MAP 按 modeId 映射
region: "global",
user: {
nickname: "房主小明",
avatarUrl: "https://cdn.example.com/avatar/host.png",
platform: "wechat",
level: 12,
// 勿传 userId:以 HOST_TOKEN 对应 JWT 为准
},
});
const created = await createdP;
console.log("party code:", created.partyCode);
// 3)等待好友加入后,房主开局(示例用 setTimeout)
setTimeout(() => mm.send("party:start", {}), 2000);
// 4)收到 match:found 后进入 game_room
const found = await foundP;
const game = await hostClient.joinById(found.roomId, {
...found.joinOptions,
reconnectKey: found.joinOptions?.reconnectKey ?? found.reconnectKey,
token: HOST_TOKEN,
});
console.log("host joined game:", game.roomId);
}import Colyseus from "colyseus.js";
const ENDPOINT = "ws://127.0.0.1:2567";
const FRIEND_TOKEN = "FRIEND_JWT_TOKEN"; // 必须与房主不同的登录态
/** 分享参数里带上 partyCode(6 位房间码) */
async function friendJoinByPartyCode(partyCode: string) {
const friendClient = new Colyseus.Client(ENDPOINT);
const mm = await friendClient.joinOrCreate("matchmaker_room", { token: FRIEND_TOKEN });
// 1)先挂下行(再发 join,避免竞态)
const joinedP = new Promise<any>((resolve, reject) => {
mm.onMessage("party:joined", resolve);
mm.onMessage("party:error", (e) => reject(new Error(e?.message || "party:error")));
});
const foundP = new Promise<any>((resolve) => mm.onMessage("match:found", resolve));
mm.onMessage("party:update", (u) => {
console.log("party members:", u.count, "/", u.playersPerMatch, u.members);
});
// 2)加入 Party —— 必填 partyCode;可选 user(勿传 userId)
mm.send("party:join", {
partyCode, // 必填,来自分享 / 房主 party:created.partyCode
user: {
nickname: "好友小红",
avatarUrl: "https://cdn.example.com/avatar/friend.png",
platform: "wechat",
level: 8,
},
});
const joined = await joinedP;
console.log("joined party:", joined.partyId, "seat count", joined.count);
// 3)等房主 party:start 后收 match:found,再进对局房
const found = await foundP;
const game = await friendClient.joinById(found.roomId, {
...found.joinOptions,
reconnectKey: found.joinOptions?.reconnectKey ?? found.reconnectKey,
token: FRIEND_TOKEN,
});
console.log("friend in game:", game.roomId, "seat", found.seatIndex);
}using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;
public static class MiniInviteBattleExample
{
public static async Task HostRun(Colyseus.Client hostClient, string hostToken)
{
var mm = await hostClient.JoinOrCreate<DynamicSchema>(
"matchmaker_room",
new Dictionary<string, object> { { "token", hostToken } });
// 1) 先挂关键下行
var createdTcs = new TaskCompletionSource<Dictionary<string, object>>();
var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
mm.OnMessage<Dictionary<string, object>>("party:created", p => createdTcs.TrySetResult(p));
mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));
// 2) 房主建房(必填 modeId、playersPerMatch;见上文参数表)
mm.Send("party:create", new Dictionary<string, object> {
// 必填
{ "modeId", "mini_duel" },
{ "playersPerMatch", 2 },
// 可选
{ "gameRoomName", "game_room" },
{ "region", "global" },
{ "user", new Dictionary<string, object> {
{ "nickname", "房主小明" },
{ "avatarUrl", "https://cdn.example.com/avatar/host.png" },
{ "platform", "wechat" },
{ "level", 12 },
}},
});
var created = await createdTcs.Task;
Console.WriteLine($"party code: {created["partyCode"]}");
// 3) 示例里直接开局;实际应在人数满足后再 start
mm.Send("party:start", new Dictionary<string, object>());
// 4) 统一收口:match:found -> JoinById
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", hostToken } };
if (found.TryGetValue("reconnectKey", out var rk) && rk != null)
gameOptions["reconnectKey"] = rk;
await hostClient.JoinById<DynamicSchema>(roomId, gameOptions);
}
}using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;
public static class MiniInviteFriendJoinExample
{
/// <summary>好友凭房间码加入,等房主开局后 JoinById。</summary>
public static async Task FriendJoinByPartyCode(
Colyseus.Client friendClient,
string friendToken,
string partyCode)
{
var mm = await friendClient.JoinOrCreate<DynamicSchema>(
"matchmaker_room",
new Dictionary<string, object> { { "token", friendToken } });
var joinedTcs = new TaskCompletionSource<Dictionary<string, object>>();
var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
mm.OnMessage<Dictionary<string, object>>("party:joined", p => joinedTcs.TrySetResult(p));
mm.OnMessage<Dictionary<string, object>>("party:error", p =>
joinedTcs.TrySetException(new Exception(p["message"]?.ToString() ?? "party:error")));
mm.OnMessage<Dictionary<string, object>>("party:update", p =>
Console.WriteLine($"party update {p["count"]}/{p["playersPerMatch"]}"));
mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));
// party:join —— 必填 partyCode;可选 user(勿传 userId)
mm.Send("party:join", new Dictionary<string, object> {
{ "partyCode", partyCode.Trim().ToUpperInvariant() },
{ "user", new Dictionary<string, object> {
{ "nickname", "好友小红" },
{ "avatarUrl", "https://cdn.example.com/avatar/friend.png" },
{ "platform", "wechat" },
{ "level", 8 },
}},
});
var joined = await joinedTcs.Task;
Console.WriteLine($"joined party {joined["partyId"]}, count={joined["count"]}");
var found = await foundTcs.Task;
var joinOptions = found["joinOptions"] as Dictionary<string, object> ?? new Dictionary<string, object>();
var gameOptions = new Dictionary<string, object>(joinOptions) { { "token", friendToken } };
if (found.TryGetValue("reconnectKey", out var rk) && rk != null)
gameOptions["reconnectKey"] = rk;
await friendClient.JoinById<DynamicSchema>(found["roomId"]?.ToString(), gameOptions);
}
}var _host_client: Colyseus.Client
var _host_mm: Colyseus.Room
var _host_token := "HOST_JWT_TOKEN"
func host_run() -> void:
_host_client = Colyseus.Client.new("ws://127.0.0.1:2567")
_host_mm = _host_client.join_or_create("matchmaker_room", {"token": _host_token})
if _host_mm:
_host_mm.message_received.connect(_on_host_mm_message)
# 1)房主创建 Party(必填 modeId、playersPerMatch;见上文参数表)
_host_mm.send_message("party:create", {
# 必填
"modeId": "mini_duel",
"playersPerMatch": 2,
# 可选
"gameRoomName": "game_room",
"region": "global",
"user": {
"nickname": "房主小明",
"avatarUrl": "https://cdn.example.com/avatar/host.png",
"platform": "wechat",
"level": 12,
},
})
func _on_host_mm_message(type: Variant, data: Variant) -> void:
var t := str(type)
if t == "party:created" and typeof(data) == TYPE_DICTIONARY:
var d: Dictionary = data
print("party code: ", d.get("partyCode", ""))
# 2)示例里直接开局;实际应在人数满足后再 start
_host_mm.send_message("party:start", {})
elif t == "match:found" and typeof(data) == TYPE_DICTIONARY:
# 3)统一收口:match:found -> join_by_id
_join_game_from_found(_host_client, _host_token, data as Dictionary)
func _join_game_from_found(client: Colyseus.Client, token: String, found: Dictionary) -> void:
var opts := {"token": token}
var join_options: Variant = found.get("joinOptions", {})
if typeof(join_options) == TYPE_DICTIONARY:
for k in (join_options as Dictionary):
opts[k] = (join_options as Dictionary)[k]
if found.has("reconnectKey"):
opts["reconnectKey"] = found["reconnectKey"]
client.join_by_id(str(found.get("roomId", "")), opts)## 好友凭房间码加入(party:join)→ 等房主 party:start → match:found → join_by_id
var _friend_client: Colyseus.Client
var _friend_mm: Colyseus.Room
var _friend_token := "FRIEND_JWT_TOKEN"
func friend_join_by_party_code(party_code: String) -> void:
_friend_client = Colyseus.Client.new("ws://127.0.0.1:2567")
_friend_mm = _friend_client.join_or_create("matchmaker_room", {"token": _friend_token})
if not _friend_mm:
return
_friend_mm.message_received.connect(_on_friend_mm_message)
# 先监听再发送,避免丢 party:joined / match:found
_friend_mm.send_message("party:join", {
"partyCode": party_code.strip_edges().to_upper(), # 必填
"user": { # 可选,勿传 userId
"nickname": "好友小红",
"avatarUrl": "https://cdn.example.com/avatar/friend.png",
"platform": "wechat",
"level": 8,
},
})
func _on_friend_mm_message(type: Variant, data: Variant) -> void:
var t := str(type)
if t == "party:joined" and typeof(data) == TYPE_DICTIONARY:
var d: Dictionary = data
print("friend joined party, count=", d.get("count"), " members=", d.get("members"))
elif t == "party:update" and typeof(data) == TYPE_DICTIONARY:
var u: Dictionary = data
print("party update ", u.get("count"), "/", u.get("playersPerMatch"))
elif t == "party:error" and typeof(data) == TYPE_DICTIONARY:
push_error("party:error " + str((data as Dictionary).get("message", "")))
elif t == "match:found" and typeof(data) == TYPE_DICTIONARY:
_join_game_from_found(_friend_client, _friend_token, data as Dictionary)
func _join_game_from_found(client: Colyseus.Client, token: String, found: Dictionary) -> void:
var opts := {"token": token}
var join_options: Variant = found.get("joinOptions", {})
if typeof(join_options) == TYPE_DICTIONARY:
for k in (join_options as Dictionary):
opts[k] = (join_options as Dictionary)[k]
if found.has("reconnectKey"):
opts["reconnectKey"] = found["reconnectKey"]
client.join_by_id(str(found.get("roomId", "")), opts)