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:
@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.
{
"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.
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:
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:
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:
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:
/matchmakeand/resolveare fully implemented and return exactly the shapes above./resolvereturnsendpoint+ a real Ed25519-signedsession_token+session_id. Both require a valid auth Bearer token. These are engine-agnostic HTTP endpoints — Godot reaches them withHTTPRequestexactly 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.
LatticeMatchmakerposts to/matchmakeand/resolvewith your Bearer token and hands the result toLatticeRunner.connect_to_endpoint, which forwards to the nativelattice_runner_connectwith the session token as opaque bytes — the same call sequence the Unity binding'sNetworkRunner.Connectmakes.
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:
- 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.
Verified against:Program.cs:101-120,141-160. - Session-token enforcement at the game server is not demonstrated here. The
/resolvetoken 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'stoken/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. - The GDScript layer itself is not run in CI. Godot is not installed in this repo's environment, so
lattice_matchmaker.gdis 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 reachingCONNECTEDthrough the same forwardersconnect_to_endpointuses.
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.