Matchmaking
Overview
The package Altruist.Gaming.Matchmaking adds queues to a room host. MatchmakingModule<TSim, TInput, TPlayer> is an IRoomHostModule: waiting players first fill seats of running rooms that allow it, and otherwise start new rooms. It provides:
- queues per playlist (
PlaylistOptions); - fill groups for unrated playlists: a full group at once, otherwise whoever waits after a timeout, with bots filling the rest;
- rated groups inside rating windows that widen while players wait, with teams balanced by rating sum;
- join in progress: open seats are taken at once, bot seats one at a time with a notice; rated rooms only inside the rating window;
- queue status packets.
Everything runs on the engine thread, inside the host's frame. Matchmaker, the queue and grouping core, has no clock and no networking and can be used on its own.
Setup
using Altruist.Gaming.Matchmaking;
using Altruist.Gaming.Rooms;
public static class FieldPlaylists
{
public static readonly PlaylistOptions Casual = new()
{
Id = "casual",
Mode = "duo",
TeamSize = 2,
Rules = new RoomRules
{
OnDisconnect = DisconnectAction.BotAfterDelay,
OnVoluntaryLeave = VoluntaryLeaveAction.OpenForBot,
ReclaimTeamSeat = true,
JoinInProgress = true,
BotRefill = true,
},
};
public static readonly PlaylistOptions Ranked = new()
{
Id = "ranked",
Mode = "duo",
TeamSize = 2,
Rated = true,
Rules = new RoomRules
{
OnGraceExpired = GraceExpiredAction.Abandon,
OnVoluntaryLeave = VoluntaryLeaveAction.Abandon,
WhenEmpty = EmptyRoomAction.TeamForfeit,
},
};
}
[Service]
[Service(typeof(IWorldStepper))]
public sealed class FieldHost : RoomHost<FieldSim, Move, Pilot>
{
public FieldHost(FieldGame game)
: base(game, game, new RoomHostOptions(), fixedHz: 30)
{
Matchmaking = new MatchmakingModule<FieldSim, Move, Pilot>(
game, new MatchmakingOptions(), [FieldPlaylists.Casual, FieldPlaylists.Ranked]);
Use(Matchmaking);
}
public MatchmakingModule<FieldSim, Move, Pilot> Matchmaking { get; }
}
The game implements IMatchmakingGame<TSim, TInput, TPlayer> next to its IRoomGame:
| Member | Meaning |
|---|---|
RatingOf(player, playlist) | The player's rating in a playlist. Read fresh on every queue entry. |
TimeLeftSeconds(room) | Seconds left on the room's clock (join in progress needs enough of it). |
QueueStatus(status) | The packet for a QueueStatusInfo. |
public int RatingOf(Pilot player, PlaylistOptions playlist) => _ratings.Get(player.AccountId, playlist.Id); // your rating cache
public double TimeLeftSeconds(Room<FieldSim, Move, Pilot> room) => 90 - room.Sim.Steps * room.Sim.Dt;
public IPacketBase QueueStatus(QueueStatusInfo s) => new FieldQueueStatus
{
State = s.State.ToString(), Playlist = s.Playlist, Elapsed = s.Elapsed, Found = s.PlayersFound, Needed = s.PlayersNeeded,
};
Queue Commands
Call these on the engine thread, typically from a gate handler through WaitForNextTick:
| Method | Behaviour |
|---|---|
Enqueue(session, playlist) | Puts the connection in the playlist's queue and sends Searching. Re-queueing into the same playlist keeps the place; switching playlists starts over. Leaves the connection's lobby. Returns false (nothing changes) while the connection plays in a room or is about to join one, and while the server drains (the connection gets an Idle status and ServerDraining()). |
Cancel(session) | Leaves the queue and any running join notice; the connection gets an Idle status. |
Remove(session) | Takes the connection out of the queue without a packet. Returns true when it was queued. |
AbortPendingJoin(session) | Calls off a running join-in-progress notice (the room gets a Left notice). |
SendStatus(session, state, playlistId) | Sends a status packet now. |
Gates such as verification, bans or level requirements are the caller's: check them before Enqueue. The connection's state is checked for you: Enqueue returns false and changes nothing while the connection plays in a room or is about to join one, and a connection in a lobby leaves it (host.TakeOver; the lobby's other members are updated):
engine.WaitForNextTick(() =>
{
if (host.SessionOf(clientId) is not { } s) return;
if (host.Matchmaking.Queue.Playlist(packet.Playlist) is { } playlist)
host.Matchmaking.Enqueue(s, playlist);
});
Joining a lobby works the other way round: the connection leaves the queue (and a running join notice) and gets an Idle status.
A connection that closes or is replaced leaves the queue on its own.
How Groups Form
Every frame, in the host's BeforeBotRefills, the module completes due join-in-progress swaps and then, unless the server drains, runs each playlist that has players waiting.
Fill groups (unrated)
- Join in progress first (see below).
- Players that joinable rooms will take soon are reserved: as many players as there are unclaimed bot seats in joinable rooms of the playlist. They stay at the front of the queue and do not start new rooms.
- Of the others, a full group (
TeamSize × 2players) forms as soon as that many wait, first come first served. - Otherwise, once the oldest of them waited
FillAfterSeconds, everyone waiting forms a group and bots fill the empty seats.
Rated groups
A rated group is always full and never gets bots at the start. The oldest player is tried first as the anchor; candidates are taken by rating closeness, then by queue time. A candidate fits only when every pair in the group is inside both players' rating windows. A window grows with each whole second waited:
window = min(RatingWindowMax, RatingWindowStart + RatingWindowGrowthPerSecond × floor(waited))
With the defaults, two players 150 apart match after two seconds (100 → 125 → 150), and the window stops at 500 after 16 seconds. Rated rooms whose rules set JoinInProgress take queued players too, see Join in Progress.
Teams
A match has two teams, 0 and 1, of TeamSize seats each. Seat ids are 1 … TeamSize for team 0 and TeamSize + 1 … 2 × TeamSize for team 1.
- Unrated: players alternate between the teams in queue order, so team sizes differ by at most one; bots take the remaining seats.
- Rated: the split with the smallest difference of rating sums (equal sizes). Every split is tried, which is fine for the usual group sizes.
Bots are seated after every player is in, so CreateBot can base a bot on the players. The new room starts with the playlist's Rules; its players get a Found status, a welcome, the roster and a snapshot.
Join in Progress
A running room is joinable when its playlist matches, its Rules.JoinInProgress is set, it has not ended, and TimeLeftSeconds(room) is at least JoinMinTimeLeftSeconds. A rated room is joinable only for players whose current rating window covers the room's mean rating (the mean of the group it started with). Waiting players (oldest first) are paired with its seats:
- Open seats (a player left and no bot has taken the seat yet) are taken at once, without pacing.
- Bot seats are swapped one at a time per room: only when no other bot swap is pending there and
SwapIntervalSecondshave passed since the last one. - Open seats come first, then the team with fewer players (keeps teams balanced), then fuller rooms.
The player gets a Found status and the room a Joining notice. An open seat is the player's right away (nobody drives it). A bot seat gets a pending claim instead: after SwapNoticeSeconds the player takes it (the others get a BotOut notice). Either way the player gets a welcome, the roster and a snapshot. A bot does not take a seat with a pending claim. If the room ends or goes away meanwhile, the player goes back to the queue with the original queue time. Cancel, a disconnect or a drain calls the claim off.
Queue Status
The game turns a QueueStatusInfo(State, Playlist, Elapsed, PlayersFound, PlayersNeeded) into its packet:
QueueState | Sent when |
|---|---|
Searching | On Enqueue, and every StatusIntervalSeconds while queued. PlayersFound is the number waiting in the playlist, capped at PlayersNeeded. Elapsed is the time waited. |
Found | A room or a seat was found (PlayersFound = PlayersNeeded). |
Idle | After Cancel, joining a lobby, an Enqueue refused by a drain, or when a drain empties the queue. |
Full and Draining Servers
With the host bound to the server node (UseServer, see Match Rooms), a group forms only while CanOpenRoom(mode, playlist) holds:
- Full (or not ready yet): no new rooms. Players keep waiting and keep getting
Searchingupdates; nobody is dropped. Join in progress still fills running rooms. As soon as a room ends and the load fits, groups form again. - Draining: pending joins are called off (
Idle), the queues are emptied (Idle, then the host'sServerDraining()), andEnqueuereturnsfalse. In a fleet the queue is handed to another server instead (see below).
In a Fleet
With the host in a fleet (UseFleet), each server publishes its queue length per playlist as the fleet stat queue:<playlist>. The players of a playlist gather on one server: the one that still has room for one more of its rooms and the most of them waiting; ties go to the lowest server id, so every server picks the same one. Players move with their waiting time kept, through IRoomGame.Redirect (reason queue). A full server sends its queue to a server with room. A draining server hands its queue over (reason drain), or tells the players they are idle when no server has room. A player who just arrived is not moved again for FleetMoveCooldownSeconds. With one server nothing moves. See Fleet & Scaling.
Using the Matchmaker Directly
module.Queue is the Matchmaker. It takes the current time as an argument, which makes it easy to test or to drive from your own loop:
var mm = new Matchmaker(new MatchmakingOptions(), [FieldPlaylists.Ranked]);
mm.Enqueue(new QueueEntry("a", "ranked", Rating: 1000, QueuedAt: 0));
mm.Enqueue(new QueueEntry("b", "ranked", Rating: 1150, QueuedAt: 0));
mm.Enqueue(new QueueEntry("c", "ranked", Rating: 1050, QueuedAt: 0));
mm.Enqueue(new QueueEntry("d", "ranked", Rating: 1100, QueuedAt: 0));
var group = mm.TryFormRated("ranked", now: 2.0); // null until the windows cover a full group
var (team0, team1) = Matchmaker.BalanceTeams(group!, byRating: true);
Get, Remove, Waiting(playlist), PopOldest(playlist), TryFormFill(playlist, now, reserved), TryFormRated(playlist, now), RatingWindow(waited, options) and BalanceTeams(players, byRating) are public.
Configuration Reference
Both records are passed in code (the package does not read them from config.yml).
PlaylistOptions:
| Property | Default | Meaning |
|---|---|---|
Id | required | The queue's name, also the rooms' Playlist. |
Mode | required | The room mode passed to CreateSimulation and LoadOf. |
TeamSize | 1 | Seats per team. A match has two teams; PlayersNeeded is TeamSize × 2. |
Rated | false | Rating windows, teams balanced by rating, no bots at the start; join in progress only inside the rating window. |
Rules | new RoomRules() | The room rules of the rooms it starts. |
MatchmakingOptions:
| Property | Default | Meaning |
|---|---|---|
FillAfterSeconds | 6 | Unrated: seconds the oldest player waits for others before bots fill. |
JoinMinTimeLeftSeconds | 45 | Join in progress only into rooms with at least this much time left. |
SwapIntervalSeconds | 4 | Minimum seconds between two bot swaps in one room. |
SwapNoticeSeconds | 2 | Seconds between the Joining notice and a bot seat's swap (open seats are taken at once). |
RatingWindowStart | 100 | Rating window of a player who just queued. |
RatingWindowGrowthPerSecond | 25 | Window growth per whole second waited. |
RatingWindowMax | 500 | Largest window. |
StatusIntervalSeconds | 1 | Seconds between Searching updates (and between fleet placement passes). |
FleetMoveCooldownSeconds | 3 | Fleet: seconds a player who just arrived from another server is not moved again. |