Skip to content

08 — Authority

Authority is who gets to decide. Lattice distinguishes two kinds:

  • State authority — the peer that owns and writes the simulation. In SERVER/HOST modes this is the server; it's the only peer that may lattice_spawn, write the state block via lattice_object_state, and lattice_object_mark_dirty.
  • Input / ownership authority — the peer whose input drives a particular object (its owner, the value you passed to lattice_spawn). A client can own its avatar without holding state authority over the world.

Verified against: reference/include/lattice/lattice.h:641-645 (spawn/state/mark-dirty are authority operations, and spawn takes an owner); :548-563 (the two authority models).

Host-authoritative validation (the default model)

The default authority model is server/host-authoritative: the server owns every object's state and clients receive snapshots. So the safe pattern for any client-initiated change is: clients ask, the authority validates and applies. A client never writes shared state; it sends a request (an event or an RPC to SERVER), and the authority decides.

Verified against: reference/include/lattice/lattice.h:689-694 ("The default authority model is SERVER/HOST-authoritative: the server owns the state of every object and clients receive snapshots").

The recipe

  1. Client sends a request — an event to LATTICE_EVENT_SERVER (or an RPC to LATTICE_RPC_SERVER). Prefer an event when the authority must know who asked, because on_event carries the sender and on_rpc does not (chapter 07).
  2. Authority validates against its own rules, using the trusted sender identity — never a value the client packed into the payload.
  3. On success: write the state block + lattice_object_mark_dirty so it replicates. On failure: change nothing (optionally notify).
  4. Clients only ever read replicated state and render it.

This keeps a cheating client from writing illegal state: it can only ask, and the authority is the single writer.

Verified against: reference/include/lattice/lattice.h:311-312 (on_event carries sender); :507-508 (only the authority writes + marks dirty); the anti-cheat example enforces exactly this at its apply step (below).

Ownership enforcement, concretely

The anti-cheat example server turns this recipe into an enforced rule: a client that RPCs an object it does not own is denied at the authority's apply step, and the server's on_violation callback fires with the offending connection. This is the ownership firewall — a client can act only on objects it owns.

A malicious RPC to a non-owned object is denied (anti-cheat-demo/main.cpp)
// The server spawned a server-owned object (owner = peer 0). The client does NOT own it.
lattice_rpc(client.runner, server_obj, RPC_SET_SCORE, LATTICE_RPC_SERVER, nullptr, 0, /*reliable*/1);
// … pump …
// The authority firewall denies it and fires on_violation(LATTICE_AC_OWNERSHIP):
server.count_violation(LATTICE_AC_OWNERSHIP) >= 1;   // asserted true
uint64_t offender = server.offending_conn();          // learned from the callback, not hardcoded

Verified against: examples/anti-cheat-demo/main.cpp:103-118; the ownership gate is enabled by c.enforce_ownership = 1 at examples/anti-cheat-demo/server_module.cpp:42-43.

The opt-in anti-cheat layer

Server-authoritative validation is powerful but manual. The core also ships an opt-in, server-side anti-cheat layer that runs at the authority's apply step and rejects impossible submissions — input bounds, move speed / teleport, RPC/event floods (token bucket), ownership, lag-comp rewind, fire-rate, aim-snap, and (for shared authority) a tampered state hash. It is default-off (a zero config behaves byte-for-byte as before) and additive.

Enable it (reference/include/lattice/lattice.h:632-635)
lattice_result lattice_ac_configure(lattice_runner* r, const lattice_ac_config* cfg);
uint32_t       lattice_ac_score(lattice_runner* r, uint64_t conn);

Verified against: reference/include/lattice/lattice.h:621-635; the layer's contract at :139-214.

You turn it on by zero-initializing a lattice_ac_config (all-zero == off), setting enabled = 1, and setting the bounds you want — anything left 0 disables just that gate:

A tuned config (anti-cheat-demo/server_module.cpp)
lattice_ac_config c{};                 // all-zero == anti-cheat OFF; we opt in:
c.enabled = 1;
c.max_speed_per_tick  = 10;            // > 10 world-units/tick is a speedhack
c.max_teleport_dist   = 100;           // a single jump > 100 is a teleport
c.rpc_bucket_capacity = 3;             // token bucket: burst 3, +1/tick
c.rpc_refill_per_tick = 1;
c.enforce_ownership   = 1;             // deny RPC/write to a non-owned object
c.min_fire_interval_ticks = 5;         // >= 5 ticks between shots
c.warn_threshold = 2;                  // accumulate severity: warn at 2, kick at 6
c.kick_threshold = 6;
lattice_ac_configure(runner_, &c);

