05 — Networked objects¶
A networked object is a piece of replicated state the authority owns and every peer sees. In C you:
- register a type — a C struct's layout + which fields replicate,
- spawn an instance on the authority, and
- read replicated changes on every peer via
on_spawned/on_state_updated.
This is the part the whole codec exists to serve, and both working drivers exercise it.
Verified against: reference/tests/two_process.cpp:44-65,182-192,267-282;
examples/starter-server-module/server_main.cpp:27-36,88-102,146-165.
The replicated state block is a POD C struct¶
Replicated state is one plain-old-data C struct. Each field you want replicated gets a
lattice_field_desc entry describing its kind and byte offset; the core reads and writes that struct
in place and delta-encodes the changed fields. The two-process driver's type is two fields:
struct ScoreState {
int32_t score; /* INT32 */
uint8_t alive; /* BOOL (1 byte in host memory) */
};
Verified against: reference/tests/two_process.cpp:47-50.
Registering a type¶
You describe each replicated leaf field with a lattice_field_desc, put them in an array, fill a
lattice_type_desc, and call lattice_register_type. Offsets come from offsetof, so the
schema matches the struct layout exactly:
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); // returns the type id; 0 on error
}
Verified against: reference/tests/two_process.cpp:52-65; lattice_register_type at
reference/include/lattice/lattice.h:640.
The two descriptor structs:
typedef struct {
const char* name; /* field name (part of the content hash) */
lattice_field_kind kind;
uint32_t offset; /* byte offset of this field in the state block */
lattice_float_quant quant; /* for COMPRESSED_FLOAT / VECTOR* */
int32_t ranged_min; /* for RANGED_INT */
int32_t ranged_max; /* for RANGED_INT */
uint32_t quat_bits; /* for QUATERNION smallest-three (default 10)*/
uint32_t byte_capacity;/* for STRING / BYTES: fixed capacity */
lattice_type_id struct_type; /* for STRUCT: a nested registered type */
} lattice_field_desc;
typedef struct {
const char* key; /* stable type key; hashed + matched across peers */
uint32_t state_size; /* bytes of the local state block for one object */
const lattice_field_desc* fields; /* NULL when the manual path is used */
uint32_t field_count;
lattice_serialize_fn serialize; /* optional manual path; NULL => declarative */
lattice_deserialize_fn deserialize;
void* user;
} lattice_type_desc;
Verified against: reference/include/lattice/lattice.h:237-247, :264-272. The core deep-copies
the field array and key string, so your buffers need not outlive the call.
Verified against: reference/include/lattice/lattice.h:257-259.
The field kinds¶
LATTICE_FIELD_BOOL, LATTICE_FIELD_INT32, LATTICE_FIELD_INT64, LATTICE_FIELD_FLOAT,
LATTICE_FIELD_DOUBLE, LATTICE_FIELD_RANGED_INT, LATTICE_FIELD_COMPRESSED_FLOAT,
LATTICE_FIELD_VECTOR2, LATTICE_FIELD_VECTOR3, LATTICE_FIELD_VECTOR4, LATTICE_FIELD_QUATERNION,
LATTICE_FIELD_STRING, LATTICE_FIELD_BYTES, LATTICE_FIELD_STRUCT
The per-kind detail fields on lattice_field_desc you fill for the compressed kinds:
| Kind | Extra fields to set |
|---|---|
LATTICE_FIELD_RANGED_INT |
ranged_min, ranged_max |
LATTICE_FIELD_COMPRESSED_FLOAT / VECTOR2/3/4 |
quant (a lattice_float_quant) |
LATTICE_FIELD_QUATERNION |
quat_bits (smallest-three; default 10) |
LATTICE_FIELD_STRING / LATTICE_FIELD_BYTES |
byte_capacity (fixed capacity in the block) |
LATTICE_FIELD_STRUCT |
struct_type (a nested registered type id) |
Verified against: reference/include/lattice/lattice.h:91-106, :237-247.
lattice_float_quant describes how a compressed float/vector is quantized:
typedef struct { float min; float max; float precision; } lattice_float_quant;
Verified against: reference/include/lattice/lattice.h:224-229. The POD vector types are
lattice_vec2/vec3/vec4 and lattice_quat at :219-222.
Designated-initializer style¶
The examples build the descriptor array compactly with aggregate initializers — note the trailing
zeros are the quant/ranged/quat_bits/byte_capacity/struct_type fields left at their
defaults:
inline void player_field_schema(lattice_field_desc out[5]) {
out[0] = lattice_field_desc{ "pos_x", LATTICE_FIELD_INT32, (uint32_t)offsetof(PlayerState, pos_x), {}, 0, 0, 0, 0, 0 };
out[1] = lattice_field_desc{ "pos_y", LATTICE_FIELD_INT32, (uint32_t)offsetof(PlayerState, pos_y), {}, 0, 0, 0, 0, 0 };
out[2] = lattice_field_desc{ "pos_z", LATTICE_FIELD_INT32, (uint32_t)offsetof(PlayerState, pos_z), {}, 0, 0, 0, 0, 0 };
out[3] = lattice_field_desc{ "health", LATTICE_FIELD_INT32, (uint32_t)offsetof(PlayerState, health), {}, 0, 0, 0, 0, 0 };
out[4] = lattice_field_desc{ "score", LATTICE_FIELD_INT32, (uint32_t)offsetof(PlayerState, score), {}, 0, 0, 0, 0, 0 };
}
Verified against: examples/anti-cheat-demo/server_module.h:53-59.
The content hash — why registration must match across peers¶
lattice_register_type returns a lattice_type_id; the type also carries a content hash over the
ordered field list. Both ends must register the same fields in the same order so the hashes
agree and the handshake / spawn is accepted — the core rejects and logs a mismatch rather than
mis-decoding. You can read a type's hash for a sanity check:
Verified against: reference/include/lattice/lattice.h:782; the "register the SAME fields in the
SAME order so their content hashes match and the handshake is accepted" requirement at
examples/starter-server-module/server_main.cpp:88-89 and
reference/tests/two_process.cpp:44-46.
Register the same types, in the same order, on every peer — before connecting
Type ids and content hashes are derived from the registration order. Both two_process.cpp and
the starter module call their register_* helper on both the server and client runners
before start/connect. If host and client disagree, replication breaks (and the core logs the
mismatch).
Verified against: reference/tests/two_process.cpp:164,245;
examples/starter-server-module/server_main.cpp:127-130.
Spawning (authority only)¶
Only an authority (SERVER / HOST / SHARED_HOST) may spawn. lattice_spawn takes the type, a
pointer to an initial state struct, and an owner id, and returns the new lattice_netid (0
on error):
lattice_netid lattice_spawn(lattice_runner* r, lattice_type_id type,
const void* initial_state, uint64_t owner); /* authority only; 0 on error */
ScoreState init; init.score = 100; init.alive = 1;
lattice_netid id = lattice_spawn(r, type, &init, /*owner=*/0);
if (!id) { /* spawn failed (non-authority, unknown type, …) */ }
for (int i = 0; i < 4; ++i) pump(r); // let the spawn flush to the client
Verified against: reference/tests/two_process.cpp:182-187; lattice_spawn at
reference/include/lattice/lattice.h:641-642.
Reading and writing state; marking dirty¶
The authority mutates state by writing the struct in place through lattice_object_state (a
mutable pointer to the object's block, or NULL if unknown), then calls lattice_object_mark_dirty
with the field index (its position in registration order) so the core replicates that field on
the next tick:
ScoreState* s = (ScoreState*)lattice_object_state(r, id);
s->score = 4242;
lattice_object_mark_dirty(r, id, F_SCORE); // F_SCORE == 0: the field's registration index
Verified against: reference/tests/two_process.cpp:188-190; the field-index enum
{ F_SCORE = 0, F_ALIVE = 1 } at :67-68. lattice_object_state /
lattice_object_mark_dirty at reference/include/lattice/lattice.h:645.
The starter module shows the loop form — mutate several fields per tick, marking each by its index:
for (int t = 0; t < 5; ++t) {
PlayerState* ps = (PlayerState*)lattice_object_state(server, pid);
if (ps) {
ps->pos_x += 1; lattice_object_mark_dirty(server, pid, 0 /*pos_x*/);
ps->score += 10; lattice_object_mark_dirty(server, pid, 2 /*score*/);
}
pump(2);
}
Verified against: examples/starter-server-module/server_main.cpp:156-163.
Only the authority writes; clients read
lattice_object_state + lattice_object_mark_dirty are authority-side operations. Clients
read replicated state in their callbacks (below) and never write the block to change the
world — they ask the authority via RPCs (chapter 06) or events
(chapter 07). A client can read its local copy of the block via
lattice_object_state to render it, exactly as the client does below.
Verified against: reference/tests/two_process.cpp:277-278 (client reads the block to
observe replicated score).
Reacting to replication¶
Every peer learns about objects through two callbacks (installed in the lattice_callbacks struct,
chapter 02):
on_spawned(user, id, type, owner)fires when an object first replicates in.on_state_updated(user, id)fires when a replicated field changed — re-read the block to see the new values.
static void cb_spawned(void* u, lattice_netid id, lattice_type_id, uint64_t) {
Capture* c = (Capture*)u; c->spawned.push_back(id); c->last_spawn = id;
}
static void cb_state_updated(void* u, lattice_netid) { ((Capture*)u)->updates++; }
// … later, after on_spawned, the client reads the block and watches for the replicated value:
ScoreState* s = (ScoreState*)lattice_object_state(r, cap.last_spawn);
if (s && s->score == 4242) { /* the mutated state arrived */ }
Verified against: reference/tests/two_process.cpp:102-105 (callbacks), :271-282 (the client
observes the spawn, then reads the block until score == 4242). on_spawned / on_state_updated
signatures at reference/include/lattice/lattice.h:297,299.
on_state_updated gives you the netid, not the data — you re-read the block
The callback tells you which object changed; you call lattice_object_state(r, id) and read the
struct to get the values. That keeps the callback allocation-free and lets you read exactly the
fields you care about.
Verified against: reference/include/lattice/lattice.h:299 (on_state_updated(user_data,
id) — id only); reference/tests/two_process.cpp:277-281.
Enumerating and despawning objects¶
The runner keeps an object table you can walk, and the authority can remove an object:
lattice_result lattice_despawn(lattice_runner* r, lattice_netid id);
uint32_t lattice_object_count(lattice_runner* r);
lattice_netid lattice_object_at(lattice_runner* r, uint32_t index);
lattice_despawn is authority-only; every peer then sees on_despawned(id).
Verified against: reference/include/lattice/lattice.h:643-647; the on_despawned callback at
:298.
The manual serialization path (advanced)¶
If a type's fields don't map cleanly to the declarative descriptors — a custom struct, a
variable-length encoding — you can register a manual path instead: set serialize /
deserialize function pointers on the lattice_type_desc (leave fields NULL). The core then
treats the whole state block as one unit and calls your functions with a lattice_bitwriter /
lattice_bitreader:
typedef void (*lattice_serialize_fn) (lattice_bitwriter* w, const void* state, void* user);
typedef void (*lattice_deserialize_fn)(lattice_bitreader* r, void* state, void* user);
Verified against: reference/include/lattice/lattice.h:249-272 (the serialize/deserialize
fields; "If serialize/deserialize are non-NULL the type uses the MANUAL path … Otherwise the
DECLARATIVE fields array is used"). The bit codec you'd use inside these is covered in
chapter 07.
Prefer the declarative path unless you need it
The declarative field array (what the tests and both examples use) gives you the delta encoder,
per-field dirty tracking, and the content hash for free. Reach for the manual path only for
fields the descriptors can't express. The reference's conformance harness exercises both paths
(a manual Loadout nested as a STRUCT field inside a declarative Player).
Verified against: reference/README.md ("a manual-serialized Loadout custom value struct
… a declarative Player … that nests Loadout as a STRUCT field").
Next: 06 — RPCs.