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 MODE_SERVER / MODE_HOST this is the server; it's the only peer that may spawn and 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:

has_authority — native side
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:

board.gd — request routes by authority
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:

board.gd — the authority's decision
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:

board.gd — the RPC handler is authority-guarded
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

  1. Client sends a request (RPC or event to STATE_AUTHORITY).
  2. Authority validates against its rules (using the trusted sender for events, or a fixed seat for this 2-player RPC).
  3. On success: write the replicated exported property → it replicates. On failure: change nothing.
  4. 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:

request_authority — native side
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:

get_input — native side (stub)
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.