Verified against: examples/anti-cheat-demo/server_module.cpp:20-57 (make_ac_config); installed at :142-143 (lattice_ac_configure).

When a gate rejects a submission, your on_violation callback fires (on the authority, inside tick, like every callback) with the offending connection, the violation category, the severity it added, and a diagnostic detail. Accumulated score crossing kick_threshold disconnects the peer (you observe it via on_disconnected):

on_violation signature (reference/include/lattice/lattice.h:334-335)
void (*on_violation)(void* user_data, uint64_t conn, lattice_ac_violation violation,
                     uint32_t severity, int64_t detail);

Verified against: reference/include/lattice/lattice.h:324-335; the demo records violations + a kick at examples/anti-cheat-demo/server_module.cpp:82-98; the config-only opt-in ("A runner with no anti-cheat config behaves byte-for-byte as before") at reference/include/lattice/lattice.h:146-152.

This is verified

The anti-cheat demo drives a malicious client against a real SERVER runner over the loopback transport and asserts each defense fires: an RPC to a non-owned object is denied; an RPC flood is token-bucket rate-limited and the peer is kicked at the threshold; speed-hack/teleport moves are rejected with a rollback target; rapid-fire trips the fire-rate heuristic. It ends ALL ANTI-CHEAT DEFENSES FIRED AS EXPECTED.
Verified against: examples/anti-cheat-demo/main.cpp:87-155 (the live runner path); examples/README.md ("SUMMARY: 33 passed, 0 failed").

Distributed / shared authority

For games where authority over an object should move between peers (e.g. whoever grabbed the ball owns it), Lattice has a second first-class model: shared / distributed authority, which coexists per-object with server-authoritative objects in the same session. A shared object's state authority can be held by a specific client, which simulates it and broadcasts its state while every other peer converges.

The API:

Shared-authority surface (reference/include/lattice/lattice.h:710-719)
lattice_result lattice_request_authority(lattice_runner* r, lattice_netid id);
uint64_t       lattice_object_owner(lattice_runner* r, lattice_netid id);          /* current holder */
uint32_t       lattice_object_authority_tick(lattice_runner* r, lattice_netid id); /* token claim counter */

Verified against: reference/include/lattice/lattice.h:687-719.

Which peer am I?

Comparing lattice_object_owner() against your own id needs lattice_runner_local_peer_id() — an authority is peer 0 (not 1), and a plaintext client honestly reports LATTICE_LOCAL_PEER_UNKNOWN rather than guessing. See Peer identity & local player.

lattice_request_authority is sent reliably to the host arbiter (the SHARED_HOST runner), which mints a new ownership token (a tick strictly higher than anything in flight) and broadcasts the grant. Once observed, on_authority_changed fires on every peer the tick it adopts the new token — the granting host, the new owner, and every other peer — so game code can start/stop simulating the object locally:

on_authority_changed signature (reference/include/lattice/lattice.h:321-322)
void (*on_authority_changed)(void* user_data, lattice_netid id, uint64_t new_owner,
                             uint32_t authority_tick);
React to an authority grant
static void on_authority_changed(void* user, lattice_netid id, uint64_t new_owner, uint32_t tick) {
    // new_owner now holds state authority over `id`, as of `tick`.
    // Start simulating it locally if new_owner == our peer id; stop if we just lost it.
}

Verified against: reference/include/lattice/lattice.h:313-322 (fires on every peer; never fires for a server/host-authoritative object); :565-573 (the request → mint → broadcast flow). Conflicts resolve by last-writer-wins with a tick tiebreak (higher tick wins; equal ticks → lower owner id wins), which is deterministic.
Verified against: reference/include/lattice/lattice.h:697-698.

This path is intended for LATTICE_MODE_SHARED_HOST sessions.

Real ABI, but not exercised end-to-end by the runnable drivers

lattice_request_authority / on_authority_changed / lattice_object_owner / lattice_object_authority_tick are genuine ABI functions with the documented semantics above. The two runnable two-process drivers (persistent_server.cpp, two_process.cpp) are single-authority (server/host), so they do not perform an authority transfer, and in the reference skeleton SHARED_HOST behaves like HOST. Design against this surface for shared-authority games and test the transfer in your own integration.
Verified against: reference/include/lattice/lattice.h:81 (SHARED_HOST "skeleton: like HOST"); absence of any lattice_request_authority call in reference/tests/two_process.cpp and reference/tests/persistent_server.cpp.


Next: 09 — Full walkthrough, where a minimal server + client come together end to end.