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 (the auth → matchmake → resolve → connect flow); control-plane/lattice-director/src/LatticeDirector/Program.cs.

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
    add_child(mm)                                   # HTTPRequest needs a parent in the tree

    mm.failed.connect(func(stage, code, message):
        push_error("join failed at %s (%d): %s" % [stage, code, 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.

Verified against: bindings/lattice-godot/demo/addons/lattice/lattice_matchmaker.gd; director port run-all.sh:30 (PORT_DIRECTOR=3010); endpoint list Program.cs:141,164.

Its signals

Signal Arguments Fires when
matched endpoint: String, session_token: String, session_id: String /resolve returned, before connecting
joined endpoint: String The runner accepted the connect call
failed stage: String, code: int, message: 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.

Handling failures

Every refusal surfaces as the failed signal rather than an exception. stage says which step broke — "matchmake", "resolve" or "connect"code is the HTTP status, and message carries the director's own {"error": "..."} text where it sent one:

mm.failed.connect(_on_join_failed)

func _on_join_failed(stage: String, code: int, message: String) -> void:
    match code:
        409: show_message("No servers available in this region right now.")
        401: await refresh_access_token_and_retry()
        _:   push_error("join failed at %s (%d): %s" % [stage, code, message])

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

code Meaning
401 Missing/invalid/expired access token
400 region or mode missing
404 Unknown session handle (/resolve)
409 No live game-server instance with capacity matches region+mode
0 Local failure: the request never reached the director (transport error, or the matchmaker is not in the scene tree), or the director's endpoint was not host:port

Verified against: Program.cs:145-155 (matchmake 401/400/409), Program.cs:169-172 (resolve 401/404); lattice_matchmaker.gd (the failed.emit(stage, 0, …) local-failure paths).

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
}

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
}

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: Program.cs:167-190; lattice-director/src/LatticeDirector/Api/Contracts.cs (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…"
}

Verified against: Program.cs:164-180; Contracts.cs (ResolveRequest, ResolveResponse).

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: lattice-director/src/LatticeDirector/Tokens/SessionTokenService.cs.

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: Contracts.cs (the snake_case record members), 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:215-226 (connect_to_endpoint), lattice_runner.cpp:186-213 (connect_to), lattice_glue.cpp:307-360 (the endpoint parser), 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: Program.cs:101-120 (/fleet/register, gated by X-Fleet-Token).

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

Verified against: Program.cs:141-160.

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 Program.cs:141,164; lattice_runner.cpp:215-226.


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: Program.cs:141-180, SessionTokenService.cs.
  • 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: demo/addons/lattice/lattice_matchmaker.gd, lattice_runner.cpp:186-226, 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: Program.cs:101-120,141-160.
  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:201-209 (the token hand-off), SessionTokenService.cs.
  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.