Skip to content

05 — Networked objects

A networked object is a piece of replicated state the authority owns and every peer sees. In C you:

  1. register a type — a C struct's layout + which fields replicate,
  2. spawn an instance on the authority, and
  3. 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:

ScoreState — the replicated block (two_process.cpp)
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:

register_score_type (two_process.cpp)
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:

lattice_field_desc (reference/include/lattice/lattice.h:237-247)
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;
lattice_type_desc (reference/include/lattice/lattice.h:264-272)
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_kind (reference/include/lattice/lattice.h:91-106)
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:

lattice_float_quant (reference/include/lattice/lattice.h:225-229)
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:

player_field_schema (anti-cheat-demo/server_module.h)
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:

uint32_t lattice_type_content_hash(lattice_runner* r, lattice_type_id type);

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_spawn (reference/include/lattice/lattice.h:641-642)
lattice_netid lattice_spawn(lattice_runner* r, lattice_type_id type,
                            const void* initial_state, uint64_t owner); /* authority only; 0 on error */
Spawn a Player and replicate it (two_process.cpp)
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:

Mutate + mark dirty (two_process.cpp)
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:

Advance + replicate over several ticks (starter-server-module/server_main.cpp)
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.
Capture spawn + read the replicated state (two_process.cpp)
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:

Object table + despawn (reference/include/lattice/lattice.h:643-647)
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:

The manual-path function pointers (reference/include/lattice/lattice.h:254-255)
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.