Skip to content

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.

  • 0 is the host's local participant. It is the owner value lattice_request_authority() stamps when the host claims an object for itself.
  • 1 is 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

if (!runner.IsLocalPlayerKnown)
    return;                     // plaintext client: no id to compare against
if (obj.Owner == runner.LocalPlayer) { /* mine */ }
if runner.local_player < 0:
    return                      # plaintext client: no id to compare against
if sync.has_authority():
    pass                        # already fails closed for you
uint64_t me = lattice_runner_local_peer_id(r);
if (me == LATTICE_LOCAL_PEER_UNKNOWN) return;
if (lattice_object_owner(r, id) == me) { /* mine */ }

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 LocalPlayer marks it pinned and the core will not overwrite it, even once the core does learn the real id. LocalPeerId still exposes the core's raw answer so you can compare the two.
  • Godot — assignment is refused when the core knows better. set_local_player is 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:

  1. 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_secure in C, runner.ConfigureSecure(...) in Unity, runner.configure_secure(...) in Godot.
  2. Supply the id yourself via the escape hatch above, if your lobby already knows it.
  3. 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