Skip to main content
The live game

Not translated yet — showing English.

Matchmaking

Get a player into the right room. Tickets describe the player and filter the others. The matchmaker resolves a placement, reserves a seat, and game traffic then flows directly to the room.

The matchmaker is in the path once, to decide where you belong. It is not in the path of the match: its outcome is a placement and a time-limited seat reservation, and from the join onward the game traffic goes straight to the room. A busy queue therefore never becomes a busy game.

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
A ticket's path: stamped server-side, widened rung by rung, matched atomically, and handed to a room the matchmaker then leaves

When to use it

  • You need players routed into rooms by declared criteria (mode, region, rank), not a hand-rolled lobby list.
  • Match criteria must come from platform data, not the client's claim: stamp rank in the pre-enqueue hook.
  • Queues should widen over time server-side while the client holds one ticket and never polls.
  • Parties must land in one match together: a group enters whole or not at all.
  • You run an external matchmaker and only need its decision terminated in placement + seat reservation.
  • Skip it when players pick a session themselves. The rooms browser and Join already cover it.

Who does what

ActorOn this page
playercreates and cancels their own ticket, and enters as part of a party
match-organizerdeclares matchmaker queues and relaxation; reads placement results
backend-servicestamps trusted criteria pre-enqueue; runs external matchmaker decisions

At a glance

Finding a match: one Find call returns a reserved seat to join
// 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)
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

// 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 — runs as-is in Unity against the generated types
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
The ranked-duo queue declared: mutual filters and a two-step relaxation ladder
[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"),
    ]
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

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 — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[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"),
    };
}

Criteria the client must not be trusted with are stamped in the pre-enqueue hook:

The pre-enqueue hook stamps the rank from platform data, not the client's claim
[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
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

Runs off the engine

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unreal client just calls Find above. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.

Runs off the engine

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unity client just calls Find above.

The read is the owner-list read from Leaderboards — the same one a friends cohort uses — and every row it returns carries that owner's rank in the full table. The hook may ask for somebody else's row because the board's predicate lets a cloud function do it; a player session asking the same question gets only its own.

The model

What a ticket carries.

PartWhat it isWho believes it
self-descriptionthe participant's declared properties — rating, mode, language, chosen mapnobody without a check: it is the caller's claim
requirementa predicate the rest must satisfythe platform, because it is the one that applies it

A ticket's participant is an actor or a group — a group enters whole, and that is what a party is. Its ticket is indivisible: the group enters a roster entire or not at all.

What a queue type declares.

DeclaresWhat it is
propertiesby name and type. A property not declared here is refused in a ticket as a validation failure rather than ignored
roster sizea minimum, a maximum, and a compatibility step — the multiple at which a roster is acceptable, so teams "of five" means five, not any number between two and ten
requirement ladderan ordered set of predicates with windows: each rung is a wider requirement and a time after which matchmaking moves on. Relaxation is a declaration, never arbitrary logic in a handler
the predicate languagethe same one everything else uses, and its vocabulary includes the ticket's own properties — "a rating within ±100 of mine" is expressible.
mutualitywhether a roster is acceptable in which A accepts B while B does not accept A. There is no default
ticket lifetimeafter which the ticket transitions to expired with an event
outcomeRoomPlacement — a room reference plus reservations in it, for a simultaneous game; or RosterSet — the roster alone, with no room and no reservations, for an asynchronous one where the opponent is offline

A ticket's states. created → queued → matched · cancelled · expired, the last three terminal.

AlwaysWhat it is
one live ticket per participant per queuea second is a conflict, not a second application — read the existing one
the reason for a pairingis observable: it reaches the matchmaking event and the history. For the shipped algorithms that is the ladder rung; an overriding implementation may have no rungs, and then the reason is an opaque value it declares — but there is always one
expiryis an outcome, not an error: "a roster did not come together in the declared time" is a normal completion delivered as the ticket's outcome
the outcomearrives by subscription, not by polling: the client holds one ticket and never asks again
matchedis atomic: for RoomPlacement, either the roster is matched and every participant holds a reservation, or the tickets stay in the queue. For RosterSet the atomic result is the roster alone

Losing the connection cancels the ticket

A ticket is an application to play now, so a disconnect cancels it. Matching an absent player would make the roster worse for everyone else.

Errors

  • A second ticket in the same queue is a conflict; do not repeat it, read the existing ticket.
  • An undeclared property, or a requirement naming one, is a validation failure — not a silent ignore that would surface later as "no opponents were found".
  • The queue is suspended answers unavailable, not forbidden: the caller's rights are intact and the situation is temporary, so retrying with backoff is right.
  • The room for the result cannot be created is likewise unavailable, with backoff.
  • The reservation failed is a conflict worth retrying — the ticket stays in the queue.
  • A ticket that is not found or is another's, and an actor a predicate does not admit to the queue, both answer not found, so a refusal reveals neither the ticket nor the queue.

Limits

The numbers arrive with the platform limits chapter; what happens at each ceiling is fixed now.

  • Tickets in a queue: creation is refused as a conflict, and existing tickets are not evicted to make room.
  • A ticket's lifetime: a transition to expired with an event.
  • Ladder rungs: a declaration with too many is refused at declaration time.
  • Group size in a ticket: the ticket is refused as a validation failure.
  • Declared properties per queue type: refused at declaration time.
  • Ticket-creation rate: a rate-limit refusal with a deadline.
  • Retention of matchmaking history: past the declared period an entry is no longer readable.