07 — Custom Module Setup & Build Guide¶
How to set up, build, and link the attachable shared game-sim custom module — the single body of user code that compiles into both the dedicated server and a P2P host with identical logic, by linking against
liblatticethrough the stable C ABI (include/lattice/lattice.h).This guide is grounded in the verified reference skeleton under
reference/. Every code snippet mirrorsreference/tests/conformance.cpp, and every build command is taken fromreference/build.sh,reference/build-windows.sh,reference/CMakeLists.txt, andreference/cmake/mingw-w64.cmake. The recipes below have been run: the Linux build and the Windows MinGW cross-build (under wine) both pass the 130-check conformance harness with exit 0.
Read alongside: 02 §18 Shared simulation library · 02 §19 Public C ABI · 02 §23 Cross-Platform Support · 05 §8.2 Shared game-sim library · 08 Server & Client API reference · reference/README.md
Table of Contents¶
- What the custom module is
- The "same code = dedicated server or P2P host" model
- Project skeleton
- Authoring against the C ABI
- 4.1 Define your networked state blocks
- 4.2 Register networked types
- 4.3 Start the runner in a role
- 4.4 Spawn, mutate state, and tick
- 4.5 RPCs
- Building — Linux
- Building — Windows
- Linking the module + liblattice into your binaries
- Minimal end-to-end example
- Conformance & staying in step with the core
1. What the custom module is¶
The custom module is game-sim/ — your deterministic, game-specific code: the
networked schema ([Networked] state), the RPC handlers, and the fixed-tick simulation
logic. It is authored once in C++ and compiled into multiple binaries
(02 §18,
05 §8.2).
It does not contain transport, reliability, replication, crypto, prediction, or NAT
code — all of that lives in lattice-core (the reference proves the surface as
liblattice.so / lattice.dll). The module reaches the core only through the stable
extern "C" C ABI in include/lattice/lattice.h. No C++ types, STL containers, or
exceptions ever cross that boundary (02 §19).
Concretely, the module:
- registers its networked types with
lattice_register_type, - spawns / despawns objects and mutates their replicated state through
lattice_object_state+lattice_object_mark_dirty, - sends and receives RPCs (
lattice_rpc/ theon_rpccallback), - and is driven forward by the host program's
lattice_runner_tickloop.
Because that surface is identical regardless of role, the same module object code runs as authority in a dedicated server and in a P2P host.
2. The "same code = dedicated server or P2P host" model¶
The locked decision (02 §18): the same simulation code compiles into both the dedicated server and the client; a client can be a P2P host. What is shared at compile time is your logic; what is injected at runtime is the role / topology, not the logic.
In the reference C ABI, the role is injected when the host program starts the runner. The module's registration and per-tick logic are byte-for-byte identical; only the start mode differs:
/* lattice.h — lattice_game_mode */
LATTICE_MODE_SERVER = 0, /* dedicated server: authority, typically headless */
LATTICE_MODE_HOST = 1, /* P2P host: authority + a local participant */
LATTICE_MODE_CLIENT = 2, /* predicts/converges; holds no server-auth authority */
LATTICE_MODE_SHARED_HOST = 3 /* shared/distributed authority (skeleton: like HOST) */
lattice_runner_start(runner, LATTICE_MODE_SERVER); /* dedicated server build/launch */
lattice_runner_start(runner, LATTICE_MODE_HOST); /* P2P host build/launch */
Everything else — lattice_register_type, lattice_spawn, lattice_object_state,
lattice_object_mark_dirty, lattice_rpc, the tick loop — is called the same way in
both. The reference runner is what branches internally on the mode to decide who is
authoritative, who predicts, and who relays (this maps to the design's
RoleContext, 02 §18.1). Your module
never branches on role.
What differs at runtime (02 §18.3):
| Concern | SERVER (dedicated) |
HOST (P2P) |
CLIENT |
|---|---|---|---|
Runs your simulate_tick |
yes | yes | yes (prediction) |
Holds authority (calls lattice_spawn) |
yes | yes | only over its shared-auth objects |
| Local view / render | usually headless | yes | yes |
| Relays to other peers | clients connect directly | yes | no |
Authority operations are gated by the core:
lattice_spawnandlattice_despawnsucceed only on an authority (SERVER/HOST/SHARED_HOST); on aCLIENTthey return0/LATTICE_ERR_NOT_AUTHORITY. This is exactly why the same code is safe in both roles — the role check is the core's responsibility, not yours.
3. Project skeleton¶
A custom module is an ordinary C++ project that includes one header and links one library. A minimal layout:
my-game/
├── include/lattice/lattice.h # the C ABI header (copied/symlinked from reference/include)
├── lib/
│ ├── liblattice.so # Linux shared lib (from reference build/)
│ ├── lattice.dll # Windows DLL (from reference build-win/)
│ └── liblattice.dll.a # Windows import lib (from reference build-win/)
├── game-sim/
│ ├── schema.h # your networked state structs + field descriptors
│ ├── schema.cpp # type registration helpers (register_types)
│ └── simulate.cpp # your simulate_tick / RPC handlers (the shared logic)
├── server/main.cpp # dedicated-server entry: start(SERVER) + tick loop
├── host/main.cpp # P2P-host entry: start(HOST) + tick loop
└── build.sh / CMakeLists.txt # your build (modelled on reference/)
Key points:
- One header, one library. The module compiles against only
include/lattice/lattice.h(pure C,<stdint.h>only) and links the sharedliblattice(.soon Linux,.dll+.dll.aimport lib on Windows). This is exactly howreference/tests/conformance.cppis built — it includes only the public header and links-llattice(seereference/build.shlines 38–41). game-sim/is shared object code.schema.*andsimulate.cppare compiled once and linked into bothserver/andhost/targets (and, in a real engine, into the client plugin via the binding — see 05 §8.2).server/main.cppvshost/main.cppdiffer in one line — thelattice_runner_startmode. They can even be the samemain.cppselecting the mode fromargv.
To obtain liblattice and the header today, build the reference skeleton (Sections 5–6)
and copy reference/include/lattice/lattice.h plus the produced library next to your
module.
4. Authoring against the C ABI¶
All snippets below are drawn directly from the verified harness
reference/tests/conformance.cpp (PART B), which exercises this exact surface end-to-end.
4.1 Define your networked state blocks¶
Each networked type has a state block: a fixed-layout POD struct held in host memory. The core copies it across the ABI; you mutate it in place and mark fields dirty.
#include <lattice/lattice.h>
#include <cstdint>
#include <cstddef> /* offsetof */
/* A custom value struct, replicated via the MANUAL serialize path (INetworkSerializable,
* 02 §21.4b): you control its exact bit layout. hasShield is a 1-byte bool (POD on ABI). */
struct Loadout {
int32_t weaponId; /* ranged 0..255 on the wire */
uint8_t ammo; /* byte */
uint8_t hasShield; /* bool */
};
/* The Player state block. Field offsets come from offsetof so the schema matches the host
* layout exactly (what a codegen step would emit). It nests Loadout as a STRUCT field. */
struct PlayerState {
uint8_t isAlive; /* BOOL (1 byte in host memory) */
int32_t score; /* INT32 */
int64_t xp; /* INT64 */
float stamina; /* COMPRESSED_FLOAT */
lattice_vec3 position; /* VECTOR3 compressed */
lattice_vec3 velocity; /* VECTOR3 compressed */
lattice_quat rotation; /* QUATERNION (smallest-three) */
Loadout loadout; /* STRUCT (nested, manual serializer) */
};
There are two ways a type's fields reach the wire:
- Declarative path — supply a
lattice_field_desc[]array describing each replicated leaf field (kind, byteoffset, quantization). The core does the bit-packing. This is the[Networked]codegen analogue (02 §20–§21). - Manual path (
INetworkSerializable) — supplyserialize/deserializefunction pointers and write the bits yourself with thelattice_bw_*/lattice_br_*helpers. Use it when you want exact control over the encoding.
Loadout uses the manual path; Player uses the declarative path and nests Loadout.
The manual serializer for Loadout (note the typed bit helpers — these are the same
methods documented in 02 §21 /
05 §12):
static void loadout_serialize(lattice_bitwriter* w, const void* state, void* /*user*/) {
const Loadout* l = (const Loadout*)state;
lattice_bw_write_ranged_int(w, l->weaponId, 0, 255);
lattice_bw_write_byte(w, l->ammo);
lattice_bw_write_bool(w, l->hasShield != 0);
}
static void loadout_deserialize(lattice_bitreader* r, void* state, void* /*user*/) {
Loadout* l = (Loadout*)state;
l->weaponId = lattice_br_read_ranged_int(r, 0, 255);
l->ammo = lattice_br_read_byte(r);
l->hasShield = lattice_br_read_bool(r) ? 1u : 0u;
}
The declarative field array for Player (offsets via offsetof, quantization per field):
#include <vector>
enum { F_ISALIVE=0, F_SCORE, F_XP, F_STAMINA, F_POSITION, F_VELOCITY, F_ROTATION, F_LOADOUT };
static std::vector<lattice_field_desc> player_fields(lattice_type_id loadout_id) {
lattice_float_quant qstam = {0.0f, 100.0f, 0.1f};
lattice_float_quant qpos = {-1000.0f, 1000.0f, 0.01f};
lattice_float_quant qvel = {-100.0f, 100.0f, 0.01f};
lattice_float_quant qzero = {0,0,0};
std::vector<lattice_field_desc> f;
auto add = [&](const char* name, lattice_field_kind kind, uint32_t off,
lattice_float_quant q, int32_t rmin, int32_t rmax, uint32_t qb,
lattice_type_id st) {
lattice_field_desc d{};
d.name = name; d.kind = kind; d.offset = off; d.quant = q;
d.ranged_min = rmin; d.ranged_max = rmax; d.quat_bits = qb;
d.byte_capacity = 0; d.struct_type = st;
f.push_back(d);
};
add("isAlive", LATTICE_FIELD_BOOL, offsetof(PlayerState, isAlive), qzero, 0,0,0, 0);
add("score", LATTICE_FIELD_INT32, offsetof(PlayerState, score), qzero, 0,0,0, 0);
add("xp", LATTICE_FIELD_INT64, offsetof(PlayerState, xp), qzero, 0,0,0, 0);
add("stamina", LATTICE_FIELD_COMPRESSED_FLOAT, offsetof(PlayerState, stamina), qstam, 0,0,0, 0);
add("position", LATTICE_FIELD_VECTOR3, offsetof(PlayerState, position), qpos, 0,0,0, 0);
add("velocity", LATTICE_FIELD_VECTOR3, offsetof(PlayerState, velocity), qvel, 0,0,0, 0);
add("rotation", LATTICE_FIELD_QUATERNION, offsetof(PlayerState, rotation), qzero, 0,0,10, 0);
add("loadout", LATTICE_FIELD_STRUCT, offsetof(PlayerState, loadout), qzero, 0,0,0, loadout_id);
return f;
}
4.2 Register networked types¶
Register types in the same order on every peer so type ids and content hashes line up. The core hashes the ordered field list into a content hash and rejects spawns on a mismatch (02 §21.6), so this discipline is what keeps server, host, and client schema-compatible.
/* Register Loadout (manual struct) FIRST, then Player (declarative, nesting Loadout).
* `fields_storage` must outlive the lattice_register_type call (the core deep-copies it,
* but the array must be valid during the call). */
static void register_types(lattice_runner* r,
std::vector<lattice_field_desc>& fields_storage,
lattice_type_id* out_loadout, lattice_type_id* out_player) {
/* Loadout — manual / INetworkSerializable path (fields == NULL, serialize/deserialize set). */
lattice_type_desc loadout{};
loadout.key = "Loadout"; /* stable key; hashed + matched across peers */
loadout.state_size = sizeof(Loadout);
loadout.fields = nullptr;
loadout.field_count = 0;
loadout.serialize = loadout_serialize;
loadout.deserialize = loadout_deserialize;
loadout.user = nullptr;
lattice_type_id loadout_id = lattice_register_type(r, &loadout); /* 0 == error */
/* Player — declarative path, nesting Loadout as a STRUCT field. */
fields_storage = player_fields(loadout_id);
lattice_type_desc player{};
player.key = "Player";
player.state_size = sizeof(PlayerState);
player.fields = fields_storage.data();
player.field_count = (uint32_t)fields_storage.size();
player.serialize = nullptr; /* NULL => declarative path */
player.deserialize = nullptr;
player.user = nullptr;
lattice_type_id player_id = lattice_register_type(r, &player);
if (out_loadout) *out_loadout = loadout_id;
if (out_player) *out_player = player_id;
}
This register_types body is shared logic — it runs identically in the server build and
the host build, which is the whole point of the module.
4.3 Start the runner in a role¶
Create the runner, install callbacks, register types, then start in the desired role and
either listen (authority) or connect (client). The pump order inside tick is fixed:
recv → tick → send, and all callbacks fire synchronously inside lattice_runner_tick
on the calling thread (02 §19).
lattice_runner_config cfg{};
cfg.tick_rate_hz = 60; /* fixed sim rate; 0 => default 60 */
lattice_runner* runner = lattice_runner_create(&cfg);
lattice_callbacks cb{};
cb.user_data = &my_state;
cb.on_connected = on_connected;
cb.on_spawned = on_spawned;
cb.on_state_updated = on_state_updated;
cb.on_rpc = on_rpc;
cb.on_despawned = on_despawned;
cb.on_log = on_log;
lattice_runner_set_callbacks(runner, &cb);
std::vector<lattice_field_desc> fields;
lattice_type_id loadoutId = 0, playerId = 0;
register_types(runner, fields, &loadoutId, &playerId);
/* THE ONE LINE THAT DIFFERS between dedicated server and P2P host: */
lattice_runner_start(runner, LATTICE_MODE_SERVER); /* or LATTICE_MODE_HOST */
lattice_runner_listen(runner, 9000); /* authority: open the session */
A client build instead does:
lattice_runner_start(runner, LATTICE_MODE_CLIENT);
uint8_t token[4] = {'a','u','t','h'}; /* connect token (03 auth-service) */
lattice_runner_connect(runner, "127.0.0.1", 9000, token, 4);
4.4 Spawn, mutate state, and tick¶
Only an authority spawns. Mutate the live state block via lattice_object_state and call
lattice_object_mark_dirty(runner, id, field_index) for each changed declarative field;
the core delta-replicates dirty fields on the next send (02 §14).
/* Authority spawns a Player with initial values (incl. the nested loadout). */
PlayerState init{};
init.isAlive = 1;
init.score = 10;
init.xp = 1000;
init.stamina = 75.0f;
init.position = {1.0f, 2.0f, 3.0f};
init.velocity = {0.5f, 0.0f, -0.5f};
init.loadout = Loadout{ /*weaponId*/42, /*ammo*/30, /*hasShield*/1 };
lattice_netid nid = lattice_spawn(runner, playerId, &init, /*owner*/ 7); /* 0 on error */
/* Later, in your per-tick logic, mutate + mark dirty: */
PlayerState* s = (PlayerState*)lattice_object_state(runner, nid); /* NULL if unknown */
if (s) {
s->score += 1; lattice_object_mark_dirty(runner, nid, F_SCORE);
s->position = {5.0f, 6.0f, 7.0f}; lattice_object_mark_dirty(runner, nid, F_POSITION);
s->loadout.ammo = 12; lattice_object_mark_dirty(runner, nid, F_LOADOUT);
}
/* Drive the simulation forward one fixed step (recv -> tick -> send). Call once per step. */
lattice_runner_tick(runner, 1.0 / 60.0);
The host program owns the loop; a headless dedicated server typically runs it in a fixed-timestep accumulator:
const double dt = 1.0 / 60.0;
while (running) {
/* sample inputs / run game logic that mutates state + marks dirty ... */
lattice_runner_tick(runner, dt);
/* sleep to pace to the tick rate */
}
On a connecting peer, the corresponding objects appear through the on_spawned callback;
read their replicated state with the same lattice_object_state call, and observe
convergence via on_state_updated.
4.5 RPCs¶
RPCs are opaque byte payloads routed by target and delivered through the on_rpc
callback during tick. Targets are LATTICE_RPC_SERVER / OWNER / ALL /
ALL_BUT_OWNER (02 §16).
/* Client -> server: */
const uint8_t ping[4] = {'p','i','n','g'};
lattice_rpc(runner, nid, /*rpc_id*/7, LATTICE_RPC_SERVER, ping, 4, /*reliable*/1);
/* Server -> everyone: */
const uint8_t pong[4] = {'p','o','n','g'};
lattice_rpc(runner, nid, /*rpc_id*/9, LATTICE_RPC_ALL, pong, 4, /*reliable*/1);
Receiving side (callback fires inside tick; do no blocking work in it):
static void on_rpc(void* user, lattice_netid id, uint16_t rpc_id,
const uint8_t* payload, uint32_t len) {
/* dispatch on rpc_id; payload/len is caller-owned for the duration of the call */
}
5. Building — Linux¶
Two supported recipes — a direct g++ invocation (quick, no CMake) and CMake. Both come
straight from the reference and produce liblattice.so plus an executable that links it.
5.1 g++ (the quick path) — reference/build.sh¶
The shared library is built with -fPIC, hidden visibility, and -DLATTICE_BUILD=1 (which
selects symbol export); the consumer is compiled against only the public header and
links -llattice, with an $ORIGIN rpath so it finds the .so beside it:
# Build liblattice.so from the core sources:
g++ -std=c++20 -Wall -Wextra -O2 -fPIC \
-fvisibility=hidden -fvisibility-inlines-hidden -DLATTICE_BUILD=1 \
-Iinclude -Isrc -shared -o build/liblattice.so \
src/platform/platform.cpp src/platform/socket.cpp src/bitstream.cpp \
src/netsim.cpp src/reliability.cpp src/transport_loopback.cpp \
src/replication.cpp src/rpc.cpp src/runner.cpp src/lattice_abi.cpp \
-lpthread
# Compile + link YOUR module/consumer against ONLY the public header:
g++ -std=c++20 -Wall -Wextra -O2 \
-Iinclude -o build/my_app my_module.cpp \
-Lbuild -llattice -Wl,-rpath,'$ORIGIN'
To build the reference as-is and run its harness:
cd reference
./build.sh run # builds build/liblattice.so + build/lattice_conformance, then runs it
# => PASS: 130 FAIL: 0 TOTAL: 130 / RESULT: ALL CHECKS PASSED (exit 0)
Inspect that only the lattice_* ABI is exported:
5.2 CMake — reference/CMakeLists.txt¶
CMake builds the same lattice shared target (PIC on, LATTICE_BUILD=1, hidden
visibility, links Threads::Threads) and a consumer linked against it.
cmake -S reference -B /tmp/lattice-build -DCMAKE_BUILD_TYPE=Release
cmake --build /tmp/lattice-build -j
/tmp/lattice-build/lattice_conformance ; echo "EXIT=$?"
Environment note (configure the build dir under
/tmp). In sandboxed/CI shells the project tree may be read-only or on a filesystem where in-tree CMake configuration is blocked. Point-Bat a writable scratch directory such as/tmp/lattice-build(as above) rather than an in-treebuild/. Theg++path in 5.1 has no such constraint.
6. Building — Windows¶
Two routes: MinGW-w64 cross-compile from a Linux host (the verified path), and MSVC.
6.1 MinGW-w64 cross-compile — reference/build-windows.sh¶
This mirrors the Linux g++ path and produces a self-contained lattice.dll, its
import library liblattice.dll.a, and a .exe consumer.
# Requires (apt): gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 (and `wine` to run).
# Shared library -> lattice.dll (+ import lib for linkers):
x86_64-w64-mingw32-g++-posix -std=c++20 -Wall -Wextra -O2 \
-DLATTICE_BUILD=1 -Iinclude -Isrc -shared \
-o build-win/lattice.dll \
src/platform/platform.cpp src/platform/socket.cpp src/bitstream.cpp \
src/netsim.cpp src/reliability.cpp src/transport_loopback.cpp \
src/replication.cpp src/rpc.cpp src/runner.cpp src/lattice_abi.cpp \
-Wl,--out-implib,build-win/liblattice.dll.a \
-static -static-libgcc -static-libstdc++ -lws2_32
# Your module/consumer, linked against the import lib (public header only):
x86_64-w64-mingw32-g++-posix -std=c++20 -Wall -Wextra -O2 \
-Iinclude -o build-win/my_app.exe my_module.cpp \
-Lbuild-win -llattice.dll \
-static -static-libgcc -static-libstdc++ -lws2_32
Or just run the script:
cd reference
./build-windows.sh run # cross-builds build-win/, then runs the .exe under wine
# => PASS: 130 FAIL: 0 TOTAL: 130 (exit 0)
Three Windows-specific requirements are baked into the commands and the header — get any of them wrong and the build either fails to compile or fails to load:
-posixthread model (mandatory). Use thex86_64-w64-mingw32-g++-posixvariant, not the defaultx86_64-w64-mingw32-g++. Ubuntu's default MinGW uses the win32 thread model, whose libstdc++ does not providestd::mutex/std::thread/std::lock_guard— which the transport uses. The-posixvariant ships the full C++ threading library. (Override withWIN_CXX=...if your toolchain prefix differs.)-static -static-libgcc -static-libstdc++. These fold libgcc / libstdc++ / winpthread into the binaries, so the resulting.dll/.exedepend only on core Windows DLLs and run under wine and on a bare Windows host with no extra DLLs to ship._WIN32_WINNT = 0x0600(Vista), set before any system include. This lives insrc/platform/platform.h(it must precede<chrono>/<mutex>, which indirectly pull in<windows.h>). Without it the modern Winsock APIs (inet_pton,getaddrinfo) used by the socket abstraction are not declared. If your module includes any Lattice internal platform header, includeplatform.hfirst; if it includes only the publiclattice.h, you don't need to worry about this.
The resulting DLL is genuinely self-contained — its only dependencies are core Windows
DLLs (verified with objdump -p build-win/lattice.dll):
There is also a CMake toolchain file for the same cross-build
(reference/cmake/mingw-w64.cmake), which selects the -posix compilers and the static
link flags for you:
cmake -S reference -B /tmp/lattice-win \
-DCMAKE_TOOLCHAIN_FILE=reference/cmake/mingw-w64.cmake -DCMAKE_BUILD_TYPE=Release
cmake --build /tmp/lattice-win -j
wine /tmp/lattice-win/lattice_conformance.exe
(The same /tmp build-dir guidance from §5.2 applies.)
6.2 MSVC notes¶
The reference is MSVC-ready though MinGW is the verified cross path:
- The export macro in
include/lattice/lattice.halready handles MSVC:LATTICE_APIexpands to__declspec(dllexport)whenLATTICE_BUILDis defined (building the lib) and__declspec(dllimport)for consumers. So an MSVC DLL exports exactly thelattice_*ABI. - GNU/Clang-only flags (
-Wall -Wextra,-fvisibility=hidden) are gated off MSVC inCMakeLists.txt; MSVC uses/W3instead. ws2_32is linked on Windows for both MinGW and MSVC.- The static-runtime flags (
-static…) are MinGW-only; MSVC links its CRT separately (choose/MDor/MTper your shipping policy).
Configure with the Visual Studio generator and build the same targets:
cmake -S reference -B build-msvc -G "Visual Studio 17 2022" -A x64
cmake --build build-msvc --config Release
build-msvc\Release\lattice_conformance.exe
This yields lattice.dll + lattice.lib (import lib) that your module links the same way.
7. Linking the module + liblattice into your binaries¶
The link step is identical in shape across all three binaries — only the start mode differs
at runtime (Section 2). Compile your shared game-sim/ translation units once and link them
into each target alongside liblattice:
Linux dedicated server (server/main.cpp calls start(SERVER)):
g++ -std=c++20 -O2 -Iinclude \
game-sim/schema.cpp game-sim/simulate.cpp server/main.cpp \
-o build/lattice-gameserver \
-Lbuild -llattice -Wl,-rpath,'$ORIGIN'
Linux P2P host (host/main.cpp calls start(HOST)) — same module objects:
g++ -std=c++20 -O2 -Iinclude \
game-sim/schema.cpp game-sim/simulate.cpp host/main.cpp \
-o build/lattice-host \
-Lbuild -llattice -Wl,-rpath,'$ORIGIN'
Windows (either target), linking the import lib:
x86_64-w64-mingw32-g++-posix -std=c++20 -O2 -Iinclude \
game-sim/schema.cpp game-sim/simulate.cpp server/main.cpp \
-o build-win/lattice-gameserver.exe \
-Lbuild-win -llattice.dll -static -static-libgcc -static-libstdc++ -lws2_32
Ship the runtime library beside each executable: liblattice.so next to the Linux binary
(found via $ORIGIN rpath), or lattice.dll next to the .exe (found by the Windows
loader from the exe's directory).
For an engine client (Unity/Unreal/Godot), the same game-sim/ library is linked into
the client plugin through the binding, which loads lattice-core natively — see
05 §8.2 and
the per-engine packaging in 05. The C ABI surface is the same;
only the packaging differs.
8. Minimal end-to-end example¶
A complete, adaptable program: start a server, register the types, spawn a Player, run the
tick loop, mutate state, send an RPC. It links only the public header + liblattice.
(This is the same flow PART B of reference/tests/conformance.cpp asserts; here it is
distilled to a single authority for clarity.)
#include <lattice/lattice.h>
#include <cstdint>
#include <cstddef>
#include <cstdio>
#include <vector>
/* --- schema (Section 4.1): Loadout (manual) + PlayerState (declarative) --- */
struct Loadout { int32_t weaponId; uint8_t ammo; uint8_t hasShield; };
struct PlayerState {
uint8_t isAlive; int32_t score; int64_t xp; float stamina;
lattice_vec3 position, velocity; lattice_quat rotation; Loadout loadout;
};
enum { F_ISALIVE=0, F_SCORE, F_XP, F_STAMINA, F_POSITION, F_VELOCITY, F_ROTATION, F_LOADOUT };
static void loadout_serialize(lattice_bitwriter* w, const void* st, void*) {
const Loadout* l = (const Loadout*)st;
lattice_bw_write_ranged_int(w, l->weaponId, 0, 255);
lattice_bw_write_byte(w, l->ammo);
lattice_bw_write_bool(w, l->hasShield != 0);
}
static void loadout_deserialize(lattice_bitreader* r, void* st, void*) {
Loadout* l = (Loadout*)st;
l->weaponId = lattice_br_read_ranged_int(r, 0, 255);
l->ammo = lattice_br_read_byte(r);
l->hasShield = lattice_br_read_bool(r) ? 1u : 0u;
}
static void on_log(void* /*u*/, lattice_log_level lvl, const char* msg) {
if (lvl >= LATTICE_LOG_WARN) std::printf("[lattice] %s\n", msg);
}
int main() {
lattice_runner_config cfg{}; cfg.tick_rate_hz = 60;
lattice_runner* r = lattice_runner_create(&cfg);
lattice_callbacks cb{}; cb.on_log = on_log;
lattice_runner_set_callbacks(r, &cb);
/* --- register types (same order everywhere) --- */
lattice_type_desc lo{};
lo.key = "Loadout"; lo.state_size = sizeof(Loadout);
lo.serialize = loadout_serialize; lo.deserialize = loadout_deserialize;
lattice_type_id loadoutId = lattice_register_type(r, &lo);
lattice_float_quant qstam={0,100,0.1f}, qpos={-1000,1000,0.01f},
qvel={-100,100,0.01f}, qz={0,0,0};
std::vector<lattice_field_desc> f(8, lattice_field_desc{});
auto F = [&](int i, const char* n, lattice_field_kind k, uint32_t off,
lattice_float_quant q, int rmin, int rmax, uint32_t qb, lattice_type_id st){
f[i].name=n; f[i].kind=k; f[i].offset=off; f[i].quant=q;
f[i].ranged_min=rmin; f[i].ranged_max=rmax; f[i].quat_bits=qb; f[i].struct_type=st;
};
F(F_ISALIVE, "isAlive", LATTICE_FIELD_BOOL, offsetof(PlayerState,isAlive), qz,0,0,0,0);
F(F_SCORE, "score", LATTICE_FIELD_INT32, offsetof(PlayerState,score), qz,0,0,0,0);
F(F_XP, "xp", LATTICE_FIELD_INT64, offsetof(PlayerState,xp), qz,0,0,0,0);
F(F_STAMINA, "stamina", LATTICE_FIELD_COMPRESSED_FLOAT, offsetof(PlayerState,stamina), qstam,0,0,0,0);
F(F_POSITION,"position",LATTICE_FIELD_VECTOR3, offsetof(PlayerState,position), qpos,0,0,0,0);
F(F_VELOCITY,"velocity",LATTICE_FIELD_VECTOR3, offsetof(PlayerState,velocity), qvel,0,0,0,0);
F(F_ROTATION,"rotation",LATTICE_FIELD_QUATERNION, offsetof(PlayerState,rotation), qz,0,0,10,0);
F(F_LOADOUT, "loadout", LATTICE_FIELD_STRUCT, offsetof(PlayerState,loadout), qz,0,0,0,loadoutId);
lattice_type_desc pl{};
pl.key = "Player"; pl.state_size = sizeof(PlayerState);
pl.fields = f.data(); pl.field_count = (uint32_t)f.size();
lattice_type_id playerId = lattice_register_type(r, &pl);
/* --- ROLE INJECTION: this single line is SERVER vs HOST --- */
lattice_runner_start(r, LATTICE_MODE_SERVER); /* swap to LATTICE_MODE_HOST for P2P host */
lattice_runner_listen(r, 9000);
/* --- spawn one Player (authority only) --- */
PlayerState init{};
init.isAlive=1; init.score=10; init.xp=1000; init.stamina=75.0f;
init.position={1,2,3}; init.velocity={0.5f,0,-0.5f};
init.rotation={0,0,0,1}; init.loadout={42,30,1};
lattice_netid nid = lattice_spawn(r, playerId, &init, /*owner*/7);
std::printf("spawned netid=%llu\n", (unsigned long long)nid);
/* --- fixed-tick loop: mutate state, mark dirty, pump --- */
const double dt = 1.0/60.0;
for (int tick = 0; tick < 600; ++tick) { /* ~10s at 60Hz */
PlayerState* s = (PlayerState*)lattice_object_state(r, nid);
if (s) { s->score += 1; lattice_object_mark_dirty(r, nid, F_SCORE); }
if (tick == 100) {
const uint8_t msg[4] = {'p','o','n','g'};
lattice_rpc(r, nid, /*rpc_id*/9, LATTICE_RPC_ALL, msg, 4, /*reliable*/1);
}
lattice_runner_tick(r, dt);
}
lattice_despawn(r, nid);
lattice_runner_tick(r, dt);
lattice_runner_destroy(r);
return 0;
}
Build and run it (Linux):
g++ -std=c++20 -O2 -Iinclude -o build/example example.cpp \
-Lbuild -llattice -Wl,-rpath,'$ORIGIN'
./build/example
To produce a P2P host instead, change exactly one line —
lattice_runner_start(r, LATTICE_MODE_HOST) — and rebuild. To produce a client,
start with LATTICE_MODE_CLIENT and call lattice_runner_connect(...) instead of
listen + spawn, then read replicated objects via the on_spawned / on_state_updated
callbacks. The schema-registration code is unchanged in all three.
9. Conformance & staying in step with the core¶
- This guide matches the verified conformance harness. Every snippet above is taken
from or directly compatible with
reference/tests/conformance.cpp, which links only the public header and exercises the full module surface — primitive bit round-trips (PART A), loopback server/client replication + RPC (PART B), the deterministic network simulator (PART C), and the reliability layer / four channels (PART D). It passes 130/130 checks with exit 0 on Linux and, cross-compiled with MinGW-w64 (-posix, static) and run under wine, 130/130 on Windows — including the seed-driven PRNG sequences, so loss/reorder/dup decisions and RTT estimates are byte-identical across OSes (02 §23.4). - The C ABI is the stable contract. The reference is a faithful, compilable model of
the documented ABI and wire encodings; it deliberately omits crypto, real UDP/QUIC,
NAT/relay, prediction/rollback, and interest management (called out at their call sites
as extension points — reference/README.md). As the production
core lands those features (roadmap tasks C3–C14), they extend the platform shim and the
internals behind
lattice.h; your module is unaffected as long as it speaks only the C ABI. Re-run the conformance harness after pulling a new core to confirm the surface still matches. - Schema discipline is enforced. Register types in the same order on every peer; the core's per-type content hash rejects spawns on a schema mismatch (02 §21.6), which is what keeps the dedicated-server, host, and client builds of your one module mutually compatible.
See also: 08 — Server & Client API reference for the full per-function C ABI and engine-level API; 02 §18 for the shared-sim mechanism and determinism rules; 05 for binding the same module into Unity / Unreal / Godot; and reference/README.md for what the skeleton does and does not implement.