Skip to content

06 — RPCs

A remote procedure call sends a one-shot message with a payload to other peers, routed by a target and delivered reliably or unreliably. Unlike replicated state (which converges to a value), an RPC is an event you handle once.

Verified against: reference/include/lattice/lattice.h:649-655; reference/tests/two_process.cpp:71-77,202-217,294-305.

Sending an RPC

There is one entry point — a runner-level call scoped to an object by its lattice_netid:

lattice_rpc (reference/include/lattice/lattice.h:652-655)
lattice_result lattice_rpc(lattice_runner* r, lattice_netid id, uint16_t rpc_id,
                           lattice_rpc_target target, const uint8_t* payload,
                           uint32_t len, int reliable);

Verified against: reference/include/lattice/lattice.h:652-655.

  • id — the lattice_netid the RPC is scoped to (drives OWNER / ALL_BUT_OWNER routing).
  • rpc_id — a uint16_t you choose to identify the call (like an opcode).
  • target — who receives it (below).
  • payload / len — arbitrary bytes you encode yourself; pass nullptr/0 for none.
  • reliable — non-zero guarantees delivery + ordering; zero is fire-and-forget.

The client half of the two-process driver fires a HELLO RPC to the server; the server replies with a WELCOME RPC to everyone:

Client → server, then server → all (two_process.cpp)
// tags the two processes agree on:
static const uint16_t RPC_HELLO   = 0xA1;  // client -> server
static const uint16_t RPC_WELCOME = 0xB2;  // server -> client

// CLIENT: send HELLO to the authority, reliably
lattice_rpc(r, cap.last_spawn, RPC_HELLO, LATTICE_RPC_SERVER,
            (const uint8_t*)HELLO_MSG, (uint32_t)std::strlen(HELLO_MSG), /*reliable=*/1);

// SERVER: broadcast WELCOME to all, reliably
lattice_rpc(r, id, RPC_WELCOME, LATTICE_RPC_ALL,
            (const uint8_t*)WELCOME_MSG, (uint32_t)std::strlen(WELCOME_MSG), /*reliable=*/1);

Verified against: reference/tests/two_process.cpp:71-72,327-328 (client HELLO → SERVER), :214-215 (server WELCOME → ALL).

The starter module shows the same round-trip on the loopback transport:

RPC round-trip (starter-server-module/server_main.cpp)
lattice_rpc(client, pid, RPC_HELLO,   LATTICE_RPC_SERVER, (const uint8_t*)"hi", 2, /*reliable*/1);
// … pump …
lattice_rpc(server, pid, RPC_WELCOME, LATTICE_RPC_ALL,    (const uint8_t*)"welcome", 7, /*reliable*/1);

Verified against: examples/starter-server-module/server_main.cpp:170-175.

Targets

lattice_rpc_target selects the routing:

Target Value Delivers to
LATTICE_RPC_SERVER 0 The authority (a client's RPC implicitly goes here)
LATTICE_RPC_OWNER 1 The object's owner (input authority)
LATTICE_RPC_ALL 2 Every peer
LATTICE_RPC_ALL_BUT_OWNER 3 Everyone except the owner

Verified against: reference/include/lattice/lattice.h:108-113.

Receiving an RPC

Install on_rpc on your lattice_callbacks. It hands you the netid the RPC was scoped to, the rpc_id, and a payload pointer + len:

on_rpc signature (reference/include/lattice/lattice.h:300-301)
void (*on_rpc)(void* user_data, lattice_netid id, uint16_t rpc_id,
               const uint8_t* payload, uint32_t len);
Handle an incoming RPC (two_process.cpp)
static void cb_rpc(void* u, lattice_netid, uint16_t rpc_id, const uint8_t* p, uint32_t len) {
    Capture* c = (Capture*)u;
    c->got_rpc = true;
    c->rpc_id = rpc_id;
    c->rpc_payload.assign((const char*)p, len);   // COPY it out — the pointer is only valid in-call
}

Verified against: reference/tests/two_process.cpp:106-110.

The payload pointer is valid only during the callback — copy what you keep

Like every buffer the core hands a callback, the payload pointer is owned by the core and valid only for the duration of the call. The driver assigns it into a std::string immediately. If you stash the raw pointer and read it after the callback returns, it is a dangling read.
Verified against: reference/include/lattice/lattice.h:22-25 (buffers caller-owned, callbacks synchronous); reference/tests/two_process.cpp:109.

The starter module dispatches on rpc_id:

Dispatch on rpc_id (starter-server-module/server_main.cpp)
static void on_rpc(void* user, lattice_netid id, uint16_t rpc_id, const uint8_t*, uint32_t) {
    Context* ctx = (Context*)user;
    if (rpc_id == RPC_HELLO)   ctx->hellos_received++;
    if (rpc_id == RPC_WELCOME) ctx->welcomes_received++;
}

Verified against: examples/starter-server-module/server_main.cpp:65-72.

Reliability

The final reliable argument maps straight to the core's reliable-vs-unreliable channels:

  • reliable = 1 — guaranteed delivery, in order. Use for gameplay-critical, infrequent messages (a move, a purchase, "player died"). Both drivers send their HELLO/WELCOME reliably so a dropped localhost datagram can't lose them.
  • reliable = 0 — may be dropped or reordered; cheaper. Use for high-frequency, loss-tolerant hints where the latest wins.

Verified against: the reliable=1 argument in every RPC call at reference/tests/two_process.cpp:214-215,294-295; examples/starter-server-module/server_main.cpp:171,174.

Reliable messages are retried each tick until observed

The two-process driver resends its reliable HELLO/WELCOME each tick until it sees the reply, which is how it survives a lost datagram on a real UDP socket. The reliable channel handles the retransmission; your resend loop is just the driver's way of pumping until the exchange is confirmed by observation.
Verified against: reference/tests/two_process.cpp:289-307 (client resends HELLO each tick until it observes WELCOME).

Is the RPC path real? Yes — and verified

The RPC path is exercised end to end by both working drivers and the conformance harness: a client → server RPC and a server → ALL RPC are sent and asserted to arrive with the correct id and payload, over both the loopback transport and a real localhost UDP socket.

Verified against: reference/tests/two_process.cpp:202-217,294-307 (the asserted RPC exchange, SERVER: ALL CHECKS PASSED / CLIENT: ALL CHECKS PASSED); reference/README.md (PART B: "a client→server RPC and a server→ALL RPC are exchanged (payloads asserted)").

RPC vs. event: who sent it?

One design note that matters for server-authoritative games: on_rpc does not tell you which peer sent the RPC — its signature carries the netid and rpc_id, but no sender. If your handler must know who sent a message (to map a connection to a seat, validate a move came from the right player, etc.), use a custom event instead — on_event carries the originating sender peer id. That is the subject of chapter 07.

Verified against: reference/include/lattice/lattice.h:300-301 (on_rpc — no sender) vs. :311-312 (on_event — has sender).

Use an RPC when… Use an event when…
The handler doesn't need the sender's identity The receiver must know who sent it (server-side validation)
It's naturally scoped to an object (netid) It's session-global (no object) — pass netid 0
A broadcast / owner-directed effect A directed send to one specific connection (LATTICE_EVENT_PEER)

Both ride the same reliable/unreliable channels and the same target routing.


Next: 07 — Events, the object-less message channel that carries the sender.