跳到主要内容
实时游戏

本页尚未翻译,当前显示英文版本。

Matchmaking

把玩家送进合适的房间。 匹配票据描述玩家本人,并筛选其他玩家。匹配器确定分配结果、预留席位,之后游戏流量直接发往房间。

匹配器只在路径上出现一次,用来决定你该去哪里。它不在对局的路径上:它的结果是一个分配(placement)加一个有时限的席位预留,从加入那一刻起,游戏流量直接发往房间。所以队列再忙,也不会拖慢游戏。

a ticket — a player, or a party entering wholeyour pre-enqueue hook — stamps trusted criteriathe queue — the requirement widens rung by rungexpired — an outcome, not an errormatched — atomicallyRoomPlacement — a room and reserved seatsRosterSet — the roster only, no roomthe room lifetime passeda simultaneous gamean asynchronous gamethe player joins; the matchmaker leaves the path
匹配票据的路径:在服务器端写入标记,逐级放宽,原子地完成匹配,然后交给房间,匹配器随即退出

何时使用

  • 你需要按声明的条件(模式、地区、名次)把玩家路由进房间,而不是自己拼一个大厅列表。
  • 匹配条件必须来自平台数据,而不是客户端的自报:在入队前 hook 中写入名次(rank)。
  • 队列应在服务器端随时间放宽条件,而客户端只持有一张匹配票据(ticket),从不轮询。
  • 小队(party)必须一起进入同一场对局:群组要么整体进入,要么都不进入。
  • 你运行外部匹配器,只需要把它的决定落实为分配 + 席位(seat)预留。
  • 无需使用:如果玩家自己挑选会话。房间浏览器和 Join 已经够用。

分工

主体在本页中的职责
player创建和取消自己的匹配票据,以小队成员身份加入
match-organizer声明匹配队列和放宽规则;读取分配结果
backend-service在入队前写入可信条件;执行外部匹配器的决定

概览

寻找对局:一次 Find 调用返回一个可加入的预留席位
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
// client — one call for the common case
const seat = await playserv.matchmaking.find('ranked-duo');
const room = await playserv.rooms.join(seat);
# client — one call for the common case
seat = await playserv.matchmaking.find("ranked-duo")
room = await playserv.rooms.join(seat)
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

// client — one call for the common case
Client->Matchmaking->Of<FRankedDuo>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("duo") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        // the seat arrives as the ticket's outcome
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterMatch(JoinResult.Value());
                }));
        });
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — 针对生成的类型,在 Unity 中原样运行
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
声明 ranked-duo 队列:双向筛选和两级放宽阶梯
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}
@Matchmaker('ranked-duo')
export class RankedDuo {
  static size = Size.exactly(4, { multiple: 2 });
  static filter = "mode == 'duo' && region == self.region";
  static relax = [
    Relax.after(seconds(15), 'abs(rank - self.rank) < 300'),
    Relax.after(seconds(45), 'abs(rank - self.rank) < 800'),
  ];
}
@matchmaker("ranked-duo")
class RankedDuo:
    size = Size.exactly(4, multiple=2)
    filter = "mode == 'duo' && region == self.region"
    relax = [
        Relax.after(seconds(15), "abs(rank - self.rank) < 300"),
        Relax.after(seconds(45), "abs(rank - self.rank) < 800"),
    ]
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

USTRUCT(PSMatchmaker = (Name = "ranked-duo", Size = "Exactly:4", Multiple = 2,
                       Filter = "mode == 'duo' && region == self.region"))
struct FRankedDuo
{
    GENERATED_BODY()

    UPROPERTY(PSRelax = (After = "15s", Filter = "abs(rank - self.rank) < 300")) FPSRelax First;
    UPROPERTY(PSRelax = (After = "45s", Filter = "abs(rank - self.rank) < 800")) FPSRelax Second;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — 同一个模板类在 Unity 中按 2021.3 基线编译,这里没有用到 C# 12 语法
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}

不能信任客户端的条件,在入队前 hook 中写入:

入队前 hook 根据平台数据写入名次,而不是采信客户端的自报
[Before(Matchmaking.Enqueue)]                     // the server has the last word
public static async Task<Ticket> StampRank(Ticket t)
{
    var rows = await PlayServ.Leaderboards.ForOwners("ranked", new[] { t.Player });
    t.Properties["rank"] = rows[0].Rank;          // the row carries its rank in the full table
    return t;
}
// the server has the last word
export const stampRank = before(Matchmaking.enqueue, async (t: Ticket) => {
  const rows = await PlayServ.leaderboards.forOwners('ranked', [t.player]);
  t.properties.rank = rows[0].rank;               // the row carries its rank in the full table
  return t;
});
@before(matchmaking.enqueue)                      # the server has the last word
async def stamp_rank(t: Ticket) -> Ticket:
    rows = await playserv.leaderboards.for_owners("ranked", [t.player])
    t.properties["rank"] = rows[0].rank           # the row carries its rank in the full table
    return t
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

