Public API for the built-in matchmaking system.
Matchmaking is ticket-based. Each call to join/4 creates a ticket in the
database. The periodic Gamend.Matchmaking.Worker groups queued tickets
that share the same match_params and creates a hidden lobby for each match.
Tickets are the source of truth; there is no cached queue. The sweep reads
them directly through the (status, queued_at) index, and the worker
serializes the sweep cluster-wide so only one instance forms matches at a
time.
Liveness
A queued ticket only means something while its owner can still be pulled into a lobby. Two things keep the queue honest:
UserChannel.terminate/2cancels the user's tickets when their socket closes, andprune_offline/0sweeps tickets whose owner the database no longer considers online — the safety net for tickets created over HTTP by a client that never opened a socket.
Summary
Functions
Associates already-claimed tickets with their created lobby.
Cancels the user's queued tickets. Returns how many were cancelled.
Cancels one ticket by id, whoever owns it — for admins clearing a stuck
ticket. Returns {:error, :not_found} for an unknown, malformed or
already-resolved id.
Atomically claims a formed match: flips its tickets from queued to matched.
Counts tickets matching the same filters as list_tickets/1.
The user's current queued ticket, or nil.
Cancels claimed tickets whose owner could not be seated in the lobby.
Unlike requeue/1 these do not go back in the queue — a user who cannot
join a lobby (banned, already in one) would fail again on every tick.
Fetches a ticket by id, or nil when the id is unknown or malformed.
Adds a user — or their whole party — to the matchmaking queue.
All queued tickets grouped by match_params, oldest first within a group.
Lists tickets for the admin views, newest first.
Cancels queued tickets whose owner has been offline past the grace period.
Returns claimed tickets to the queue (lobby creation failed).
Queue statistics for the admin dashboard and the public stats endpoint:
counts by status, plus the depth of each distinct queued match_params.
Functions
@spec assign_lobby([Gamend.Matchmaking.Ticket.t()], Ecto.UUID.t()) :: :ok
Associates already-claimed tickets with their created lobby.
@spec cancel(Ecto.UUID.t()) :: non_neg_integer()
Cancels the user's queued tickets. Returns how many were cancelled.
A party queues as a unit, so it leaves as one: cancelling any member's ticket cancels the whole party's. Any member may do this — only the leader can queue, but nobody should be stuck in a queue they cannot leave.
@spec cancel_ticket(Ecto.UUID.t()) :: {:ok, Gamend.Matchmaking.Ticket.t()} | {:error, :not_found}
Cancels one ticket by id, whoever owns it — for admins clearing a stuck
ticket. Returns {:error, :not_found} for an unknown, malformed or
already-resolved id.
@spec claim([Gamend.Matchmaking.Ticket.t()]) :: :ok | :conflict
Atomically claims a formed match: flips its tickets from queued to matched.
Runs inside the sweep's cluster lock, before any lobby exists (the lobby is
created outside the lock because Lobbies fires hooks and broadcasts, which
must not run inside a transaction). The status == queued guard makes the
claim atomic: if any ticket was cancelled since it was read, nothing is
claimed and :conflict is returned.
@spec count_tickets(keyword()) :: non_neg_integer()
Counts tickets matching the same filters as list_tickets/1.
@spec current_ticket(Ecto.UUID.t()) :: Gamend.Matchmaking.Ticket.t() | nil
The user's current queued ticket, or nil.
@spec discard([Gamend.Matchmaking.Ticket.t()]) :: :ok
Cancels claimed tickets whose owner could not be seated in the lobby.
Unlike requeue/1 these do not go back in the queue — a user who cannot
join a lobby (banned, already in one) would fail again on every tick.
@spec get_ticket(Ecto.UUID.t()) :: Gamend.Matchmaking.Ticket.t() | nil
Fetches a ticket by id, or nil when the id is unknown or malformed.
@spec join(Gamend.Accounts.User.t(), map(), pos_integer() | nil, pos_integer() | nil) :: {:ok, Gamend.Matchmaking.Ticket.t()} | {:error, Ecto.Changeset.t() | atom()}
Adds a user — or their whole party — to the matchmaking queue.
match_params is a map of arbitrary string keys and values, for example
%{"mode" => "deathmatch", "map" => "dust2"}. Only tickets with exactly the
same parameters are matched together.
min_players and max_players can be passed to override the defaults.
Parties
A user in a party cannot queue alone: only the leader may queue, and doing so
creates one ticket per member sharing the party's id. Those tickets are
matched as an indivisible unit. Because only the leader queues, every member's
ticket carries the same limits by construction — there is no way for members
to disagree about min_players/max_players.
Returns the caller's own ticket. Fails with:
{:error, :not_party_leader}— a member tried to queue{:error, :party_too_large}— the party cannot fit inmax_players{:error, :party_has_blocked_pair}— two members have blocked each other{:error, :already_queued}— the caller or a party member is queued
@spec list_queued_by_params() :: %{required(map()) => [Gamend.Matchmaking.Ticket.t()]}
All queued tickets grouped by match_params, oldest first within a group.
Read straight from the database: the queue changes on every join and the sweep runs seconds apart, so caching here would only ever serve stale rows.
@spec list_tickets(keyword()) :: [Gamend.Matchmaking.Ticket.t()]
Lists tickets for the admin views, newest first.
Options: :status, :user_id, :page, :page_size.
@spec prune_offline() :: non_neg_integer()
Cancels queued tickets whose owner has been offline past the grace period.
Reads users.is_online — the flag the user channel maintains — instead of
taking a caller-supplied id list, so it stays correct on any node. Going
offline is not enough on its own: the player must have been gone longer than
matchmaking_offline_grace_ms, so a brief disconnect does not cost a queue
position. A player who queued over HTTP and never opened a socket has no
last_seen_at, so their queued_at starts the same grace period.
A party is matched as a unit, so it is pruned as one: if any member times out, the whole party leaves the queue. Returns how many tickets were cancelled.
@spec requeue([Gamend.Matchmaking.Ticket.t()]) :: :ok
Returns claimed tickets to the queue (lobby creation failed).
@spec stats() :: %{ queued: non_neg_integer(), matched: non_neg_integer(), cancelled: non_neg_integer(), queues: [%{params: map(), waiting: non_neg_integer()}] }
Queue statistics for the admin dashboard and the public stats endpoint:
counts by status, plus the depth of each distinct queued match_params.