Concepts
Matchmaking & parties
How queueing with friends works, from the table to the draft. Every queue entry is a party, and a solo player is a party of one.
The decision everything follows from#
Every queue entry is a party. A solo player is a party of one.
The alternative was a nullable PartyId on a per-player queue row, with the matchmaker grouping
rows back together on every pass. That keeps two shapes of queue entry alive forever, and every piece of
matchmaking then has to handle both: gather, balance, requeue, the count the launcher shows, the rating
window. The solo path is the one that gets tested while the party path is the one that breaks.
Making the party the unit means there is one shape. The matchmaker never asks whether something is a party; it asks how many seats it takes. Solo queueing works because a solo player is a party, not because there is a branch for them.
Data model#
erDiagram
Player ||--o| PartyMember : "is in at most one"
Party ||--|{ PartyMember : has
Party ||--o{ PartyInvite : "has pending"
Player ||--o{ PartyInvite : "is invited by"
Party ||--o| QueueEntry : "queues as one"
Party }o--|| Player : "led by"Party#
| Column | Type | Why |
|---|---|---|
Id | int | |
LeaderId | int → Player | Only the leader queues and invites |
CreatedAt | timestamptz | For sweeping abandoned parties |
PartyMember#
| Column | Type | Why |
|---|---|---|
PartyId | int → Party | |
PlayerId | int → Player, unique | The database enforces one party per player |
JoinedAt | timestamptz | Display order, and who leads next |
That unique index is load-bearing. Two invites accepted in the same instant, or one accepted twice by a retrying client, cannot put a player in two parties: the second insert violates the index and the transaction fails. No lock, no check-then-act race, and it holds even if two API instances ever run at once.
PartyInvite#
| Column | Type | Why |
|---|---|---|
PartyId | int → Party | |
ToPlayerId | int → Player | Unique together with PartyId, so inviting twice is a no-op |
FromPlayerId | int → Player | Who asked, so the launcher can say |
ExpiresAt | timestamptz | An invite nobody answers stops existing |
QueueEntry points at a PartyId, still unique: a party is either waiting or it is not.
The party has one queue time, so it has one widening rating window rather than five windows to
reconcile.
Party rating#
Two numbers, used for different things, and the difference matters.
For finding opponents
The highest rating in the party
A 2600 player queueing with a 1200 friend is a 2600-strength player in the match. Averaging would hand them opponents their friend cannot play against.
For balancing teams
The sum of members' ratings
Team strength is the whole side, not its best player.
Gathering a match#
The matchmaker anchors on the longest-waiting party and admits anyone whose rating gap fits inside both parties' windows. Windows widen by 25 points every ten seconds of waiting. Admitting somebody adds their whole party, so the group is assembled by seats rather than by heads.
A set of parties is only usable if it satisfies two conditions:
- the sizes sum to exactly the match size, and
- those sizes can be split into two teams of equal size without splitting a party.
The check is a subset-sum over at most ten items: a few hundred operations, a few times a minute. Not a cost worth optimising, but a check worth having.
gather(waiting, size, now):
for anchor in waiting (oldest first):
group = [anchor]
for other in waiting, excluding anchor:
if group.seats + other.size > size: continue # would overflow
gap = |other.rating - anchor.rating| # rating = party max
allowed = min(anchor.tolerance(now), other.tolerance(now))
if gap > allowed: continue
group.add(other)
if group.seats == size and splittable(group, size / 2):
return group
return none # nobody compatible yet; the windows widen and we try again"The match size" is not always ten. An instance that sets
Matchmaking:MinCompetitivePlayers will, once the
longest-waiting party has waited twenty seconds, also try eight, then six, down to that minimum. Both
conditions still apply. Full matches are gathered for everybody first, and the sizes tried are always
even.
Drafting the teams#
Among the splits that keep every party whole, the draft picks the one with the smallest difference in total rating. With at most ten parties there are at most 210 candidate splits, and far fewer of the right size. Enumerating them is cheaper than the database round trip before it, and unlike a snake draft it is exact: no arrangement of these parties balances better.
balance(parties, teamSize):
best = none
for each subset of parties whose sizes total teamSize:
difference = |sum(ratings in subset) - sum(ratings not in subset)|
keep the subset with the smallest difference
return best as team A, the rest as team BSolo players are parties of one, so this replaced the old snake draft for them too, and balances them better than it did.
After a cancelled match#
When somebody fails to accept, the players who did accept go back to the queue so they do not lose their place, with one rule for parties:
A party is requeued only if every one of its members accepted. If one member did not, the party is dropped from the queue. Putting the rest back without them would break the party up.
Lifecycle#
stateDiagram-v2
[*] --> Solo: signed in
Solo --> InParty: accepted an invite / invited someone
InParty --> Solo: left, kicked, or disbanded
InParty --> Queued: leader queues
Queued --> InParty: leader cancels
Queued --> InMatch: matched
InMatch --> InParty: match over or cancelled
Solo --> Queued: queues alone (a party of one)- Membership changes dequeue the party. Somebody joining or leaving changes the seat count and the party's rating, and a queue entry that no longer describes its party is a match formed on stale information.
- Only the leader queues, invites and kicks. Not for hierarchy: otherwise two members could queue for different modes at the same moment.
- If the leader leaves, the longest-standing remaining member becomes leader. An empty party is deleted.
- Invites expire after two minutes and are swept by
MatchmakerJanitor. A stale invite offers a party that may have disbanded, filled up, or already be in a match.
The party API#
Rules worth stating, because they are security and not manners:
- Only friends can be invited. Without it, any signed-in account could spam invites at any player id it can guess.
- A party holds at most as many members as a team, five for Competitive. Checked when accepting, not when inviting, because the party may fill between the two.
- The leader check is server-side on every mutation. The launcher hides the buttons; that is a courtesy, not a control.
- Pending invites are capped at ten per party, so nobody can make the poll expensive for everyone the invites were sent to.
Getting it to the launcher#
Party state is not a new poll; it joins the one that already existed. The launcher polls
/api/social/state every five seconds while focused and once a minute when not, with an ETag so
unchanged state answers 304 with no body.
{
"friends": [ ... ],
"requests": [ ... ],
"party": { "leaderId": 12, "members": [ ... ] },
"invites": [ { "partyId": 7, "fromName": "Nyx", "expiresAt": "..." } ]
}The ETag covers all four, so a party invite arriving makes the next poll return 200 and the
whole panel updates together. One request means a friend list and a party list can never be drawn from
different instants.
Not in the first version#
- Party codes. A short code to join by, for when an invite is the wrong shape. Planned: see the roadmap.
- Party chat. A different problem with a different transport.
- Leader transfer by choice. Automatic transfer on leaving is enough.
- Cross-mode parties. The party queues for one mode, chosen by the leader.
- Presence. Belongs with the push channel that eventually replaces polling.