在引擎之外运行

hook 是云函数:它在平台上执行,不在引擎里执行。用 C#、TypeScript 或 Python 编写,Unreal 客户端只需调用上面的 Find。引擎内编写的函数已在规划中:Blueprint 图表,以及 C++(转译为平台校验过的脚本目标)。两者都像其他声明一样经过分析并推送,执行仍在平台上。

在引擎之外运行

hook 是云函数:它在平台上执行,不在引擎里执行。用 C#、TypeScript 或 Python 编写,Unity 客户端只需调用上面的 Find。

这里读取的是 Leaderboards 中的按所有者列表读取,和好友分组用的是同一种;返回的每一行都带着该所有者在完整榜单中的名次。hook 能查询别人的行,是因为榜单的谓词允许云函数这样做;玩家会话问同样的问题,只能拿到自己的那一行。

模型

匹配票据携带什么。

部分含义谁采信
自我描述参与者声明的属性:评分、模式、语言、所选地图未经检查谁都不信:这是调用方的自报
要求其他参与者必须满足的谓词平台,因为由平台来应用它

匹配票据的参与者是一个主体或一个群组(group):群组整体进入,这就是小队。它的票据不可拆分:群组要么整体进入名单(roster),要么都不进入。

队列类型声明什么。

声明项含义
属性按名称和类型声明。票据中未在此声明的属性按校验失败拒绝,而不是忽略
名单大小最小值、最大值和一个兼容步长:名单可接受的倍数,这样“五人”队伍就是五人,而不是二到十之间的任意人数
要求阶梯一组带时间窗口的有序谓词:每一级都是更宽的要求,外加一个时间,超时后匹配(matchmaking)进入下一级。放宽是一项声明,绝不是处理器里的任意逻辑
谓词语言与其他所有地方用的是同一种,其词汇包含票据自身的属性,所以“评分与我相差 ±100 以内”可以表达。
双向性是否接受 A 接受 B、而 B 不接受 A 的名单。没有默认值
票据有效期过期后票据转为 expired 并发出事件
结果RoomPlacement:一个房间引用加上该房间中的预留,用于同步游戏;或 RosterSet:只有名单,没有房间也没有预留,用于对手离线的异步游戏

匹配票据的状态。 created → queued → matched · cancelled · expired,后三个是终态。

始终成立含义
每个参与者在每个队列中只有一张有效票据第二张属于冲突,而不是第二份申请:请读取已有的那张
配对原因可观测:它会进入匹配事件和历史记录。对于内置算法,原因就是阶梯的级别;覆盖实现可能没有级别,那么原因就是它声明的一个不透明值,但原因始终存在
过期是一种结果,不是错误:“名单未能在声明的时间内凑齐”是正常结束,作为票据的结果送达
结果通过订阅送达,而不是轮询:客户端只持有一张票据,从不重复询问
matched是原子的:对于 RoomPlacement,要么名单匹配成功且每个参与者都持有预留,要么票据留在队列中。对于 RosterSet,原子结果只有名单本身

断线会取消匹配票据

匹配票据是“现在就要玩”的申请,所以断线会取消它。给不在场的玩家配对,会让其他所有人的名单变差。

错误

  • 同一队列中的第二张票据属于冲突;不要重试,读取已有的票据。
  • 未声明的属性,或引用未声明属性的要求,属于校验失败,而不是静默忽略,否则之后会表现为“没有找到对手”。
  • 队列已暂停返回 unavailable,而不是 forbidden:调用方的权限没有问题,情况只是暂时的,所以应带退避重试。
  • **无法为结果创建房间(room)**同样返回 unavailable,带退避重试。
  • 预留失败属于冲突,值得重试:票据仍留在队列中。
  • 找不到的票据或别人的票据,以及谓词不允许进入该队列的主体,都返回 not found,因此拒绝结果既不暴露票据,也不暴露队列。

限制

具体数值将随平台限制一章给出;到达每个上限时的行为现在已经确定。

  • 队列中的票据数:创建按冲突拒绝,已有票据不会为了腾出空间而被移除。
  • 票据有效期:转为 expired 并发出事件。
  • 阶梯级数:级数过多的声明在声明阶段即遭拒绝。
  • 票据中的群组大小:按校验失败拒绝票据。
  • 每种队列类型声明的属性数:在声明阶段拒绝。
  • 票据创建速率:速率限制拒绝,并附带可重试的截止时间。
  • 匹配历史的保留期:超过声明的期限后,条目不再可读。