04 — Lobbies & matchmaking¶
With an access token (chapter 03) you can ask the lattice-director service
to place you in a game session and hand back a server endpoint + a session token, which the runner
then connects with.
The binding ships a Matchmaker helper that performs that whole exchange, so you no longer
hand-roll HTTP against the director. The sequence it runs:
sequenceDiagram
participant App as Your Unity client
participant AU as auth
participant DI as director
participant GS as Game server (your sim)
App->>AU: login → access_token
App->>DI: POST /matchmake (Bearer access_token) → session_handle + endpoint
App->>DI: POST /resolve (Bearer, {session_handle}) → { endpoint, session_token }
App->>GS: runner.Connect(host, port, session_token)
Verified against: bindings/lattice-unity/com.lattice.netcode/Runtime/Matchmaking/Matchmaker.cs;
control-plane/lattice-director/src/LatticeDirector/Program.cs:141-193.
Read the verified-status section before you build on this
The director endpoints are fully implemented server-side and the helper is covered by the binding's headless test suite — but the flow still needs a game-server instance registered with the fleet, and the session token is only enforced if your server verifies it. The exact status is spelled out at the end of this chapter.
The one-call join¶
Matchmaker.JoinAsync matchmakes, resolves, splits the endpoint, and connects the runner:
using Lattice;
using Lattice.Matchmaking;
// Keep ONE Matchmaker for the app's lifetime (it owns an HttpClient).
_matchmaker = new Matchmaker("https://director.example:3010", accessToken);
// ... when the player presses Play:
SessionTicket ticket = await _matchmaker.JoinAsync(_runner, region: "eu", mode: "ffa");
Debug.Log($"joined {ticket.SessionId} on {ticket.Host}:{ticket.Port}");
That is the whole flow. JoinAsync starts the runner as a Client if it is not running yet, then
calls Connect with the session token as UTF-8 bytes.
Verified against: Runtime/Matchmaking/Matchmaker.Connect.cs:20-62 (JoinAsync, ConnectRunner);
exercised end to end against a live loopback server in
tests/Lattice.Headless.Tests/MatchmakerConnectTests.cs.
Await it on the main thread
Unity installs a SynchronizationContext, so the continuation that starts the runner and calls
Connect resumes on the main thread. Call JoinAsync from a MonoBehaviour async method (or
an async void button handler) and do not ConfigureAwait(false) around it.
Constructing the Matchmaker¶
new Matchmaker(string directorBaseUrl, string accessToken = null, HttpMessageHandler handler = null);
new Matchmaker(HttpClient http, string accessToken = null);
directorBaseUrlmust be an absolute http/https URL. A path prefix works (https://api.example/director) — the helper appends the route under it.accessTokenis the lattice-auth access token from chapter 03, sent asAuthorization: Bearer. It is a settable property: assignmatchmaker.AccessTokenwhen you refresh, and the next call picks it up.handlerlets you supply your own transport (certificate pinning, a proxy, or a mock in tests). An injected handler is not disposed with the Matchmaker.- The second overload takes an
HttpClientyou configured yourself; itsBaseAddressmust be the director's base URL, and it is not disposed with the Matchmaker either.
Verified against: Matchmaker.cs:38-85.
Parties and session size¶
Pass a MatchRequest for anything beyond a solo join:
var request = new MatchRequest("eu", "ffa")
{
Party = new[] { "sub-a", "sub-b", "sub-c" }, // account ids; omit ⇒ just you
MaxPlayers = 16, // omit ⇒ the director's default of 8
};
SessionTicket ticket = await _matchmaker.JoinAsync(_runner, request);
Omitted fields are left out of the request body, not sent as null — the director treats an absent
party as "the caller's own sub from the bearer token".
Verified against: DirectorContracts.cs:19-44 (MatchRequest), Program.cs:150-152 (solo ⇒ own
sub, max_players ?? 8).
Joining a session someone else found¶
A party member does not matchmake — the leader already did. They resolve the shared handle, which mints their own session token (tokens are per player):
// e.g. from the social service's "lattice.sessionHandle" join metadata
SessionTicket ticket = await _matchmaker.JoinByHandleAsync(_runner, sessionHandle);
Verified against: Matchmaker.Connect.cs:34-46; per-player sub binding at
SessionTokenService.cs via Program.cs:174-179.
Handling failures¶
Every director refusal surfaces as a DirectorException carrying the HTTP status and the service's
error text:
try
{
await _matchmaker.JoinAsync(_runner, "eu", "ffa");
}
catch (DirectorException ex) when (ex.StatusCode == 409)
{
ShowMessage("No servers available in this region right now.");
}
catch (DirectorException ex) when (ex.StatusCode == 401)
{
await RefreshAccessTokenAndRetry();
}
StatusCode |
Meaning |
|---|---|
| 401 | Missing/invalid/expired access token, or a token from an untrusted authority |
| 400 | region or mode missing |
| 404 | Unknown session handle (/resolve) or session id (/sessions/{id}) |
| 409 | No live game-server instance with capacity matches region+mode |
| 0 | Local failure: an unparseable body, or an endpoint that is not host:port |
Transport failures (DNS, TLS, timeouts) surface as the usual HttpRequestException /
TaskCanceledException, untouched. The default request timeout is 15 s; pass your own HttpClient
to change it.
Verified against: Matchmaker.cs:185-213 (status → DirectorException),
DirectorContracts.cs:144-162; director statuses at Program.cs:144-156, 169-172.
The individual routes¶
JoinAsync is FindSessionAsync + connect, and FindSessionAsync is MatchmakeAsync +
ResolveTicketAsync. Use the steps directly when a lobby UI needs what is in between.
MatchmakeAsync → POST /matchmake¶
SessionDescription session = await _matchmaker.MatchmakeAsync(new MatchRequest("eu", "ffa"));
// session.SessionHandle / SessionId / Endpoint / Region / Mode / PlayerCount / MaxPlayers
Wire body and response:
{
"region": "eu",
"mode": "ffa",
"party": ["sub-a", "sub-b"], // optional; omitted ⇒ solo
"max_players": 8 // optional
}
{
"session_handle": "3pQ…_", // opaque, URL-safe; this is what you resolve
"room_code": "0NHBZ4", // 6 chars, for a player to read out — see below
"session_id": "b1f2…",
"endpoint": "1.2.3.4:9000", // the game server's host:port
"region": "eu",
"mode": "ffa",
"player_count": 1,
"max_players": 8
}
Verified against: Program.cs:167-190; Api/Contracts.cs:25-39 (MatchmakeRequest,
MatchmakeResponse); binding mapping at DirectorContracts.cs:47-77.
room_code is the one to show a player
The handle is built to be unguessable, which also makes it unreadable. room_code is six
characters from an alphabet with no vowels and no look-alike glyphs, so it survives being
read out loud. It is unique within your game rather than globally, so resolving one
needs your game's X-Lattice-Api-Key alongside the player's bearer — send that header on
/matchmake too, since it is what tags the session with your game. Codes expire after six
hours; the handle does not.
ResolveAsync → POST /resolve¶
Exchanges the opaque handle for connect details and mints the session token. Resolve even straight after matchmaking — the token exists only here.
ResolveResult resolved = await _matchmaker.ResolveAsync(sessionHandle);
// resolved.Endpoint / SessionToken / SessionId
The body takes either identifier — {"session_handle": "…"} as shown, or
{"room_code": "0NHBZ4"} with the X-Lattice-Api-Key header. Resolving also takes a seat
in the session, so a room that fills up stops being offered to other matchmaking players; it is
idempotent, so a host resolving its own handle does not consume a second slot.
{
"endpoint": "1.2.3.4:9000",
"session_token": "eyJ…", // director-signed Ed25519 JWT
"session_id": "b1f2…"
}
Prefer ResolveTicketAsync, which returns a SessionTicket with the endpoint already split.
Verified against: Program.cs:164-180; Contracts.cs:43-45; Matchmaker.cs:108-161.
GetSessionAsync → GET /sessions/{id}¶
Live occupancy for a session you know the id of — for a lobby screen polling player counts. It mints no token.
SessionDescription status = await _matchmaker.GetSessionAsync(sessionId);
Label.text = $"{status.PlayerCount}/{status.MaxPlayers}";
Verified against: Program.cs:182-193; Matchmaker.cs:122-136.
What the session token contains¶
The director signs an Ed25519 (EdDSA) JWT binding the session to a specific endpoint and player, so the game server can verify — offline, against the director's JWKS — exactly who may join which session at the netcode handshake:
sid = session id hdl = session handle
ep = endpoint (host:port) sub = player account id (from your access token)
region = region iat/exp/jti = issued/expiry/unique id
Verified against: lattice-director/src/LatticeDirector/Tokens/SessionTokenService.cs:14-24,58-93.
Connecting with a ticket you already have¶
SessionTicket is the connect-ready shape: Host, Port, SessionToken, SessionTokenBytes(),
plus SessionId / SessionHandle / the raw Endpoint. Build one from details you obtained
elsewhere (a cached resolve, or a payload passed between systems) and connect without another round
trip:
SessionTicket ticket = SessionTicket.ForEndpoint("1.2.3.4:9000", sessionToken, sessionId);
Matchmaker.ConnectRunner(_runner, ticket); // starts the runner as Client if needed, then Connects
DirectorEndpoint.TryParse is the same host:port split on its own, if you only need that. Both
accept bracketed IPv6 ([::1]:9000); a bare IPv6 literal is rejected as ambiguous, because there
is no way to tell a port from an address group.
Verified against: DirectorContracts.cs:102-142 (SessionTicket, ForEndpoint), 165-217
(DirectorEndpoint); parsing cases in tests/Lattice.Headless.Tests/DirectorEndpointTests.cs.
Where game servers come from¶
The director does not spawn servers. A session's endpoint is the endpoint a running
game-server instance advertised by registering itself with the fleet:
curl -s -X POST http://director:3010/fleet/register -H "X-Fleet-Token: $FLEET" \
-H 'content-type: application/json' \
-d '{"instance_id":"i-1","endpoint":"1.2.3.4:9000","region":"eu","modes":["ffa"],"capacity":64}'
Verified against: Program.cs:101-120 (/fleet/register, gated by X-Fleet-Token);
docs-site/docs/getting-started/self-hosted.md:69-73.
The matchmaker then either reuses an open session in that region+mode or creates a new one on the
live instance with the most free capacity. With no live registered instance matching
region+mode, /matchmake returns 409 and JoinAsync throws a DirectorException with
StatusCode == 409.
Verified against: lattice-director/src/LatticeDirector/Services/Matchmaker.cs:46-92.
Social parties (optional)¶
The social service models parties and carries game-defined join metadata. The blessed convention for suite games is a well-known bag key that holds a director-resolvable handle:
// "lattice.sessionHandle" — a director-resolvable handle a suite game reads → resolve → connect.
public const string LatticeSessionHandleKey = "lattice.sessionHandle";
A party leader calls JoinAsync (or FindSessionAsync), publishes ticket.SessionHandle into the
party's join metadata, and members call JoinByHandleAsync with it.
Verified against: control-plane/lattice-social-client/src/Models.cs:79-96 (JoinMetadata,
LatticeSessionHandleKey), Models.cs:118-126 (Party with SessionHandle).
Party matchmaking is server-modeled, not exercised by the Unity sample
The party plumbing exists on the social service and its client models, but the tic-tac-toe sample does not use it (it's a two-player listen-server). Treat this section as a pointer to the capability, not a verified end-to-end Unity flow.
Appendix: calling the director yourself¶
The helper is optional — the routes are plain HTTP and nothing stops you from calling them with
UnityWebRequest, your own HttpClient, or a backend of your own. Use this path if you already have
an HTTP layer with your own retry/telemetry policy, or if your session data arrives from somewhere
other than the director.
// resolve returned: string endpoint (e.g. "1.2.3.4:9000"), string sessionToken (a JWT)
var parts = endpoint.Split(':');
string host = parts[0];
ushort port = ushort.Parse(parts[1]);
byte[] token = System.Text.Encoding.UTF8.GetBytes(sessionToken);
_runner.StartGame(LatticeGameMode.Client);
_runner.Connect(host, port, token);
Two things that split is missing and the helper handles: bracketed IPv6 endpoints, and a clear error
when a fleet instance registered something that is not host:port. If you keep your own HTTP layer
but want the rest, hand your values to SessionTicket.ForEndpoint and Matchmaker.ConnectRunner.
Verified against: NetworkRunner.cs:230-236 (Connect(string address, ushort port, byte[] token)).
Verified wiring status (honest)¶
What is real and works:
/matchmakeand/resolveare fully implemented and return exactly the shapes above./matchmakereturns asession_handle+endpoint;/resolvereturnsendpoint+ a real Ed25519-signedsession_token+session_id. Both require a valid auth Bearer token.
Verified against:Program.cs:141-180,SessionTokenService.cs:58-93, and the director's own suite (MatchmakerTests.cs,EndpointTests.cs,SessionTokenTests.cs).- The binding now ships a director client.
Matchmakercovers matchmake / resolve / session-status, the endpoint split, the error mapping and the connect. Its request shapes, response parsing, error paths and base-URL handling are pinned by headless tests against a mockedHttpMessageHandler, andJoinAsyncis additionally driven against a real listening runner — a mocked director hands back a live loopback endpoint and the client reachesConnected.
Verified against:tests/Lattice.Headless.Tests/MatchmakerTests.cs,MatchmakerConnectTests.cs,DirectorEndpointTests.cs. - The endpoint is a real advertised
host:port— but only because a game-server instance registered it via/fleet/register.
The caveats you must design around:
- You must run and register a game server. The director never launches servers. With no live
fleet instance matching the requested region+mode,
/matchmakereturns 409. In the self-hosted path you start your own server (e.g.LatticeGameMode.Server) andPOST /fleet/registerits endpoint.
Verified against:lattice-director/src/LatticeDirector/Services/Matchmaker.cs:66-76,Program.cs:101-120. - Session-token enforcement at the game server is yours to wire. The
/resolvetoken is designed to be verified offline by the game server at the netcode handshake (SessionTokenService.Validate), but the tic-tac-toe sample connects with an arbitrary token ("ttt") to a loopback server, so nothing here proves a native server rejecting a bad token. The helper delivers the token; verifying it is a server-side responsibility.
Verified against:TicTacToeGame.cs:61;SessionTokenService.cs:95-111. - The Fusion-compat lobby API is still stubs.
SessionInfo/LobbyInfo/GetAvailableRegions/JoinSessionLobbyonNetworkRunnerare compile-stubs that never connect — they exist for ported Photon Fusion code to compile. UseMatchmaker, not those.
Verified against:Runtime/FusionCompat/NetworkRunner.Simulation.cs:150-204.
Bottom line: the matchmake → resolve → connect contract is real, and the glue is now in the binding. What remains yours is running and registering a game server, and enforcing the session token on it. For learning and for local/P2P play, skip the director entirely and use the listen-server flow from chapter 02.
Where the session token actually gets checked
Passing the token to Connect only delivers it. The game server verifies it inside the
handshake, through the token-validation seam of the
secure datagram channel — which is also what turns that
channel from unauthenticated into authenticated.
Next: 05 — Networked objects, the heart of replication.