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:
@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:
if await mm.join(runner, "eu", "ffa"):
code_label.text = mm.last_room_code # e.g. "0NHBZ4" — read this out
await runner.connected
# 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.
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:
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.
{
"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.
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:
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:
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:
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:
/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: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.
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: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:
- 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:control-plane/lattice-director/src/LatticeDirector/Program.cs:160-179(RegisterRequest);control-plane/lattice-director/src/LatticeDirector/Services/Matchmaker.cs:52-88(MatchParty). - 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:203-209(glue_runner_connect, the token hand-off),SessionTokenService.cs:58-93(Issue). - 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.