08 — Authority¶
Authority is who gets to decide. Lattice distinguishes two kinds:
- State authority — the peer that owns and writes the simulation. In
MODE_SERVER/MODE_HOSTthis is the server; it's the only peer that mayspawnand whose writes to a replicated object win. - Input authority — the peer whose input drives a particular object (its
owner). A client can own its own avatar without holding state authority over the world.
Verified against: bindings/lattice-godot/src/lattice_sync.cpp:173-206,
bindings/lattice-godot/src/lattice_runner.cpp:363-365.
Reading authority off a LatticeSync¶
sync.has_authority() # true if the local peer owns this object OR is the server/host
sync.get_input_authority() # the peer id that currently owns this object
runner.is_server() # true for MODE_SERVER / MODE_HOST / MODE_SHARED_HOST
has_authority() returns true when the object's current owner is the local player or when the local
runner is the server/host — and it declines to answer on a guess:
bool LatticeSync::has_authority() const {
if (!runner_ || !runner_->_core()) return false;
if (netid_) {
int64_t local = runner_->get_local_player();
if (local >= 0) { // ← unknown id: do not compare
uint64_t owner = lgodot::glue_object_owner(runner_->_core(), netid_);
if (owner == (uint64_t)local) return true;
}
}
return runner_->is_server();
}
Verified against: lattice_sync.cpp:173-189; get_input_authority at lattice_sync.cpp:199-206
(returns lattice_object_owner).
On a plaintext client, has_authority() fails closed
local_player is LOCAL_PLAYER_UNKNOWN (-1) for a client on the legacy plaintext handshake —
only the secure handshake tells a client which id the authority assigned it. Rather than compare
against a guess, has_authority() skips the ownership test entirely and falls through to
is_server(), so such a client reports false for every object.
That is deliberate. The alternative is worse: the binding used to assume 0 for a client, and
since the host is peer 0, that assumption would have made a client claim authority over
every host-owned object. Turn on the secure channel, or set the id yourself with
set_local_player, to get real ownership answers on a client
(chapter 02, and
Peer identity & local player for the cross-binding picture).
Verified against: lattice_sync.cpp:179-186,
bindings/lattice-godot/thirdparty/lattice/lattice.h:387-397.
Host-authoritative validation (the sample's model)¶
The most important authority pattern — and the one the sample verifies end to end — is server-authoritative validation: clients request changes; the authority validates and applies them; illegal requests change nothing.
In tic-tac-toe the host is the sole writer of the board. A client never writes the board directly — it
sends the "place" RPC (chapter 06), and the host decides:
func request_move(cell: int, mark: int) -> void:
if sync.has_authority():
_apply_move(cell, mark) # host applies its own move directly
else:
sync.send_rpc("place", [cell], LatticeSync.STATE_AUTHORITY) # client asks the host
Verified against: board.gd:74-78.
The host's authoritative apply validates against the pure rules, and only on a legal move writes the
cell and flips the turn or sets the winner. Because the cells are exported properties tagged for
replication, the writes are picked up and replicated automatically — no manual mark_dirty:
func _apply_move(cell: int, mark: int) -> void:
var cells := get_cells()
if not TttRules.is_legal_move(cells, turn, winner, cell, mark):
return # authority rejects: board unchanged, nothing replicated
_set_cell(cell, mark)
cells = get_cells()
var w := TttRules.check_winner(cells)
if w != TttRules.NONE:
winner = w
else:
turn = TttRules.other_mark(turn)
board_changed.emit()
Verified against: board.gd:90-101; the rules at ttt_rules.gd:29-46
(is_legal_move / check_winner).
The place RPC handler itself re-checks authority before applying, so a stray call on a non-authority
peer is a no-op:
func place(cell: int) -> void:
if not sync.has_authority():
return
_apply_move(cell, TttRules.MARK_O)
Verified against: board.gd:82-85.
This is verified
The headless harness asserts the authority rejects an illegal move (wrong turn / occupied cell /
after game over), leaving the board unchanged on both peers, while legal moves replicate — and that
full games reach a win and a draw, detected and replicated. This is the core guarantee of a
server-authoritative game.
Verified against: samples/godot-tictactoe/README.md ("an illegal move is rejected by the
authority (board unchanged, nothing sent)"; "a full game reaches a WIN … a second game reaches a
DRAW").
The recipe¶
- Client sends a request (RPC or event to
STATE_AUTHORITY). - Authority validates against its rules (using the trusted
senderfor events, or a fixed seat for this 2-player RPC). - On success: write the replicated exported property → it replicates. On failure: change nothing.
- Clients only ever read replicated state and render it.
Distributed / shared authority¶
For games where authority over an object should move between peers, Lattice has a shared / distributed authority model. The Godot surface:
sync.request_authority() # ask the host arbiter for state authority over this object
runner.authority_changed # signal: (netid, new_owner, authority_tick)
request_authority() forwards to the core's real lattice_request_authority; it warns (and does
nothing) if the sync isn't bound to a spawned object yet:
void LatticeSync::request_authority() {
if (!runner_ || !runner_->_core() || !netid_) {
UtilityFunctions::push_warning("LatticeSync.request_authority: not bound to a spawned object");
return;
}
lgodot::glue_request_authority(runner_->_core(), netid_); // -> lattice_request_authority
}
Verified against: lattice_sync.cpp:190-198; lattice_glue.cpp:228-230 (glue_request_authority
forwards 1:1 to lattice_request_authority).
When the grant is observed, authority_changed fires on every peer with the new owner and the
authority tick:
runner.authority_changed.connect(func(netid, new_owner, authority_tick):
# new_owner now holds state authority over netid, as of authority_tick
pass)
Verified against: lattice_runner.cpp:76-79 (the signal), lattice_runner.cpp:581-584
(_on_authority_changed emits it).
Real ABI, but not exercised by the sample or headless test
request_authority / authority_changed / get_input_authority are genuine wrappers over the
core's lattice_request_authority / on_authority_changed / lattice_object_owner. The
tic-tac-toe sample is single-authority (host-only), so it does not call request_authority, and
the harness verifies spawn/replicate/RPC/event/validation but not an authority transfer. The API
surface and its documented semantics are accurate; end-to-end transfer isn't demonstrated in this
tutorial's verified sources. Also note the reference skeleton treats MODE_SHARED_HOST like HOST.
Design against this API for shared-authority games, and test the transfer in your own integration.
Verified against: bindings/lattice-godot/README.md:189-201 ("ABI evolution — Ownership
transfer #11"); thirdparty/lattice/lattice.h:81 (SHARED_HOST … skeleton: like HOST).
Honesty note: get_input() is a stub¶
LatticeSync.get_input() is the fixed-tick input accessor a predicted/owned object's _simulated_tick
would read (the demo/player.gd pattern). Today it returns an empty Dictionary — the production
input channel arrives with the prediction catch-up — so the _simulated_tick input-driven movement in
the demo compiles and runs but reads no input:
Dictionary LatticeSync::get_input() const {
/* Input buffering rides the prediction ABI (#7 test hooks today; production
* input channel in the catch-up). Return an empty dict until then. */
return Dictionary();
}
Verified against: lattice_sync.cpp:208-213; lattice_sync.h:107-108. The demo/player.gd
_simulated_tick guards on if input and input.has("move"), which is simply false until the channel
lands.
Verified against: bindings/lattice-godot/demo/player.gd:24-32.
Next: 09 — Full walkthrough, where all of this comes together into the complete game.