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, then hand both to the runner. The binding ships a helper — LatticeMatchmaker — that does the whole sequence in one call.

The intended sequence (from the project's own getting-started docs) is:

sequenceDiagram
  participant App as Your Godot 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_to_endpoint(endpoint, session_token)

Verified against: docs-site/docs/getting-started/common-setup.md:43-52 (the auth → matchmake → resolve → connect flow); control-plane/lattice-director/src/LatticeDirector/Program.cs:200-277 (MapPost).

Read the verified-status section at the end before you ship on this

The director endpoints are fully implemented server-side and return real data, and the Godot binding can now consume them end to end. What you still have to provide yourself is a running, registered game server — the director never launches one. The exact status is spelled out at the end of this chapter.

The one-call join

The binding ships res://addons/lattice/lattice_matchmaker.gd — a Node that walks matchmake → resolve → connect for you. Add it to the tree, give it your access token, and call join:

Matchmake, resolve and connect in one call
@onready var runner: LatticeRunner = $LatticeRunner

func join_online(access_token: String) -> void:
    var mm := LatticeMatchmaker.new()
    mm.director_url = "http://localhost:3010"
    mm.access_token = access_token
    mm.api_key = "lk_yourgame"                      # your game's PUBLIC key
    add_child(mm)                                   # HTTPRequest needs a parent in the tree

    mm.failed.connect(func(stage, code, message, reason):
        push_error("join failed at %s (%d, %s): %s" % [stage, code, reason, message]))

    runner.start_game(LatticeRunner.MODE_CLIENT)    # start the core; no link yet
    if await mm.join(runner, "eu", "ffa"):
        await runner.connected                      # the netcode handshake completed
        print("in the session")

join(runner, region, mode, max_players := 0, party := PackedStringArray()) returns true once the runner has accepted the connect call. The link itself completes asynchronously — await runner.connected for that. max_players and party are sent only when you set them, so a solo join uses your own token subject and the director's default session size.

api_key is your game's public api key, sent as X-Lattice-Api-Key. It names the game (the access token is what authorises), so it is safe to ship in a client build. Set it: room codes are unique within a game, so joining by one requires it — and it is also what tags a new session as yours, so a client that sends it only when joining would create rooms in the unscoped pool and then fail to find its own. The helper sends it on every route for that reason.

Verified against: lattice_matchmaker.gd:129-152 (join), lattice_matchmaker.gd:103-107 (api_key); director port run-all.sh:30 (PORT_DIRECTOR=3010); endpoint list control-plane/lattice-director/src/LatticeDirector/Program.cs:200-223 (MapPost); control-plane/lattice-director/src/LatticeDirector/Program.cs:134-141 (GameScopeAsync, 403 on an unresolvable key).

Its signals

Signal Arguments Fires when
matched endpoint: String, session_token: String, session_id: String, room_code: String, is_private: bool /resolve returned, before connecting
joined endpoint: String The runner accepted the connect call
failed stage: String, code: int, message: String, reason: String Any step failed

matched gives a lobby UI the session details a moment before the link is dialled; joined confirms the runner took the connect call. Neither means the handshake is done — await runner.connected for that.

matched's room_code is the six characters to show the host's players; it is empty on a join-by-handle, which never learns one. is_private reports what the joined room IS, not what was asked for. failed's reason is a stable token to branch on — see the table under Handling failures.

Verified against: lattice_matchmaker.gd:54-66 (matched, joined, failed).

Playing with a friend: the room code

The handle is built to be unguessable, which also makes it unreadable. Every /matchmake also mints a six-character room code the host can say out loud, and the friend types into their own copy of the game. One call on each side:

Host: show the code
if await mm.join(runner, "eu", "ffa"):
    code_label.text = mm.last_room_code          # e.g. "0NHBZ4" — read this out
    await runner.connected
Friend: type it in
# Whatever they typed is fine: case, spaces, dashes and look-alike glyphs are fixed up first.
if await mm.join_by_code(runner, code_field.text):
    await runner.connected

join_by_code(runner, code) needs api_key set, because a code is unique within your game rather than globally — the key is what tells the director whose codes to search. Otherwise it behaves exactly like join: it resolves this player's own session token and connects the runner.

Codes are six characters from a 27-glyph alphabet with no vowels (so a code can never spell a word) and no look-alike pairs: O folds to 0, I and 1 to L, 2 to Z, 5 to S, 8 to B. join_by_code canonicalises what the player typed before sending it, through LatticeRunner.normalize_room_code — which is the C++ glue, not GDScript, so the six characters have one implementation that is compiled and run against the real core on Linux and under wine. A code one character too long is refused, never truncated: silently dropping a character would join a different room than the player asked for.

Validating a text field as the player types
var code := runner.normalize_room_code(code_field.text)   # "" ⇒ it cannot be a code
join_button.disabled = code.is_empty()

Verified against: lattice_matchmaker.gd:157-172 (join_by_code), lattice_matchmaker.gd:253-259 (_canonical_code); lattice_runner.cpp:228-235 (normalize_room_code), lattice_glue.cpp:362-397 (glue_normalize_room_code); alphabet and mapping copied from RoomCode.cs:21,57-85 (Normalize); 20 assertions in compile_check/glue_test_main.cpp §4d-bis, run on both Linux and Windows-under-wine by compile_check/build_check.sh.

Normalising locally is not belt-and-braces — it saves the player's retry budget

/resolve is rate-limited per IP (a burst of 10, refilling ~5/minute), and a request that was never going to succeed still spends one. Catching "that is not a code" before sending is what keeps three typos from throttling a player who had the right code all along — which is also why join_by_code reports a malformed code as reason == "malformed_code" with no request made.
Verified against: control-plane/lattice-director/src/LatticeDirector/Program.cs:87-104 (AddRateLimiter); lattice_matchmaker.gd:162-165 (REASON_MALFORMED_CODE, refused before any request).

Private rooms

A room a stranger can never be paired into. Quick match offers it to nobody, so its room code and handle are the only ways in — pass is_private as the last argument to join:

A room for invited friends only
if await mm.join(runner, "eu", "ffa", 4, PackedStringArray(), true):
    code_label.text = mm.last_room_code       # the only way anyone else gets in
    await runner.connected

The friend joins exactly as before, with join_by_code. They never send the flag and do not need to: /resolve reports what the room is, so they learn it too — from mm.last_private, or from the matched signal's last argument:

mm.matched.connect(func(endpoint, token, session_id, room_code, is_private):
    badge.visible = is_private)

A private request also never reuses an open session — it creates a fresh room or it fails. That is the flag working, and it gives 409 a second meaning on /matchmake: with the fleet saturated there is nowhere to make a new room, while the identical public request might still have been seated into somebody else's. It arrives as reason == "private_unavailable", distinct from no_capacity, so the useful thing to offer is quick match rather than "try again".

Verified against: lattice_matchmaker.gd:225-243 (matchmake, the private body field), lattice_matchmaker.gd:126-128 (last_private), lattice_matchmaker.gd:188-212 (_finish_join reads the resolve echo), lattice_matchmaker.gd:363-382 (_classify); director policy at control-plane/lattice-director/src/LatticeDirector/Services/Matchmaker.cs:52-87 (MatchParty); PrivateRoomPolicyTests.cs:89-105 (A_private_request_is_refused_rather_than_folded_into_an_open_public_room).

Handling failures

Every refusal surfaces as the failed signal rather than an exception. Branch on reason, not on code: the same status means different things on different routes. message is a sentence you can put straight in front of a player, and stage says which step broke — "matchmake", "resolve", "resolve_code" or "connect".

mm.failed.connect(_on_join_failed)

func _on_join_failed(stage: String, code: int, message: String, reason: String) -> void:
    if reason == LatticeMatchmaker.REASON_NOT_SIGNED_IN:
        await refresh_access_token_and_retry()
        return
    show_message(message)                       # "That room is already full.", …
    push_error("join failed at %s (%d, %s): %s" % [stage, code, reason, message])

Connect a named method rather than a lambda when the handler needs to await — a GDScript lambda cannot be a coroutine.

reason When What the player is told
unknown_code 404 resolving a room code No room with that code. Check it and try again.
unknown_session 404 resolving a handle That session is no longer available.
room_full 409 on /resolve That room is already full.
no_capacity 409 on /matchmake No servers are free right now. Try again in a moment.
private_unavailable 409 on a private /matchmake Could not open a private room right now. Try again, or use quick match.
rate_limited 429 — the per-IP /resolve budget Too many join attempts from your network. Wait a minute…
unknown_api_key 403 — the director does not know your key The platform did not recognise this build's game key.
not_signed_in 401 — no/expired access token You are not signed in any more. Sign in and try again.
malformed_code local, before any request That is not a valid room code. Codes are 6 characters.
api_key_missing local — api_key was never set This build is not set up to join by room code.
"" anything else: 400, 5xx, a transport error, or the matchmaker is not in the scene tree the director's own {"error": …} text, or a local description

Note the statuses that mean more than one thing: 409 is "this room is full" from /resolve, "no server has capacity" from /matchmake, and "no room could be created" on a private matchmake — where the status and the error text are identical and only the request tells them apart. 404 is "no such code" or "no such session" depending on which identifier was sent. That is the whole reason reason exists — a 429 shown as "check your code" is how a throttled player spends the rest of their budget retyping a code that was always right.

The director's own error text is kept in mm.last_error.detail for your logs, alongside stage, code, reason and message.

Verified against: lattice_matchmaker.gd:72-96 (REASON_MALFORMED_CODE, REASON_MESSAGES), lattice_matchmaker.gd:363-382 (_classify), lattice_matchmaker.gd:389-397 (_fail); director statuses at control-plane/lattice-director/src/LatticeDirector/Program.cs:200-277 (MapPost).

The individual routes

join is matchmake + resolve + connect. Use the steps directly when a lobby UI needs what is in between, or when you want to show the session before committing to it.

matchmake() → POST /matchmake

Requires a valid auth Bearer token. Places the caller (or a whole party roster) into a session.

POST /matchmake (Authorization: Bearer <access_token>)
{
  "region": "eu",
  "mode": "ffa",
  "party": ["sub-a", "sub-b"],   // optional; omit/empty ⇒ solo (your own sub is used)
  "max_players": 8,              // optional
  "private": true                // optional; omit ⇒ false (a normal, pairable room)
}

On success (200):

{
  "session_handle": "3pQ…_",     // opaque, URL-safe; this is what you resolve
  "room_code": "0NHBZ4",         // 6 chars, readable enough to say out loud
  "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,
  "private": false               // what the room IS
}

Show players the room_code, not the handle. It is unique within your game rather than globally, so /resolve needs your game's X-Lattice-Api-Key header alongside the player's bearer to look one up — 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.

Verified against: control-plane/lattice-director/src/LatticeDirector/Program.cs:200-223 (MapPost); control-plane/lattice-director/src/LatticeDirector/Api/Contracts.cs:25-39 (MatchmakeRequest, MatchmakeResponse).

resolve() → POST /resolve

Exchanges the opaque session_handle for concrete connect details and mints a session token. Also requires the Bearer token.

POST /resolve (Authorization: Bearer <access_token>)
{ "session_handle": "3pQ…_" }

On success (200):

{
  "endpoint": "1.2.3.4:9000",
  "session_token": "eyJ…",       // director-signed Ed25519 JWT
  "session_id": "b1f2…",
  "private": false               // what the JOINED room is, on both request shapes
}

The same route takes a room code instead, which is what join_by_code posts — the body is { "room_code": "0NHBZ4" } and the X-Lattice-Api-Key header is required, because a code is unique only within one game. resolve_by_code(code) is that call on its own; it expects a code that is already canonical (join_by_code does that for you) and refuses outright, with no request, when api_key is unset.

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.

Verified against: control-plane/lattice-director/src/LatticeDirector/Program.cs:227-277 (MapPost); control-plane/lattice-director/src/LatticeDirector/Api/Contracts.cs:43-51 (ResolveRequest, ResolveResponse); lattice_matchmaker.gd:264-275 (resolve_by_code); RoomCodeEndpointTests.cs:250-267 (Joining_by_code_takes_a_seat).

What the session token contains

The director signs an Ed25519 (EdDSA) JWT binding the session to a specific endpoint and player, so a game server can verify — offline, against the director's JWKS — exactly who may join which session at the netcode handshake.

Verified against: SessionTokenService.cs:14-24,58-93 (Issue).

Driving the two steps yourself

Both helper methods return the director's response as a Dictionary, or an empty one on failure (with failed emitted):

var match_result: Dictionary = await mm.matchmake("eu", "ffa")
if not match_result.is_empty():
    show_lobby(match_result["endpoint"], match_result["player_count"], match_result["max_players"])
    var resolved: Dictionary = await mm.resolve(match_result["session_handle"])
    if not resolved.is_empty():
        runner.connect_to_endpoint(resolved["endpoint"], resolved["session_token"])

The dictionary keys are the director's own snake_case JSON field names, unchanged — the helper does no remapping, because the director declares its request and response records in snake_case and does not remap them either.

Verified against: control-plane/lattice-director/src/LatticeDirector/Api/Contracts.cs:25-51 (MatchmakeResponse, ResolveResponse), control-plane/lattice-director/tests/LatticeDirector.Tests/EndpointTests.cs:118-120,148-153 (the tests read endpoint, player_count, session_handle, session_token straight off the wire).

Connecting with a ticket you already have

connect_to_endpoint(endpoint, token) takes the director's endpoint string as-is — it parses host:port (and bracketed IPv6, and a udp:// style scheme prefix) with the same tested parser start_game uses, then calls lattice_runner_connect with the session token as opaque bytes:

runner.connect_to_endpoint(resolved["endpoint"], resolved["session_token"])

Use connect_to(host, port, token) instead when you already have the parts separately. Either way the session data can come from anywhere — your own backend, a saved invite, a friend's lobby — not just from this helper.

Verified against: lattice_runner.cpp:217-226 (connect_to_endpoint), lattice_runner.cpp:188-215 (connect_to), lattice_glue.cpp:307-358 (glue_parse_endpoint), compile_check/glue_test_main.cpp (sections 4d and 4e).

Where game servers come from

The director does not spawn servers. A session's endpoint is one 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: control-plane/lattice-director/src/LatticeDirector/Program.cs:160-179 (RegisterRequest, gated by X-Fleet-Token).

With no live registered instance matching region+mode, /matchmake returns 409 "no suitable instance with capacity."

Verified against: control-plane/lattice-director/src/LatticeDirector/Services/Matchmaker.cs:52-88 (MatchParty).

Appendix: calling the director yourself

The helper is optional — these are ordinary HTTP endpoints, the same HTTPRequest shape as chapter 03, with the access token as a Bearer header. 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:

Matchmake with HTTPRequest
const DIRECTOR_BASE := "http://localhost:3010"

func matchmake(access_token: String, region := "eu", mode := "ffa") -> void:
    var body := JSON.stringify({ "region": region, "mode": mode })
    var headers := [
        "Content-Type: application/json",
        "Authorization: Bearer " + access_token,
    ]
    var err := http.request(DIRECTOR_BASE + "/matchmake", headers, HTTPClient.METHOD_POST, body)
    if err != OK:
        push_error("matchmake failed to start: %d" % err)
    # In request_completed: read session_handle + endpoint, then POST /resolve the same way.

You can still hand the result to the runner with connect_to_endpoint — it is the endpoint parser plus lattice_runner_connect, and it is what saves you from hand-splitting a bracketed IPv6 endpoint on :.

Verified against: director port run-all.sh:30 (PORT_DIRECTOR=3010); endpoint list control-plane/lattice-director/src/LatticeDirector/Program.cs:200-223,227-277 (MapPost); lattice_runner.cpp:217-226 (connect_to_endpoint).


Verified wiring status (honest)

What is real and works:

  • /matchmake and /resolve are fully implemented and return exactly the shapes above. /resolve returns endpoint + a real Ed25519-signed session_token + session_id. Both require a valid auth Bearer token. These are engine-agnostic HTTP endpoints — Godot reaches them with HTTPRequest exactly like any other client.
    Verified against: control-plane/lattice-director/src/LatticeDirector/Program.cs:200-277 (MapPost), SessionTokenService.cs:58-93 (Issue).
  • The endpoint is a real advertised host:port — but only because a game-server instance registered it via /fleet/register.

  • The Godot binding calls the director and connects. LatticeMatchmaker posts to /matchmake and /resolve with your Bearer token and hands the result to LatticeRunner.connect_to_endpoint, which forwards to the native lattice_runner_connect with the session token as opaque bytes — the same call sequence the Unity binding's NetworkRunner.Connect makes.
    Verified against: lattice_matchmaker.gd:175-192 (_finish_join), lattice_runner.cpp:188-226 (connect_to), bindings/lattice-unity/com.lattice.netcode/Runtime/NetworkRunner.cs:230-236.

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.
    Verified against: control-plane/lattice-director/src/LatticeDirector/Program.cs:160-179 (RegisterRequest); control-plane/lattice-director/src/LatticeDirector/Services/Matchmaker.cs:52-88 (MatchParty).
  2. Session-token enforcement at the game server is not demonstrated here. The /resolve token is designed to be verified offline by the game server at the netcode handshake. The binding now delivers it — connect_to(host, port, token) passes your string through as the ABI's token/token_len — but the tic-tac-toe sample connects to a local server with no control plane and no token, so this tutorial cannot show a native server rejecting a bad one. Treat token verification as a server-side responsibility you must wire.
    Verified against: samples/godot-tictactoe/README.md ("Local server"), lattice_runner.cpp:203-209 (glue_runner_connect, the token hand-off), SessionTokenService.cs:58-93 (Issue).
  3. The GDScript layer itself is not run in CI. Godot is not installed in this repo's environment, so lattice_matchmaker.gd is authored clean rather than executed. What is built and run against the real native library is the ABI path underneath it — endpoint parsing plus a host and a client reaching CONNECTED through the same forwarders connect_to_endpoint uses.
    Verified against: compile_check/build_check.sh, compile_check/glue_test_main.cpp (sections 4d and 4e).

Bottom line for Godot: matchmake → resolve → connect works end to end from GDScript. What you supply is the running game server the director can match you onto. For learning and for local play you can still skip the director entirely and use the host/join flow from chapter 02 — that is the path the sample and headless harness exercise.


Where the session token actually gets checked

Passing the token to connect_to 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 — and the part that is fully verified.