Skip to content

07 — Events

Custom events are the object-less sibling of an RPC — a small tagged message you send to other peers, where the handler learns who sent it. Where lattice_rpc always names a netid, an event can be global (netid 0) or optionally object-scoped, and its callback carries the originating sender peer id.

Verified against: reference/include/lattice/lattice.h:656-684; reference/tests/two_process.cpp:73-74,111-116,206-217,296-305.

Sending an event

lattice_send_event (reference/include/lattice/lattice.h:680-684)
lattice_result lattice_send_event(lattice_runner* r, uint16_t event_id,
                                  lattice_event_target target, lattice_netid netid,
                                  uint64_t peer, const uint8_t* payload,
                                  uint32_t len, int reliable);

Verified against: reference/include/lattice/lattice.h:680-684.

  • event_id — a uint16_t tag delivered verbatim to the receiver's on_event.
  • target — a lattice_event_target (below). For a client, the target is implicitly the authority.
  • netid0 for a global / session-scoped event, or a live object's netid for an object-scoped one (used for OWNER / ALL_BUT_OWNER routing exactly like an RPC).
  • peer — the destination connection id for LATTICE_EVENT_PEER (ignored otherwise).
  • payload / len — arbitrary serialized bytes, delivered byte-exact; may be nullptr/0.
  • reliable — non-zero rides the reliable-ordered channel (exactly-once, ordered); zero is best-effort.

The two-process driver sends a global HELLO event from the client and a global WELCOME event from the server, alongside the RPCs — note netid = 0 and peer = 0 for a global send:

Global events, both directions (two_process.cpp)
static const uint16_t EVT_HELLO   = 0x10;  // client -> server (object-less)
static const uint16_t EVT_WELCOME = 0x20;  // server -> client (object-less)

// CLIENT: HELLO to the authority
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 everyone
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:73-74,296-297 (client HELLO → SERVER), :216-217 (server WELCOME → ALL).

Event targets

lattice_event_target mirrors the RPC routing policy, plus a PEER target for a directed send:

