09 — Full walkthrough: a minimal UDP server + client¶
This chapter assembles everything into a complete, runnable program: a real SERVER and a real
CLIENT, in two OS processes, talking over an actual 127.0.0.1 UDP socket through the public C
ABI only. It is not a toy — it is the repository's own two-process conformance driver
(reference/tests/two_process.cpp) plus the long-lived server (reference/tests/persistent_server.cpp),
each including nothing but <lattice/lattice.h>.
Every step below cites the exact source. Both processes print a single machine-checkable line on
success — SERVER: ALL CHECKS PASSED / CLIENT: ALL CHECKS PASSED — and exit 0.
Verified against: reference/tests/two_process.cpp:1-27 (the driver's own description);
reference/tests/persistent_server.cpp:1-11.
The architecture¶
flowchart TD
subgraph P1["Process 1 — server (LATTICE_MODE_SERVER)"]
S["listen(port) → accept → spawn+mutate a ScoreObj → recv hello RPC+event → send welcome RPC+event"]
end
subgraph P2["Process 2 — client (LATTICE_MODE_CLIENT)"]
C["connect(127.0.0.1:port) → pump until CONNECTED → observe spawn+state → send hello RPC+event → observe welcome"]
end
S <-->|"real 127.0.0.1 UDP (LATTICE_TRANSPORT=udp)"| C
Both sides register the same replicated type in the same order, so their content hashes match and the handshake is accepted (chapter 05).
Verified against: reference/tests/two_process.cpp:9-19 (the server/client role summary),
:44-65 (the shared type), :17-18 (UDP selected by LATTICE_TRANSPORT=udp).
The full flow, end to end¶
sequenceDiagram
participant S as Server (authority)
participant C as Client
S->>S: create → set_callbacks → register_score_type → start(SERVER) → listen(port)
C->>C: create → set_callbacks → register_score_type → start(CLIENT) → connect(127.0.0.1, port, "auth")
C-->>S: UDP handshake (retried each tick)
S->>S: on_connected fires (client accepted)
C->>C: state == LATTICE_CONN_CONNECTED
S->>S: spawn(ScoreObj, score=100) → mutate score=4242 + mark_dirty
S-->>C: spawn + delta replicate
C->>C: on_spawned → read block until score == 4242
C-->>S: hello RPC (Server) + hello event (Server), reliable, resent each tick
S->>S: on_rpc + on_event (learns the client's hello)
S-->>C: welcome RPC (All) + welcome event (All), reliable
C->>C: on_rpc + on_event → ALL CHECKS PASSED
Verified against: reference/tests/two_process.cpp:155-231 (server role), :236-317 (client role).
1. Both sides register the same type¶
Before connecting, server and client each register the identical ScoreObj type so ids and content
hashes agree (chapter 05):
struct ScoreState { int32_t score; uint8_t alive; };
static lattice_type_id register_score_type(lattice_runner* r) {
lattice_field_desc fields[2];
std::memset(fields, 0, sizeof(fields));
fields[0].name = "score"; fields[0].kind = LATTICE_FIELD_INT32; fields[0].offset = offsetof(ScoreState, score);
fields[1].name = "alive"; fields[1].kind = LATTICE_FIELD_BOOL; fields[1].offset = offsetof(ScoreState, alive);
lattice_type_desc desc;
std::memset(&desc, 0, sizeof(desc));
desc.key = "ScoreObj"; desc.state_size = sizeof(ScoreState);
desc.fields = fields; desc.field_count = 2;
return lattice_register_type(r, &desc);
}
Verified against: reference/tests/two_process.cpp:47-65; called on the server at :164 and the
client at :245.
2. Callbacks that capture everything¶
One Capture struct behind user_data records connection, spawns, updates, and the last RPC/event
(payloads copied out in-call). One factory wires the pointers (chapter 02):
static lattice_callbacks make_cb(Capture* c) {
lattice_callbacks cb; std::memset(&cb, 0, sizeof(cb));
cb.user_data = c;
cb.on_connected = cb_connected;
cb.on_spawned = cb_spawned;
cb.on_state_updated = cb_state_updated;
cb.on_rpc = cb_rpc;
cb.on_event = cb_event;
cb.on_log = cb_log;
return cb;
}
Verified against: reference/tests/two_process.cpp:121-132; the capture callbacks at :100-119.
3. Server: start → listen → accept¶
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 = "server";
lattice_callbacks cb = make_cb(&cap);
lattice_runner_set_callbacks(r, &cb);
lattice_type_id type = register_score_type(r);
lattice_runner_start(r, LATTICE_MODE_SERVER);
lattice_runner_listen(r, port);
// Phase 1: pump until a client completes the handshake (on_connected fires per accepted peer).
int steps = 0;
while (!cap.connected && steps < kMaxSteps) { pump(r); ++steps; }
Verified against: reference/tests/two_process.cpp:156-179.
4. Client: connect → pump until CONNECTED¶
lattice_runner_start(r, LATTICE_MODE_CLIENT);
uint8_t token[4] = { 'a','u','t','h' };
lattice_runner_connect(r, "127.0.0.1", port, token, sizeof(token));
int steps = 0;
while (lattice_runner_state(r) != LATTICE_CONN_CONNECTED && steps < kMaxSteps) { pump(r); ++steps; }
if (lattice_runner_state(r) != LATTICE_CONN_CONNECTED) { /* handshake failed */ }
Verified against: reference/tests/two_process.cpp:248-264.
5. Server spawns + mutates; client observes the delta¶
ScoreState init; init.score = 100; init.alive = 1;
lattice_netid id = lattice_spawn(r, type, &init, /*owner=*/0);
for (int i = 0; i < 4; ++i) pump(r); // flush the spawn to the client
ScoreState* s = (ScoreState*)lattice_object_state(r, id);
s->score = 4242;
lattice_object_mark_dirty(r, id, F_SCORE); // replicate the changed field
if (!saw_spawn && !cap.spawned.empty()) saw_spawn = true;
if (saw_spawn) {
ScoreState* s = (ScoreState*)lattice_object_state(r, cap.last_spawn);
if (s && s->score == 4242) saw_score = true; // the mutated state arrived
}
Verified against: reference/tests/two_process.cpp:217-222 (server, lattice_object_state),
:307-313 (client, lattice_object_state).
6. RPC + event both ways¶
The client sends a HELLO RPC and event to the server each tick (reliable, resent until the reply is
seen); the server, once it has heard both, replies with a WELCOME RPC + event to all
(chapters 06–07):
lattice_rpc(r, cap.last_spawn, RPC_HELLO, LATTICE_RPC_SERVER,
(const uint8_t*)HELLO_MSG, (uint32_t)std::strlen(HELLO_MSG), /*reliable=*/1);
lattice_send_event(r, EVT_HELLO, LATTICE_EVENT_SERVER, /*netid=*/0, /*peer=*/0,
(const uint8_t*)HELLO_MSG, (uint32_t)std::strlen(HELLO_MSG), /*reliable=*/1);
if (got_client_rpc && got_client_evt) {
lattice_rpc(r, id, RPC_WELCOME, LATTICE_RPC_ALL,
(const uint8_t*)WELCOME_MSG, (uint32_t)std::strlen(WELCOME_MSG), /*reliable=*/1);
lattice_send_event(r, EVT_WELCOME, LATTICE_EVENT_ALL, /*netid=*/0, /*peer=*/0,
(const uint8_t*)WELCOME_MSG, (uint32_t)std::strlen(WELCOME_MSG), /*reliable=*/1);
}
Verified against: reference/tests/two_process.cpp:294-297 (client hello), :213-218 (server
welcome).
7. Clean shutdown¶
Each side pumps a short tail so final datagrams flush, then destroys its runner (closing the socket) and prints its pass line:
for (int i = 0; i < 10; ++i) pump(r); // let final datagrams flush
lattice_runner_destroy(r); // clean shutdown: closes the socket
if (ok) { std::printf("SERVER: ALL CHECKS PASSED\n"); return 0; }
Verified against: reference/tests/two_process.cpp:223-230 (server), :309-316 (client).
The long-lived server variant¶
persistent_server.cpp is the same server bring-up but ticks forever (until SIGTERM/SIGINT)
so a fleet can observe the data-plane port listening — the shape you'd ship as a dedicated server:
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:58-66; usage
LATTICE_TRANSPORT=udp lattice_persistent_server <port> at :10.
Run the proof yourself¶
Both drivers select the real UDP transport with LATTICE_TRANSPORT=udp. The repo ships a wrapper
that builds liblattice.so if needed, compiles the two_process driver against the public header
only, then launches the server and client as two OS processes and asserts both pass lines:
bash reference/tests/run-two-process.sh # default port 47812
PORT=50000 bash reference/tests/run-two-process.sh
The wrapper compiles the driver to reference/build/lattice_two_process — note the base build.sh
does not build it; the run script does. Once it exists you can drive it by hand (its $ORIGIN
rpath finds liblattice.so sitting beside it in build/):
# terminal 1 — server
LATTICE_TRANSPORT=udp ./build/lattice_two_process server 47812
# terminal 2 — client
LATTICE_TRANSPORT=udp ./build/lattice_two_process client 47812
A healthy run prints SERVER: ALL CHECKS PASSED and CLIENT: ALL CHECKS PASSED, each exiting 0.
Verified against: reference/tests/run-two-process.sh:32-46 (builds the lib, then compiles the
driver to build/lattice_two_process, exports LATTICE_TRANSPORT=udp), :53-62 (launches both
processes); the asserted pass lines at reference/tests/two_process.cpp:228,314; the
server|client [port] CLI at :320-333.
Recap: what's verified vs. what you wire¶
| Capability | Status |
|---|---|
| Runner create / callbacks / start / listen / connect / tick | ✅ verified (both drivers, real UDP) |
| Type registration, spawn, replicated delta state | ✅ verified |
RPCs (lattice_rpc / on_rpc) both directions |
✅ verified |
Custom events (lattice_send_event / on_event) both directions |
✅ verified |
Bit codec (lattice_bw_* / lattice_br_*) round-trip |
✅ verified by the conformance harness (PART A) |
| Server-authoritative validation + anti-cheat gates | ✅ verified by the anti-cheat demo (ch. 08) |
| Auth login → access token | ⚠️ real HTTP service, but you make the call — the core has no auth client (ch. 03) |
| Matchmake → resolve → connect | ⚠️ endpoints real; you write the HTTP glue + run/register a server (ch. 04) |
| Session-token verification at the handshake | ⚠️ reference omits crypto — a production core enforces it (ch. 04) |
Shared authority transfer (lattice_request_authority / on_authority_changed) |
⚠️ real ABI, not exercised end-to-end by the drivers (ch. 08) |
Verified against: reference/tests/two_process.cpp, reference/tests/persistent_server.cpp,
examples/anti-cheat-demo/, reference/README.md; the omitted subsystems at
reference/include/lattice/lattice.h:12-14 and reference/README.md ("What it deliberately omits").
That honesty is deliberate: build on the verified paths first (runner, types, RPCs, events, server-authoritative validation), and treat the ⚠️ rows as integration work you own.
Where to go next¶
- Serialize richer state with compressed floats, vectors, and quaternions — the field kinds in chapter 05 and the BitWriter/BitReader in chapter 07.
- Turn on anti-cheat with
lattice_ac_configure— chapter 08 andexamples/anti-cheat-demo/. - Copy the starter template at
examples/starter-server-module/and grow it into your dedicated server. - Wrap this ABI in your engine — the Unity binding is a worked example of exactly that; see the Unity tutorial.