02 — Your first runner¶
The NetworkRunner is the single object you drive everything through. It owns the native runner
handle, marshals native callbacks into managed C# events, and exposes StartGame / Connect /
Tick / Spawn / Rpc / SendEvent.
Verified against: bindings/lattice-unity/com.lattice.netcode/Runtime/NetworkRunner.cs.
The runner is a MonoBehaviour in Unity¶
In the editor, NetworkRunner is a MonoBehaviour. It drives its own Tick from
FixedUpdate on the main thread, so every callback lands on the main thread and it is safe to touch
Transform / GameObject from your handlers.
public sealed partial class NetworkRunner :
#if UNITY_5_3_OR_NEWER
UnityEngine.MonoBehaviour,
#endif
IDisposable
{
private void FixedUpdate()
{
if (_runner != IntPtr.Zero)
Tick(UnityEngine.Time.fixedDeltaTime);
}
private void OnDestroy() => Dispose();
}
Verified against: NetworkRunner.cs:17-22 and NetworkRunner.cs:390-396.
Because it's a component, you create it with AddComponent<NetworkRunner>() — not new — in
the editor. (The headless build uses the plain new NetworkRunner(tickRateHz) constructor instead,
which is why both exist.)
Verified against: NetworkRunner.cs:109-116; sample usage at
samples/unity-tictactoe/Assets/TicTacToe/Scripts/TicTacToeGame.cs:65-77.
The four game modes¶
StartGame takes a LatticeGameMode:
| Mode | Value | Meaning |
|---|---|---|
Server |
0 | Dedicated authoritative server (not a player) |
Host |
1 | Server that is also a local participant (listen-server) |
Client |
2 | Joins a server; no authority |
SharedHost |
3 | Shared / distributed authority |
Verified against: bindings/lattice-unity/com.lattice.netcode/Runtime/Interop/LatticeNative.cs:31-37.
IsAuthority is true for Server, Host, and SharedHost — those roles own the simulation and
may Spawn. A Client cannot spawn.
Verified against: NetworkRunner.cs:98-99.
Starting a host¶
A host is the authoritative server and a local player. StartGame(Host, port) starts the
session and, because a host is an authority, begins listening on port in one call.
public void StartHost()
{
BuildRunner();
_session.RegisterTypes(); // register networked types FIRST (chapter 05)
// No LocalPlayer assignment: StartGame reads the host's participant id from the core.
_runner.StartGame(LatticeGameMode.Host, port); // Host = server + local participant; begins listening
_session.HostStartMatch(); // spawn the world (chapter 05)
_started = true;
}
Verified against: TicTacToeGame.cs:43-53.
StartGame only auto-listens when the role is an authority and port != 0:
public void StartGame(LatticeGameMode mode, ushort port = 0)
{
#if UNITY_5_3_OR_NEWER
if (_runner == IntPtr.Zero) Initialize(60, 0);
#endif
Mode = mode;
_started = true;
LatticeException.ThrowIfError(LatticeNative.lattice_runner_start(_runner, mode), "start");
// An authority knows its participant id (0) the moment it starts -- no tick needed.
RefreshLocalPlayer();
if (IsAuthority && port != 0)
Listen(port);
}
Verified against: NetworkRunner.cs:213-225.
Don't set LocalPlayer on a host — earlier copies of this sample did
Notice there is no LocalPlayer assignment above. The runner reads its own participant id
from the core (lattice_runner_local_peer_id), and for any authority role that id is 0.
Older copies of this sample carried a _runner.LocalPlayer = 1; line here. If you have one,
delete it — it was wrong twice over:
- The host's participant id is
0, not1.1is how a client addresses the server, not the host's own identity, so the pinned value disagreed with theownerthe core stamps. - Assigning
LocalPlayerpins it, so the runner would never adopt the correct id the core reports — including the real id a client is assigned during the secure handshake.
Full semantics — including what a client reads before the authority has told it its id — are
on Peer identity & local player.
Verified against: NetworkRunner.cs:60-96; conformance W.19a ("the authority's local
participant id is 0 (not 1)"); the corrected sample at
samples/unity-tictactoe/Assets/TicTacToe/Scripts/TicTacToeGame.cs:43-53, whose headless checks
now assert "host is participant 0 (core convention)".
Starting a client and connecting¶
A client starts in Client mode (no port — it isn't listening) and then Connects to the
server's address and port. Connect takes an optional connection token (a byte[]).
public void StartClient(string address = "127.0.0.1")
{
BuildRunner();
_session.RegisterTypes(); // SAME types, SAME order as the host
_runner.StartGame(LatticeGameMode.Client); // no port: a client does not listen
_runner.Connect(address, port, System.Text.Encoding.ASCII.GetBytes("ttt"));
_started = true;
}
Verified against: TicTacToeGame.cs:56-63.
public void Connect(string address, ushort port, byte[] token = null)
{
token ??= Array.Empty<byte>();
LatticeException.ThrowIfError(
LatticeNative.lattice_runner_connect(_runner, address, port, token, (uint)token.Length),
$"connect({address}:{port})");
}
Verified against: NetworkRunner.cs:230-236.
The token here is arbitrary
In the sample the token is just the bytes "ttt" — the loopback/local server does not validate
it. In a managed deployment the token would be the director-minted session token you get
from /resolve (see chapter 04). The binding forwards whatever
bytes you pass to the native lattice_runner_connect.
Wiring up the runner¶
The sample builds the runner as a sibling component so its FixedUpdate pumps the tick
automatically — you never call Tick yourself in the editor:
private void BuildRunner()
{
// NetworkRunner is itself a MonoBehaviour; add it as a sibling component so its
// FixedUpdate drives Tick on the main thread (no manual pumping needed here).
_runner = gameObject.AddComponent<NetworkRunner>();
_session = new TicTacToeSession(_runner);
// Subscribe to gameplay events (chapters 05–08).
_session.OnBoardChanged += b => _board = b;
_session.OnMarkAssigned += m => _myMark = m;
_session.OnChat += (mark, text) => _chatLog.Add($"{MarkGlyph(mark)}: {text}");
}
Verified against: TicTacToeGame.cs:65-77.
The tick pump¶
Tick(double dt) pumps one or more fixed steps — recv → simulate → send — and fires every
callback synchronously inside the call. In Unity, FixedUpdate calls it for you with
Time.fixedDeltaTime; in a headless/console app you call it in your own loop.
public void Tick(double dt)
{
LatticeException.ThrowIfError(LatticeNative.lattice_runner_tick(_runner, dt), "tick");
RefreshLocalPlayer(); // a client learns its participant id mid-handshake
AdvanceSimulationClock(dt); // advances CurrentTick / SimulationTime / LocalAlpha
}
Verified against: NetworkRunner.cs:240-250.
Because callbacks fire inside Tick, and Tick runs on the main thread in Unity, your
OnSpawned / OnStateUpdated / OnEvent handlers can safely instantiate prefabs, move transforms,
and update UI directly.
The runner's events¶
Subscribe to these to react to the network. All fire inside Tick.
| Event | Signature | Fires when |
|---|---|---|
OnConnected |
Action |
The connection is established |
OnDisconnected |
Action<int> |
Disconnected (arg = reason code) |
OnSpawned |
Action<NetworkObject> |
A replicated object appeared |
OnDespawned |
Action<ulong> |
An object (by netid) was removed |
OnStateUpdated |
Action<NetworkObject> |
A replicated object's state changed |
OnRpc |
Action<ulong, ushort, byte[]> |
An RPC arrived (netid, rpcId, payload) |
OnEvent |
Action<ushort, ulong, ulong, byte[]> |
A custom event arrived (eventId, netid, sender, payload) |
OnAuthorityChanged |
Action<ulong, ulong, uint> |
State authority changed (netid, newOwner, authorityTick) |
OnLog |
Action<LatticeLogLevel, string> |
The core logged a line |
Verified against: NetworkRunner.cs:24-38.
Connection state¶
Poll State any time to see where the connection is:
LatticeConnectionState is Disconnected, Connecting, Connected, Disconnecting.
Verified against: NetworkRunner.cs:252,
LatticeNative.cs:39-45. The Fusion-compat helpers IsRunning, IsConnectedToServer, and
IsServer derive from this and Mode.
Verified against: NetworkRunner.Simulation.cs:95-101.
Tearing down¶
Dispose() frees the native runner and clears the object table. In Unity, OnDestroy calls it for
you, so destroying the GameObject cleans up. Shutdown() is an async-friendly alias that returns
a completed Task.
Verified against: NetworkRunner.cs:372-385, NetworkRunner.cs:396.
The minimal shape¶
Putting it together, here is the smallest useful runner setup, host and client:
using Lattice;
using Lattice.Interop;
using UnityEngine;
public sealed class MyNet : MonoBehaviour
{
public ushort port = 7777;
private NetworkRunner _runner;
public void Host()
{
_runner = gameObject.AddComponent<NetworkRunner>();
_runner.OnConnected += () => Debug.Log("a client connected");
// No LocalPlayer assignment: the runner reads its own id from the core (it is 0 on a host).
// RegisterTypes() here — see chapter 05
_runner.StartGame(LatticeGameMode.Host, port); // starts + listens
}
public void Join(string address)
{
_runner = gameObject.AddComponent<NetworkRunner>();
_runner.OnConnected += () => Debug.Log("connected to host");
// RegisterTypes() here — SAME order as the host
_runner.StartGame(LatticeGameMode.Client);
_runner.Connect(address, port, token: null);
}
// FixedUpdate on the runner pumps Tick automatically.
}
Register types before you connect
Both peers must register the same networked types in the same order before the client
connects, so type ids and content hashes agree. The sample calls RegisterTypes() inside
StartHost / StartClient before StartGame/Connect. This is covered in
chapter 05.
Verified against: TicTacToeGame.cs:46,59; TicTacToeSession.cs:62-84.
Next: 03 — Logging in, to get a real player identity from the auth service.