Target Value Delivers to
LATTICE_EVENT_SERVER 0 The authority (a client's events implicitly go here)
LATTICE_EVENT_OWNER 1 The (optional) object's owner
LATTICE_EVENT_ALL 2 Every peer
LATTICE_EVENT_ALL_BUT_OWNER 3 Everyone except the owner
LATTICE_EVENT_PEER 4 One specific connection (via the peer argument)

Verified against: reference/include/lattice/lattice.h:123-129. The values deliberately mirror lattice_rpc_target so the routing policy is one decision, not two.
Verified against: reference/include/lattice/lattice.h:118-121.

A directed reply to one connection — LATTICE_EVENT_PEER

To answer exactly one peer (e.g. a join handshake: the authority assigns a newcomer its slot and replies to just that connection), pass LATTICE_EVENT_PEER and put the destination connection id in the peer argument. You learn that id from the sender of the event they sent you (below).
Verified against: reference/include/lattice/lattice.h:128 (LATTICE_EVENT_PEER — "send to one specific connection"), :533 (the peer argument).

Receiving an event — and the sender

Install on_event. Its signature is the key difference from on_rpc — it carries the sender:

on_event signature (reference/include/lattice/lattice.h:311-312)
void (*on_event)(void* user_data, uint16_t event_id, lattice_netid netid,
                 uint64_t sender, const uint8_t* payload, uint32_t len);
  • event_id — the tag that was sent.
  • netid0 for a global event, or the owning object's netid for an object-scoped one.
  • sender — the originating peer id (0 means the local authority).
  • payload / len — the byte-exact blob; the pointer is owned by the core and valid only for the duration of the call.

Verified against: reference/include/lattice/lattice.h:302-312.

Capture an event, sender included (two_process.cpp)
static void cb_event(void* u, uint16_t event_id, lattice_netid, uint64_t sender,
                     const uint8_t* p, uint32_t len) {
    Capture* c = (Capture*)u;
    c->got_event = true;
    c->event_id = event_id;
    c->event_sender = sender;                      // <-- who sent it (0 == authority)
    c->event_payload.assign((const char*)p, len);  // copy it out before the call returns
}

Verified against: reference/tests/two_process.cpp:111-116.

This is why events exist alongside RPCs

on_rpc gives you (id, rpc_id, payload); on_event gives you (event_id, netid, sender, payload). For a host-authoritative design where the server must know who asked for something — to map a peer to its assigned role and validate the request — the sender is essential, and only events carry it. This is the pattern chapter 08 builds on.
Verified against: reference/include/lattice/lattice.h:300-301 (on_rpc: no sender) vs. :311-312 (on_event: has sender).

Reliability

Like RPCs, the final reliable argument selects the channel: non-zero → reliable-ordered (exactly-once, ordered); zero → best-effort. Both drivers send their events reliably so the exchange survives a lost datagram.

Verified against: reference/include/lattice/lattice.h:673-674; reference/tests/two_process.cpp:247-249,328-330 (all reliable=1).

Is the event channel real? Yes — and verified

The event channel is proven by the two-process UDP driver and the conformance harness: a HELLO event (client → server) and a WELCOME event (server → all) are sent and asserted to arrive with the right id and payload, in both directions, over a real localhost UDP socket — ending in SERVER: ALL CHECKS PASSED / CLIENT: ALL CHECKS PASSED.

Verified against: reference/tests/two_process.cpp:206-217,296-317 (asserted event exchange); reference/README.md (PART B).


Encoding payloads: the BitWriter / BitReader

Events (and RPCs, and the manual serialization path of chapter 05) carry raw bytes, so you encode and decode the payload yourself. For a byte or two the drivers just use a C string. For anything structured, the core ships a typed bit-packing writer/reader that both ends use to produce byte-identical wire data.

BitWriter — construct a payload (reference/include/lattice/lattice.h:726-750)
lattice_bitwriter* lattice_bw_create(void);
void               lattice_bw_destroy(lattice_bitwriter* w);
void               lattice_bw_reset(lattice_bitwriter* w);
uint32_t           lattice_bw_byte_count(const lattice_bitwriter* w);
const uint8_t*     lattice_bw_data(const lattice_bitwriter* w);  /* the flushed byte buffer */

void lattice_bw_write_bool(lattice_bitwriter* w, int v);
void lattice_bw_write_int(lattice_bitwriter* w, int32_t v);
void lattice_bw_write_ranged_int(lattice_bitwriter* w, int32_t value, int32_t min, int32_t max);
void lattice_bw_write_long(lattice_bitwriter* w, int64_t v);
void lattice_bw_write_float(lattice_bitwriter* w, float v);
void lattice_bw_write_compressed_float(lattice_bitwriter* w, float value, lattice_float_quant q);
void lattice_bw_write_double(lattice_bitwriter* w, double v);
void lattice_bw_write_vector3(lattice_bitwriter* w, lattice_vec3 v, lattice_float_quant q);
void lattice_bw_write_quaternion(lattice_bitwriter* w, lattice_quat v, uint32_t bits_per_comp);
void lattice_bw_write_string(lattice_bitwriter* w, const char* s);
void lattice_bw_write_bytes(lattice_bitwriter* w, const uint8_t* p, uint32_t n);
/* … also write_enum, write_byte, write_ranged_long, write_vector2/4, write_bits … */
BitReader — read it back (reference/include/lattice/lattice.h:754-774)
lattice_bitreader* lattice_br_create(const uint8_t* data, uint32_t byte_len);
void               lattice_br_destroy(lattice_bitreader* r);

int      lattice_br_read_bool(lattice_bitreader* r);
int32_t  lattice_br_read_int(lattice_bitreader* r);
int32_t  lattice_br_read_ranged_int(lattice_bitreader* r, int32_t min, int32_t max);
float    lattice_br_read_float(lattice_bitreader* r);
lattice_vec3 lattice_br_read_vector3(lattice_bitreader* r, lattice_float_quant q);
uint32_t lattice_br_read_string(lattice_bitreader* r, char* out, uint32_t cap);   /* returns length, NUL-terminates */
uint32_t lattice_br_read_bytes(lattice_bitreader* r, uint8_t* out, uint32_t cap); /* returns length */
/* … the mirror of every writer method … */

Verified against: reference/include/lattice/lattice.h:725-774.

Build a structured event payload with the BitWriter
lattice_bitwriter* w = lattice_bw_create();
lattice_bw_write_ranged_int(w, cell, 0, 8);        // a board cell 0..8
lattice_bw_write_string(w, "gg");                  // a chat line
lattice_send_event(r, MY_EVENT, LATTICE_EVENT_SERVER, /*netid=*/0, /*peer=*/0,
                   lattice_bw_data(w), lattice_bw_byte_count(w), /*reliable=*/1);
lattice_bw_destroy(w);

// on the receiving side, inside on_event(payload, len):
lattice_bitreader* rd = lattice_br_create(payload, len);
int cell = lattice_br_read_ranged_int(rd, 0, 8);
char text[64]; lattice_br_read_string(rd, text, sizeof(text));
lattice_br_destroy(rd);

Verified against: the writer/reader surface at reference/include/lattice/lattice.h:726-774; the codec round-trips exactly (bool = 1 bit; ranged int in ceil(log2(range+1)) bits; compressed float quantized; etc.) per reference/README.md (PART A) and reference/README.md (bit-packer description).

Read fields back in the exact order and with the exact params you wrote them

The bit stream has no self-describing tags — a ranged_int written with (min,max) must be read with the same (min,max), a compressed_float with the same lattice_float_quant, in the same order. Reading with different parameters silently mis-decodes. Keep the encode/decode pair together (as above) so they never drift.
Verified against: reference/include/lattice/lattice.h:737 / :761 (ranged read/write take the same min/max); :604 / :628 (compressed float take the same q).

For small, fixed payloads a hand-rolled buffer is perfectly fine (an identity byte + UTF-8, say). Use the BitWriter/BitReader when the message has several typed fields or you want tight quantization.


Next: 08 — Authority, where the sender from this chapter powers server-authoritative validation.