Skip to content

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.

The runner pumps itself (native side)
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:

@onready var runner: LatticeRunner = $LatticeRunner

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:

Host — from main.gd
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:

Client — from main.gd
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:

Host — accept peers on a port
runner.start_game(LatticeRunner.MODE_HOST)
runner.listen(9100)
Client — dial the authority
runner.start_game(LatticeRunner.MODE_CLIENT)
runner.connect_to("127.0.0.1", 9100)
await runner.connected

Both return boolfalse (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.

runner.connect_to("gs-eu-1.example.com", 27015, session_token)

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 CharStringconst 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:

Subscribing to runner signals — from main.gd
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

From main.gd
func _on_connected() -> void:
    status_label.text = "Connected to local server."

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:

Cleanup (native side)
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

Minimal host + client (real API)
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.