Skip to content

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

The shared type — registered on BOTH runners (two_process.cpp)
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):

Callbacks factory (two_process.cpp)
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

Server bring-up (two_process.cpp)
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

Client bring-up (two_process.cpp)
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

Server: spawn then mutate (two_process.cpp)
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
Client: observe the spawn, then the replicated value (two_process.cpp)
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 0607):

Client: hello, resent until welcome observed (two_process.cpp)
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);
Server: welcome to all, once the client's hello arrived (two_process.cpp)
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:

Tail flush + teardown (two_process.cpp)
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:

Tick forever until a signal (persistent_server.cpp)
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:

Two-process UDP run (does the build for you)
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_configurechapter 08 and examples/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.