Skip to content

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:

Find a session and connect to it
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

Constructors
new Matchmaker(string directorBaseUrl, string accessToken = null, HttpMessageHandler handler = null);
new Matchmaker(HttpClient http, string accessToken = null);
  • directorBaseUrl must be an absolute http/https URL. A path prefix works (https://api.example/director) — the helper appends the route under it.
  • accessToken is the lattice-auth access token from chapter 03, sent as Authorization: Bearer. It is a settable property: assign matchmaker.AccessToken when you refresh, and the next call picks it up.
  • handler lets 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 HttpClient you configured yourself; its BaseAddress must 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:

Party of three, 16-player session
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):

Join by handle
// 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.

MatchmakeAsyncPOST /matchmake

SessionDescription session = await _matchmaker.MatchmakeAsync(new MatchRequest("eu", "ffa"));
// session.SessionHandle / SessionId / Endpoint / Region / Mode / PlayerCount / MaxPlayers

Wire body and response:

POST /matchmake (Authorization: Bearer <access_token>)
{
  "region": "eu",
  "mode": "ffa",
  "party": ["sub-a", "sub-b"],   // optional; omitted ⇒ solo
  "max_players": 8               // optional
}
200
{
  "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.

ResolveAsyncPOST /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.

200
{
  "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.

GetSessionAsyncGET /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:

session token claims
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:

A game server registers itself (server-side, X-Fleet-Token required)
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:

JoinMetadata convention
// "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.

From /resolve to Connect (the glue the helper does for you)
// 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:

  • /matchmake and /resolve are fully implemented and return exactly the shapes above. /matchmake returns a session_handle + endpoint; /resolve returns endpoint + a real Ed25519-signed session_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. Matchmaker covers 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 mocked HttpMessageHandler, and JoinAsync is additionally driven against a real listening runner — a mocked director hands back a live loopback endpoint and the client reaches Connected.
    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:

  1. You must run and register a game server. The director never launches servers. With no live fleet instance matching the requested region+mode, /matchmake returns 409. In the self-hosted path you start your own server (e.g. LatticeGameMode.Server) and POST /fleet/register its endpoint.
    Verified against: lattice-director/src/LatticeDirector/Services/Matchmaker.cs:66-76, Program.cs:101-120.
  2. Session-token enforcement at the game server is yours to wire. The /resolve token 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.
  3. The Fusion-compat lobby API is still stubs. SessionInfo / LobbyInfo / GetAvailableRegions / JoinSessionLobby on NetworkRunner are compile-stubs that never connect — they exist for ported Photon Fusion code to compile. Use Matchmaker, 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.