02 — Your first runner¶
The lattice_runner is the single opaque handle you drive everything through. You create one,
give it callbacks, start it in a mode (server / host / client), listen or connect, and then
tick it in a loop. Callbacks fire inside tick.
Verified against: reference/include/lattice/lattice.h:50 (lattice_runner opaque handle),
:366-379 (the lifecycle + pump functions).
The lifecycle API¶
Here is the whole runner surface — the exact signatures from the header:
lattice_runner* lattice_runner_create(const lattice_runner_config* cfg);
void lattice_runner_destroy(lattice_runner* r);
lattice_result lattice_runner_set_callbacks(lattice_runner* r, const lattice_callbacks* cb);
lattice_result lattice_runner_start(lattice_runner* r, lattice_game_mode mode);
lattice_result lattice_runner_listen(lattice_runner* r, uint16_t port);
lattice_result lattice_runner_connect(lattice_runner* r, const char* addr, uint16_t port,
const uint8_t* token, uint32_t token_len);
lattice_result lattice_runner_tick(lattice_runner* r, double dt);
lattice_connection_state lattice_runner_state(lattice_runner* r);
Verified against: reference/include/lattice/lattice.h:369-379.
Almost every call returns a lattice_result — LATTICE_OK (0) on success, or an error code. You
can turn one into a string with lattice_result_str.
Verified against: reference/include/lattice/lattice.h:60-75 (the lattice_result enum), :641
(lattice_result_str).
1. Create the runner¶
lattice_runner_create takes a config struct (or NULL for defaults). The documented pattern
is to zero-initialize the struct and set only what you need — that way any appended fields get their
defaults:
lattice_runner_config cfg;
std::memset(&cfg, 0, sizeof(cfg));
cfg.tick_rate_hz = 60; // fixed simulation rate; 0 => default 60
lattice_runner* r = lattice_runner_create(&cfg);
if (!r) { /* creation failed */ }
Verified against: reference/tests/persistent_server.cpp:37-41.
The config fields:
typedef struct {
uint32_t tick_rate_hz; /* fixed simulation rate; default 60 if 0 */
uint32_t max_objects; /* soft hint; 0 => unbounded in this skeleton */
uint32_t reserved;
uint32_t worker_threads;/* async job-pool size; 0 => a small default pool */
} lattice_runner_config;
Verified against: reference/include/lattice/lattice.h:274-287.
Always zero-init the config (and every ABI struct)
The zero value is the documented default for every field, and it is how appended fields stay
additive: a caller that memsets the struct to 0 gets the default behaviour for anything added
later (the job pool, for instance). The tests use std::memset(&cfg, 0, sizeof(cfg)); C++ can
also use lattice_runner_config cfg{};.
Verified against: reference/include/lattice/lattice.h:282-286;
reference/tests/persistent_server.cpp:37-38; examples/starter-server-module/server_main.cpp:115-116.
2. Wire up callbacks¶
You react to the network by installing a lattice_callbacks struct. Every function pointer is
optional (leave it NULL to ignore that event); user_data is an opaque cookie handed back to
every callback:
typedef struct {
void* user_data;
void (*on_connected) (void* user_data);
void (*on_disconnected) (void* user_data, int reason);
void (*on_spawned) (void* user_data, lattice_netid id, lattice_type_id type, uint64_t owner);
void (*on_despawned) (void* user_data, lattice_netid id);
void (*on_state_updated)(void* user_data, lattice_netid id);
void (*on_rpc) (void* user_data, lattice_netid id, uint16_t rpc_id,
const uint8_t* payload, uint32_t len);
void (*on_event) (void* user_data, uint16_t event_id, lattice_netid netid,
uint64_t sender, const uint8_t* payload, uint32_t len);
void (*on_authority_changed)(void* user_data, lattice_netid id, uint64_t new_owner,
uint32_t authority_tick);
void (*on_log) (void* user_data, lattice_log_level level, const char* msg);
/* … on_violation, on_http_result, on_store_result — additive, opt-in (later chapters) … */
} lattice_callbacks;
Verified against: reference/include/lattice/lattice.h:293-364.
The idiom (used by every example) is a small context struct that user_data points at, plus a
factory that fills in the callbacks:
struct Context {
const char* name = "?";
bool connected = false;
lattice_netid last_spawn = 0;
};
static void on_connected(void* user) {
((Context*)user)->connected = true;
}
static void on_spawned(void* user, lattice_netid id, lattice_type_id /*type*/, uint64_t /*owner*/) {
((Context*)user)->last_spawn = id;
}
static void on_log(void* user, lattice_log_level lvl, const char* msg) {
if (lvl >= LATTICE_LOG_WARN)
std::printf("[%s] %s\n", ((Context*)user)->name, msg ? msg : "(null)");
}
static lattice_callbacks make_callbacks(Context* ctx) {
lattice_callbacks cb{}; // zero-init: every unset pointer is NULL
cb.user_data = ctx;
cb.on_connected = on_connected;
cb.on_spawned = on_spawned;
cb.on_log = on_log;
return cb;
}
Verified against: examples/starter-server-module/server_main.cpp:48-86.
Install it before you start (so you don't miss early events):
Context sctx{ "server" };
lattice_callbacks scb = make_callbacks(&sctx);
lattice_runner_set_callbacks(r, &scb);
Verified against: examples/starter-server-module/server_main.cpp:121-125;
reference/tests/two_process.cpp:160-162.
Callbacks fire inside tick, on your thread — and no exception may escape
Every callback in that struct fires synchronously inside lattice_runner_tick() on the
thread that called it. Do no blocking work in them, and let no exception propagate out of a
callback into the core. The pointers passed to on_rpc / on_event / on_log are owned by the
core and valid only for the duration of the call — copy anything you need to keep.
Verified against: reference/include/lattice/lattice.h:23-25, :290-292, :308-309.
3. Start in a mode¶
lattice_runner_start takes a lattice_game_mode:
| Mode | Value | Meaning |
|---|---|---|
LATTICE_MODE_SERVER |
0 | Dedicated authoritative server (not a player) |
LATTICE_MODE_HOST |
1 | Server that is also a local participant (listen-server) |
LATTICE_MODE_CLIENT |
2 | Joins a server; no authority |
LATTICE_MODE_SHARED_HOST |
3 | Shared / distributed authority (skeleton: like HOST) |
Verified against: reference/include/lattice/lattice.h:77-82.
Authorities (SERVER, HOST, SHARED_HOST) own the simulation and may lattice_spawn; a CLIENT
cannot. Unlike the Unity binding's StartGame, the C ABI's start does not auto-listen — you
call listen (server) or connect (client) yourself as a separate step.
Verified against: reference/tests/persistent_server.cpp:43-49 (start then a separate listen);
reference/include/lattice/lattice.h:642 (spawn is "authority only").
4a. Listen (server) — the real UDP server¶
A server binds a UDP port with lattice_runner_listen. This is the complete server from
persistent_server.cpp — start SERVER, listen, then tick forever until a signal:
lattice_runner_config cfg;
std::memset(&cfg, 0, sizeof(cfg));
cfg.tick_rate_hz = 60;
lattice_runner* r = lattice_runner_create(&cfg);
if (!r) { std::printf("SERVER: FAIL create\n"); return 1; }
if (lattice_runner_start(r, LATTICE_MODE_SERVER) != LATTICE_OK) {
std::printf("SERVER: FAIL start\n"); lattice_runner_destroy(r); return 1;
}
if (lattice_runner_listen(r, port) != LATTICE_OK) {
std::printf("SERVER: FAIL listen on %u\n", (unsigned)port);
lattice_runner_destroy(r); return 1;
}
// Tick forever (60 Hz) until a signal asks us to stop.
while (!g_stop) {
lattice_runner_tick(r, 1.0 / 60.0);
sleep_ms(16);
}
lattice_runner_destroy(r);
Verified against: reference/tests/persistent_server.cpp:37-66.
Selecting the real UDP transport
The reference defaults to an in-memory loopback transport (great for one-process tests). To
make listen/connect use a real 127.0.0.1 UDP socket, set the environment variable
LATTICE_TRANSPORT=udp before launching — no ABI change, no code change. The persistent
server is designed to be run this way:
Verified against:
reference/tests/persistent_server.cpp:6-10;
reference/tests/two_process.cpp:17-18.
4b. Connect (client) — and pump until CONNECTED¶
A client starts in CLIENT mode (no listen) and calls lattice_runner_connect with the server
address, port, and a connection token (arbitrary bytes + length). Then it ticks in a loop
until the connection state reaches LATTICE_CONN_CONNECTED — the handshake completes across ticks,
so you pump the runner while polling lattice_runner_state:
lattice_runner_config cfg; std::memset(&cfg, 0, sizeof(cfg)); cfg.tick_rate_hz = 60;
lattice_runner* r = lattice_runner_create(&cfg);
Capture cap; cap.who = "client";
lattice_callbacks cb = make_cb(&cap);
lattice_runner_set_callbacks(r, &cb);
if (lattice_runner_start(r, LATTICE_MODE_CLIENT) != LATTICE_OK) { /* fail */ }
uint8_t token[4] = { 'a','u','t','h' };
if (lattice_runner_connect(r, "127.0.0.1", port, token, sizeof(token)) != LATTICE_OK) { /* fail */ }
// Pump until the handshake is accepted (state CONNECTED). Bounded so a lost datagram can't wedge us.
int steps = 0;
while (lattice_runner_state(r) != LATTICE_CONN_CONNECTED && steps < kMaxSteps) {
lattice_runner_tick(r, 1.0 / 60.0);
sleep_ms(5);
++steps;
}
if (lattice_runner_state(r) != LATTICE_CONN_CONNECTED) { /* handshake failed */ }
Verified against: reference/tests/two_process.cpp:236-265.
The connection states:
LATTICE_CONN_DISCONNECTED = 0,
LATTICE_CONN_CONNECTING,
LATTICE_CONN_CONNECTED,
LATTICE_CONN_DISCONNECTING
Verified against: reference/include/lattice/lattice.h:84-89.
The token is arbitrary here — it becomes meaningful with a real deployment
In the test the token is just the bytes "auth"; the loopback/local server doesn't validate it.
In a managed deployment this is the director-minted session token you get from /resolve
(see chapter 04). lattice_runner_connect forwards whatever bytes
(and length) you pass straight to the core.
Verified against: reference/tests/two_process.cpp:251-252;
reference/include/lattice/lattice.h:374-375.
Resolve hostnames yourself — the core does not
Every example above dials a dotted-quad literal, and that is not incidental. The address
you pass is parsed with inet_pton and nothing else, so a hostname never resolves:
lattice_runner_connect(r, "game.example.com", 47900, tok, len); // fails immediately
lattice_runner_connect(r, "203.0.113.7", 47900, tok, len); // fine
This bites as soon as you leave localhost, because a director /resolve hands back its
endpoint as host:port with a hostname in it (chapter 04). Resolve it to a dotted quad in
your own code first — getaddrinfo with AF_INET, passing a literal straight through
untouched. samples/battleship3d/src/net/resolve.cpp is a portable worked example (POSIX and
Winsock, with the WSAStartup handling Windows needs).
Doing it application-side is the documented pattern, not an oversight: which resolver a game wants — system, cached, custom, or none — is the game's decision, not the transport's.
Verified against: reference/src/platform/socket.h:74-80 (from_ipv4);
samples/battleship3d/src/net/resolve.cpp:54-100 (resolve_ipv4).
Connecting off-box also needs a routable local bind — handled for you
A UDP socket bound to 127.0.0.1 cannot send to a routable address at all. The client
therefore picks its local bind from the destination: loopback destinations bind
127.0.0.1 exactly as before, anything routable binds 0.0.0.0. Set
LATTICE_CLIENT_BIND_ADDR only if you need to pin a specific source interface on a
multi-homed host.
Verified against: reference/src/transport_udp.h:44-70 (client_bind_address);
conformance X.2 routable destination binds 0.0.0.0 (was unreachable before).
5. The tick pump¶
lattice_runner_tick(r, dt) pumps one or more fixed steps. The order per step is
recv → tick → send, and every callback fires synchronously inside the call on your thread. You are
responsible for calling it in your own loop — there is no background thread driving the simulation.
Verified against: reference/include/lattice/lattice.h:376-378.
The pattern is a small pump helper with a real sleep so you neither busy-spin nor starve I/O:
static void pump(lattice_runner* r) {
lattice_runner_tick(r, 1.0 / 60.0);
sleep_ms(5); // yield so the two ends interleave their I/O
}
Verified against: reference/tests/two_process.cpp:145-148.
Two-process drivers bound their pump loops (e.g. kMaxSteps = 600, ~3s of wall time) so a
dropped localhost datagram can't hang the test; the handshake and any reliable RPC/event are retried
each tick until observed.
Verified against: reference/tests/two_process.cpp:150, :25-26.
6. Tear down¶
lattice_runner_destroy frees the runner and closes its socket. A clean shutdown is just destroying
each runner you created:
Verified against: reference/tests/two_process.cpp:217 (server), :319 (client);
reference/tests/persistent_server.cpp:65.
The minimal shape¶
Putting it together — this is the smallest useful runner setup, both roles, over loopback in one process (the shape the starter module uses). Register your types before you connect (that's chapter 05):
#include <lattice/lattice.h>
#include <cstdio>
#include <cstring>
struct Context { const char* name; bool connected = false; lattice_netid last_spawn = 0; };
static void on_connected(void* u) { ((Context*)u)->connected = true; }
static void on_spawned(void* u, lattice_netid id, lattice_type_id, uint64_t) { ((Context*)u)->last_spawn = id; }
int main() {
lattice_runner_config rc{}; rc.tick_rate_hz = 60;
lattice_runner* server = lattice_runner_create(&rc);
lattice_runner* client = lattice_runner_create(&rc);
Context sctx{"server"}, cctx{"client"};
lattice_callbacks scb{}; scb.user_data = &sctx; scb.on_connected = on_connected; scb.on_spawned = on_spawned;
lattice_callbacks ccb{}; ccb.user_data = &cctx; ccb.on_connected = on_connected; ccb.on_spawned = on_spawned;
lattice_runner_set_callbacks(server, &scb);
lattice_runner_set_callbacks(client, &ccb);
// RegisterType(...) here on BOTH — same order — see chapter 05
lattice_runner_start(server, LATTICE_MODE_SERVER);
lattice_runner_start(client, LATTICE_MODE_CLIENT);
lattice_runner_listen(server, 9000);
uint8_t token[4] = {'a','u','t','h'};
lattice_runner_connect(client, "127.0.0.1", 9000, token, 4);
const double dt = 1.0 / 60.0;
for (int i = 0; i < 120 && !(sctx.connected && cctx.connected); ++i) {
lattice_runner_tick(server, dt);
lattice_runner_tick(client, dt);
}
std::printf("connected: server=%d client=%d\n", sctx.connected, cctx.connected);
lattice_runner_destroy(client);
lattice_runner_destroy(server);
return 0;
}
Verified against: examples/starter-server-module/server_main.cpp:104-144 (same create → callbacks
→ start → listen/connect → pump-until-connected shape).
Register types before you connect
Both peers must register the same networked types in the same order before the client
connects, so type ids and content hashes agree and the handshake is accepted. The starter module
calls register_player(...) on both ends before start/connect. This is
chapter 05.
Verified against: examples/starter-server-module/server_main.cpp:127-137.
Next: 03 — Logging in, to get a real player identity from the auth service.