02 — Your first runner¶
The LatticeRunner is the single node you drive everything through. It is a Godot Node that owns
the native runner handle, ticks the core from _physics_process (Godot's fixed step), and translates
the C ABI callbacks into Godot signals.
Verified against: bindings/lattice-godot/src/lattice_runner.h:30-31,
bindings/lattice-godot/src/lattice_runner.cpp.
The runner is a Node you tick from the fixed step¶
Add a LatticeRunner as a child node in your scene (the sample names it LatticeRunner under the
Main node). It sets up its own fixed-step pump in _ready and calls lattice_runner_tick every
_physics_process — so every callback lands on the main thread and it is safe to touch nodes from your
signal handlers.
void LatticeRunner::_ready() {
set_physics_process(!Engine::get_singleton()->is_editor_hint()); // don't tick in the editor
}
void LatticeRunner::_physics_process(double delta) {
if (!runner_ || !started_) return;
lattice_runner_tick(runner_, delta); // recv -> tick -> send; callbacks fire synchronously here
}
Verified against: lattice_runner.cpp:98-101,387-392.
You never call the tick yourself in Godot — the node does it. In GDScript you grab the runner with
@onready:
Verified against: samples/godot-tictactoe/godot/scripts/main.gd:22.
The four game modes¶
start_game takes a mode from the LatticeRunner.Mode enum:
| Mode | Value | Meaning |
|---|---|---|
LatticeRunner.MODE_SERVER |
0 | Dedicated authoritative server (not a player) |
LatticeRunner.MODE_HOST |
1 | Server that is also a local participant (listen-server) |
LatticeRunner.MODE_CLIENT |
2 | Joins a server; no authority |
LatticeRunner.MODE_SHARED_HOST |
3 | Shared / distributed authority |
Verified against: lattice_runner.h:36-41 and bindings/lattice-godot/thirdparty/lattice/lattice.h:78-81
(the enum values 0–3).
is_server() is true for MODE_SERVER, MODE_HOST, and MODE_SHARED_HOST — those roles own the
simulation and may spawn. A MODE_CLIENT cannot spawn.
Verified against: lattice_runner.cpp:363-365 (is_server), lattice_runner.cpp:406-410 (spawn
warns + returns 0 when not authority).
Starting a host¶
A host is the authoritative server and a local player. From the sample's _on_host:
func _on_host() -> void:
my_mark = TttRules.MARK_X
lobby.hide()
status_label.text = "Hosting local server on 127.0.0.1:%d ... waiting for player O" % PORT
# MODE_HOST = listen-server + local participant (player X).
await runner.start_game(LatticeRunner.MODE_HOST, "ttt-room")
# Accept player O on PORT.
runner.listen(PORT)
# The host owns the shared board; spawn it now (owned by the host's local peer).
runner.spawn(BoardScene, runner.get_local_player())
# The spawned board adds itself to the "ttt_board" group in its _ready().
board = get_tree().get_first_node_in_group("ttt_board")
_bind_board()
_setup_chat("X")
Verified against: main.gd:63-77.
Starting a client¶
A client starts in MODE_CLIENT and then plays via replication + RPCs. From _on_join:
func _on_join() -> void:
my_mark = TttRules.MARK_O
lobby.hide()
status_label.text = "Connecting to local server 127.0.0.1:%d ..." % PORT
await runner.start_game(LatticeRunner.MODE_CLIENT, "ttt-room")
runner.connect_to("127.0.0.1", PORT)
# The board arrives via replication; _on_spawned binds it once it lands.
Verified against: main.gd:79-87.
await start_game(...) is optional and harmless
start_game returns a bool synchronously (true once the core has started, and the link — if
session named one — was accepted). Awaiting a plain value in GDScript just continues immediately,
so you can drop the await if you prefer. Note that a successful return does not mean the
handshake finished: wait for the connected signal for that.
Verified against: lattice_runner.cpp:107-162 (returns bool),
bindings/lattice-godot/demo/game.gd:16-17 ("await is harmless").
Listening and connecting¶
start_game starts the core. Getting two Godot processes talking to each other takes one more call —
listen on the authority, connect_to on the client:
runner.start_game(LatticeRunner.MODE_CLIENT)
runner.connect_to("127.0.0.1", 9100)
await runner.connected
Both return bool — false (with a pushed error) when the runner has not been started, the port is out
of range, or the core rejected the call. listen is authority-only: calling it in MODE_CLIENT is an
error.
Verified against: lattice_runner.cpp:164-184 (listen), lattice_runner.cpp:186-213 (connect_to),
lattice_runner.h:82-99.
It is connect_to, not connect
Every Godot Object already has connect(signal, callable) for signals. The netcode call is named
connect_to so it cannot collide with it.
The session token¶
connect_to(host, port, token) takes an optional third argument: the session token the director
mints, which the game server verifies at the netcode handshake. A local server with no control plane
takes no token, so the samples omit it. Chapter 04 covers where the token
comes from and the LatticeMatchmaker helper that fetches one for you.
The token crosses the ABI as opaque bytes (the UTF-8 of whatever string you pass). The server side
of that check — and the encryption that makes the token worth sending — is
runner.configure_secure(...); see the
secure datagram channel.
Verified against: lattice_runner.cpp:201-209 (the CharString → const uint8_t* hand-off),
thirdparty/lattice/lattice.h:374-375 (lattice_runner_connect(r, addr, port, token, token_len)).
start_game's session argument¶
start_game(mode, session) will do the listen/connect for you when session names an endpoint:
session |
MODE_HOST / MODE_SERVER |
MODE_CLIENT |
|---|---|---|
"9100" |
listen(9100) |
connect_to("127.0.0.1", 9100) |
"127.0.0.1:9100" |
listen(9100) |
connect_to("127.0.0.1", 9100) |
"[::1]:9100" |
listen(9100) |
connect_to("::1", 9100) |
"ttt-room", "" |
(no link — local/loopback) | (no link — local/loopback) |
Anything that is not an endpoint is treated as an opaque room label and starts the core without
naming a peer, which is why the samples' start_game(MODE_HOST, "ttt-room") still behaves exactly as it
always has. Parsing accepts host:port, a bare port, bracketed IPv6, and a scheme prefix
(udp://host:port); an unbracketed IPv6 literal is rejected rather than truncated.
Verified against: lattice_runner.cpp:147-162 (the glue_parse_endpoint dispatch),
lattice_glue.cpp:307-360 (the parser), compile_check/glue_test_main.cpp (section 4d — every row of
the table above is an assertion).
Checking the connection state¶
get_connection_state() returns the live lattice_connection_state ordinal — 0 disconnected,
1 connecting, 2 connected, 3 disconnecting. It is the same number get_net_stats() reports as
connection_state, and it is safe to call before start_game.
Verified against: lattice_runner.cpp:352-355, lattice_glue.cpp:251-253.
Two Godot instances can now establish a host↔client link
Earlier revisions of this binding surfaced no connection API at all, and this chapter carried a
warning that the GDScript host/join flow exercised only the API shape. That gap is closed:
listen / connect_to / connect_to_endpoint are bound methods that call
lattice_runner_listen / lattice_runner_connect on the real core. Which transport carries the
link is still the core's decision — loopback by default, real UDP with LATTICE_TRANSPORT=udp.
Verified against: lattice_runner.cpp:27-96 (the bound-method list),
compile_check/glue_test_main.cpp (section 4e — a host and a client reach CONNECTED over the
same forwarders, against the real library).
local_player — this endpoint's participant id¶
local_player is the owner value the runner uses for objects it owns — what you pass to spawn, and
what LatticeSync.has_authority() compares an object's owner against. It is read live from the core
(lattice_runner_local_peer_id), not guessed by the binding:
| Role | local_player |
|---|---|
Authority — MODE_SERVER / MODE_HOST / MODE_SHARED_HOST |
0 |
| Client, secure handshake | the id the authority assigned |
| Client, plaintext handshake | LOCAL_PLAYER_UNKNOWN (-1) |
Before start_game, after stop |
LOCAL_PLAYER_UNKNOWN (-1) |
runner.spawn(BoardScene, runner.get_local_player()) # method form (main.gd)
runner.spawn(player_scene, runner.local_player) # property form (demo/game.gd)
Full semantics, including how this compares with Unity and the C ABI, are on Peer identity & local player.
Verified against: lattice_runner.cpp:366-374 (get_local_player),
thirdparty/lattice/lattice.h:381-402 (the ABI contract), compile_check/glue_test_main.cpp
(sections 4f and 4g), main.gd:72, demo/game.gd:19.
An authority is peer 0, not 1 — and older builds of this binding said 1
Earlier revisions guessed is_server() ? 1 : 0, which was wrong in both directions. An
authority's local participant is 0 — that is the owner lattice_request_authority() stamps
when the host claims an object — while 1 is the id a client uses to address the server. If you
have game code that hardcoded 1 for the host, or that compared against the old value, fix it: the
headless harness has always spawned host objects with owner 0, and local_player now agrees
with it.
Verified against: thirdparty/lattice/lattice.h:387-389,
samples/godot-tictactoe/harness/ttt_harness.cpp:411 (lattice_spawn(..., /*owner host peer*/0)).
A plaintext client does not know its id — check for -1 before using it
Only the secure handshake tells a client which id the authority gave it (the channel
lattice_runner_configure_secure turns on); the legacy plaintext handshake carries no
assignment and is deliberately left byte-identical. On a plaintext client
local_player is therefore LOCAL_PLAYER_UNKNOWN (-1) — an honest "not known" rather than a
plausible-looking wrong number. Test it before you use it as an id:
if runner.local_player < 0:
# not known yet (or a plaintext client): do not treat it as an owner
return
What this means for has_authority(): when the local id is unknown, LatticeSync fails
closed — it declines to compare, so has_authority() falls back to is_server() and a client
never claims ownership on a guess. This matters: the old guess of 0 for a client would have
matched every host-owned object, since the host is peer 0.
If your own lobby knows the id, supply it with set_local_player(id) (or
runner.local_player = id). It is honoured only while the core reports unknown — an override
can never contradict the authority, and one that tries is ignored with a warning.
Verified against: lattice_sync.cpp:177-187 (the fail-closed branch),
lattice_runner.cpp:376-385 (set_local_player refuses to shadow a known id),
thirdparty/lattice/lattice.h:394-397.
The runner's signals¶
Connect to these to react to the network. All fire inside the _physics_process tick, on the main
thread.
| Signal | Arguments | Fires when |
|---|---|---|
connected |
— | The connection is established |
disconnected |
reason: int |
Disconnected |
player_joined |
player: int |
See the note below |
spawned |
netid: int, type: int, owner: int |
A replicated object appeared |
despawned |
netid: int |
An object was removed |
state_updated |
— | (on LatticeSync, not the runner — see ch. 05) |
event |
event_id: int, netid: int, sender: int, payload: PackedByteArray |
A custom event arrived (ch. 07) |
authority_changed |
netid: int, new_owner: int, authority_tick: int |
State authority changed (ch. 08) |
Verified against: lattice_runner.cpp:61-80 (the ADD_SIGNAL list).
Wire them in _ready, exactly as the sample does:
runner.connected.connect(_on_connected)
runner.player_joined.connect(_on_player_joined)
runner.spawned.connect(_on_spawned)
Verified against: main.gd:49-51.
player_joined currently fires alongside every spawned
In the current node layer, player_joined is emitted from the spawn callback, carrying the
spawned object's owner — it is a convenience, not a distinct "a peer connected" event. If you
need true connection events, use connected (both sides) and treat player_joined as "an object
owned by player appeared." The sample guards its handler with is_server() and only updates a
status label.
Verified against: lattice_runner.cpp:537-538 (emit_signal("spawned", …) immediately
followed by emit_signal("player_joined", (int)owner)), main.gd:93-96.
Reacting to a connection¶
Verified against: main.gd:90-91. On both host and client, connected fires when the transport
handshake completes (the harness asserts on_connected on both peers).
Verified against: samples/godot-tictactoe/harness/ttt_harness.cpp:400-402.
Tearing down¶
stop() destroys the native runner and clears the spawned-object table. The node also cleans up
automatically in _exit_tree when it leaves the scene:
void LatticeRunner::stop() {
if (runner_) { lattice_runner_destroy(runner_); runner_ = nullptr; }
started_ = false;
spawned_.clear();
}
void LatticeRunner::_exit_tree() {
if (runner_) { lattice_runner_destroy(runner_); runner_ = nullptr; started_ = false; }
}
Verified against: lattice_runner.cpp:103-105,357-361.
The minimal shape¶
extends Node
@onready var runner: LatticeRunner = $LatticeRunner
func _ready() -> void:
runner.connected.connect(func(): print("connected"))
runner.spawned.connect(func(netid, type, owner): print("spawned ", netid))
func host() -> void:
# register your networked types on the spawned object's LatticeSync (chapter 05)
runner.start_game(LatticeRunner.MODE_HOST, "room-1") # starts the core in HOST mode
runner.listen(9100) # accept peers
runner.spawn(preload("res://board.tscn"), runner.get_local_player())
func join() -> void:
runner.start_game(LatticeRunner.MODE_CLIENT, "room-1")
runner.connect_to("127.0.0.1", 9100) # the board arrives via replication
Or, letting start_game do the link from an endpoint:
runner.start_game(LatticeRunner.MODE_HOST, "9100") # host: listen(9100)
runner.start_game(LatticeRunner.MODE_CLIENT, "127.0.0.1:9100") # client: connect
The _physics_process on the LatticeRunner pumps the tick for you.
Next: 03 — Logging in, to get a real player identity from the auth service.