Peer identity & local player new¶
Every replicated object carries an owner — a peer id. To answer the question every game asks
constantly, "is this object mine?", an endpoint has to know its own peer id. This page is about
that one value, why it cannot be guessed, and what to do when the core honestly does not know it.
The ABI¶
#define LATTICE_LOCAL_PEER_UNKNOWN 0xFFFFFFFFFFFFFFFFull
LATTICE_API uint64_t lattice_runner_local_peer_id(lattice_runner* r);
The id of this runner's local participant — the value the core puts in owner for objects this
endpoint owns.
| Role | lattice_runner_local_peer_id() |
|---|---|
Authority — SERVER / HOST / SHARED_HOST |
0 |
| Client, secure handshake | The id the authority assigned it |
| Client, plaintext handshake | LATTICE_LOCAL_PEER_UNKNOWN |
| NULL runner, or not yet started | LATTICE_LOCAL_PEER_UNKNOWN |
Verified against: reference/include/lattice/lattice.h:381-402,
reference/src/runner.cpp:418-431 (Runner::local_peer_id); conformance W.19a ("the
authority's local participant id is 0 (not 1)"), W.19b ("a plaintext client reports UNKNOWN
instead of guessing an id"), W.4 ("the client learned the peer id the authority assigned it").
The authority is 0, not 1¶
This is the part that is not guessable from the outside, and bindings used to get it wrong in both directions.
0is the host's local participant. It is the owner valuelattice_request_authority()stamps when the host claims an object for itself.1is how a client addresses the server — a routing destination, not the host's identity.
A binding that assumed is_server() ? 1 : 0 was therefore wrong twice over: wrong for the host, and
wrong for the client. Both bindings now read the value from the core instead.
Godot upgraders: the host's local_player changed from 1 to 0
Earlier Godot builds guessed is_server() ? 1 : 0, so runner.local_player reported 1 on a
host. It now reports 0, from the core. Anything that hardcoded 1, compared against the old
value, or stored it needs updating — most commonly a spawn(scene, 1) that should now be
spawn(scene, runner.local_player).
Host-owned objects have owner 0. The headless conformance harness has always spawned them that
way — it was the bindings that disagreed.
Verified against: reference/src/runner.cpp:1568 (grant_authority(id, /*new_owner=*/0)
— "0 == the host's local participant"), samples/godot-tictactoe/harness/ttt_harness.cpp:411,
conformance W.19a.
A client is not always told its id¶
Only the secure handshake carries an id assignment. The legacy plaintext handshake does not — and it was deliberately left byte-identical, so it cannot be extended without breaking wire compatibility with peers that predate the secure channel.
So a plaintext client genuinely has no id, and the core says so rather than inventing one:
LATTICE_LOCAL_PEER_UNKNOWN is an honest "not known" that a binding can branch on, instead of a
plausible-looking wrong number — conformance W.19b, "a plaintext client reports UNKNOWN instead
of guessing an id".
flowchart TD
A[lattice_runner_local_peer_id] --> B{Runner started?}
B -->|no| U[UNKNOWN]
B -->|yes| C{Authority?}
C -->|SERVER / HOST / SHARED_HOST| Z[0]
C -->|CLIENT| D{Secure handshake?}
D -->|yes, completed| E[assigned id]
D -->|plaintext, or still handshaking| U
Why UNKNOWN must never collapse to 0¶
Because the host is peer 0, an endpoint that reports 0 when it does not know its id claims to
be the host. Feed that into an ownership test and a plaintext client matches every host-owned
object — it believes it has authority over the entire server-owned world.
Both bindings therefore fail closed: with an unknown id, an ownership check answers false
rather than guessing. The asymmetry is the whole argument — a wrong true claims authority over
objects you do not own, while a wrong false merely declines to act locally.
Two mechanisms, one outcome
Unity fails closed by value: LocalPlayer holds the UNKNOWN sentinel, which cannot equal
any real owner, so HasInputAuthority is simply never true.
Godot fails closed by branch: has_authority() checks local_player >= 0 and skips the
comparison entirely, falling through to is_server().
Verified against: bindings/lattice-unity/com.lattice.netcode/Runtime/NetworkObject.cs:68,
bindings/lattice-godot/src/lattice_sync.cpp:177-187.
Across the bindings¶
The concept is identical; the spelling and the sentinel's surface differ, because each binding uses its language's idiomatic "no such id".
| C ABI | Unity (C#) | Godot (GDScript) | |
|---|---|---|---|
| Read it | lattice_runner_local_peer_id(r) |
runner.LocalPlayer (ulong) |
runner.local_player (int) |
| Raw core answer | — | runner.LocalPeerId |
runner.local_player |
| Unknown reads as | LATTICE_LOCAL_PEER_UNKNOWN( 0xFFFF…FFFF) |
LatticeNative.LocalPeerUnknown(same value) |
LatticeRunner.LOCAL_PLAYER_UNKNOWN( -1) |
| Is it known? | id != LATTICE_LOCAL_PEER_UNKNOWN |
runner.IsLocalPlayerKnown |
runner.local_player >= 0 |
| Override | — | assign LocalPlayer (pins) |
set_local_player(id) (guarded) |
| Freshness | live call | LocalPlayer cached (refreshed at StartGame and each Tick); LocalPeerId live |
live read on every access |
-1 and 0xFFFF…FFFF are the same bits
Godot's -1 is not a different sentinel — it is LATTICE_LOCAL_PEER_UNKNOWN reinterpreted as a
signed 64-bit value, which is exactly -1. GDScript ints are signed, and -1 is Godot's
conventional "no such id", so the binding surfaces it that way rather than as an 18-quintillion
magic number.
Checking before you use it¶
The escape hatch, and its limits¶
Both bindings let you supply an id for the one case the core cannot answer — a plaintext client whose own lobby or matchmaking layer already knows which id the authority assigned. They differ in how firmly they hold it, and the difference matters if you are writing cross-engine code:
- Unity — assignment pins. Setting
LocalPlayermarks it pinned and the core will not overwrite it, even once the core does learn the real id.LocalPeerIdstill exposes the core's raw answer so you can compare the two. - Godot — assignment is refused when the core knows better.
set_local_playeris honoured only while the core reports UNKNOWN; once the core has a real id the call is ignored with a warning, so an override can fill a hole but never contradict the authority.
Neither is wrong — Unity's favours "the application is in charge", Godot's favours "the authority is in charge" — but do not assume one behaves like the other.
Do not pin what the core can answer
The escape hatch is for the plaintext-client hole, not for authorities. The Unity tic-tac-toe
sample used to pin its host to 1 before StartGame; that disagreed with the 0 the core
stamps as owner, and would have broken the first time the host called RequestAuthority. It
now assigns nothing and reads the id from the core.
Verified against: samples/unity-tictactoe/Assets/TicTacToe/Scripts/TicTacToeGame.cs:43-53,
samples/unity-tictactoe/headless/MatchHarness.cs:70-72; the sample's own checks
"host is participant 0 (core convention)" and "board is owned by the host's real participant id".
Verified against: NetworkRunner.cs:60-96, bindings/lattice-godot/src/lattice_runner.cpp:376-385.
Closing the plaintext gap¶
Three ways, in descending order of how much you should like them:
- Turn on the secure channel. The handshake assigns the id, so
the question disappears. This is the real fix, and every binding can now do it —
lattice_runner_configure_securein C,runner.ConfigureSecure(...)in Unity,runner.configure_secure(...)in Godot. - Supply the id yourself via the escape hatch above, if your lobby already knows it.
- Design around not knowing. A client that never needs to answer "is this mine?" locally — one that acts only on server-confirmed state — does not need the id at all.
The gap is a property of the plaintext handshake, not of the bindings: it disappears with no binding change if that handshake ever gains an id assignment.
Next steps¶
- Authority & ownership — what
ownermeans and how it transfers. - Secure datagram channel — the handshake that assigns client ids.
- Runner lifecycle & pump — where the local participant comes into existence.