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.
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
Joinalready cover it.
Who does what
| Actor | On this page |
|---|---|
player | creates and cancels their own ticket, and enters as part of a party |
match-organizer | declares matchmaker queues and relaxation; reads placement results |
backend-service | stamps trusted criteria pre-enqueue; runs external matchmaker decisions |
At a glance
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)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.// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);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"),
]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[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:
[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 tAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Part | What it is | Who believes it |
|---|---|---|
self-description | the participant's declared properties — rating, mode, language, chosen map | nobody without a check: it is the caller's claim |
requirement | a predicate the rest must satisfy | the 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.
| Declares | What it is |
|---|---|
properties | by name and type. A property not declared here is refused in a ticket as a validation failure rather than ignored |
roster size | a 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 ladder | an 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 language | the same one everything else uses, and its vocabulary includes the ticket's own properties — "a rating within ±100 of mine" is expressible. |
mutuality | whether a roster is acceptable in which A accepts B while B does not accept A. There is no default |
ticket lifetime | after which the ticket transitions to expired with an event |
outcome | RoomPlacement — 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.
| Always | What it is |
|---|---|
one live ticket per participant per queue | a second is a conflict, not a second application — read the existing one |
the reason for a pairing | is 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 |
expiry | is 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 outcome | arrives by subscription, not by polling: the client holds one ticket and never asks again |
matched | is 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
expiredwith 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.