Skip to content

05 — Networked objects

A networked object is a piece of replicated state the authority owns and every peer sees. You:

  1. register a type (its state layout + which fields replicate),
  2. spawn an instance on the authority, and
  3. read replicated changes on every peer via OnSpawned / OnStateUpdated.

The tic-tac-toe sample has exactly one networked object — the board — so it's a clean, complete example.

Verified against: samples/unity-tictactoe/Assets/TicTacToe/Scripts/TicTacToeProtocol.cs, TicTacToeSession.cs, and bindings/lattice-unity/com.lattice.netcode/Runtime/NetworkObject.cs, NetworkTypeSchema.cs.

The replicated state block

Replicated state is a blittable struct with [StructLayout(LayoutKind.Sequential)]. It maps 1:1 onto the core's state block, which the binding reads and writes in place. The board is nine cells + whose turn + the result:

BoardState — the [Networked] state of the board
[StructLayout(LayoutKind.Sequential)]
public struct BoardState
{
    public int cell0, cell1, cell2;
    public int cell3, cell4, cell5;
    public int cell6, cell7, cell8;
    public int turn;     // Mark whose move it is (X=1)
    public int result;   // GameResult
}

Verified against: TicTacToeProtocol.cs:19-27.

Field width gotcha: RANGED_INT fields must be 4-byte int, not byte

Each board field replicates as a RANGED_INT. The core reads and writes a RANGED_INT as a 4-byte int at the field's offset, so the struct fields must be declared int (4-byte, aligned at offsets 0, 4, 8, …). A byte-backed layout silently corrupts replication because the 4-byte writes overlap adjacent fields. On the wire each value still costs only ~2 bits — the int storage is purely to match the ABI's field-access width. This was caught and fixed during the sample's headless bring-up.
Verified against: TicTacToeProtocol.cs:11-18 and samples/unity-tictactoe/README.md ("Note on the [Networked] field width").

Registering the type

You describe each replicated leaf field with a FieldSpec, then call RegisterType. Offsets come from Marshal.OffsetOf, so the schema matches the struct layout exactly.

RegisterTypes — from the sample
public NetworkType RegisterTypes()
{
    var fields = new List<FieldSpec>(11);

    // Nine cells, each a ranged int 0..2 (Empty/X/O). Field order defines the MarkDirty index.
    for (int i = 0; i < 9; i++)
        fields.Add(FieldSpec.Of<BoardState>($"cell{i}", LatticeFieldKind.RangedInt, $"cell{i}")
                            .WithRange(0, 2));

    fields.Add(FieldSpec.Of<BoardState>("turn",   LatticeFieldKind.RangedInt, nameof(BoardState.turn)).WithRange(0, 2));
    fields.Add(FieldSpec.Of<BoardState>("result", LatticeFieldKind.RangedInt, nameof(BoardState.result)).WithRange(0, 3));

    _boardType = _runner.RegisterType(
        "TicTacToeBoard",
        (uint)Marshal.SizeOf<BoardState>(),
        fields);
    return _boardType;
}

Verified against: TicTacToeSession.cs:66-84 (adapted for brevity; the sample lists the cell names explicitly).

FieldSpec

FieldSpec.Of<TBlock>(name, kind, fieldName) captures the field's offset from the struct; fluent With… methods add per-kind detail:

Method For
.WithRange(min, max) RangedInt
.WithQuant(min, max, precision) CompressedFloat / Vector*
.WithQuatBits(bits) Quaternion (smallest-three)
.WithCapacity(bytes) String / Bytes
.WithStructType(id) nested Struct

Verified against: NetworkTypeSchema.cs:19-51.

The field kinds

LatticeFieldKind: Bool, Int32, Int64, Float, Double, RangedInt, CompressedFloat, Vector2, Vector3, Vector4, Quaternion, String, Bytes, Struct.

Verified against: LatticeNative.cs:47-63.

What RegisterType returns

A NetworkType carries its Id, its ContentHash, and its Key. Both must match across peers, so the two ends agree on the wire format.

NetworkType
public sealed class NetworkType : IDisposable
{
    public uint   Id { get; }
    public uint   ContentHash { get; }
    public string Key { get; }
}

Verified against: NetworkTypeSchema.cs:59-63.

Register the same types, in the same order, on every peer — before connecting

Type ids and content hashes are derived from the registration. If host and client disagree, replication breaks. The sample calls RegisterTypes() on both peers before StartGame/Connect; the headless harness asserts HostBoardType.Id == ClientBoardType.Id and the content hashes match.
Verified against: samples/unity-tictactoe/headless/MatchHarness.cs:66-68; headless/Program.cs:61-63.

Spawning (authority only)

Only an authority (Server / Host / SharedHost) may spawn. Spawn<T> takes the type, an initial state struct, and an owner id, and returns the managed NetworkObject:

HostStartMatch — spawn the board (from the sample)
public NetworkObject HostStartMatch()
{
    if (!IsHost) throw new InvalidOperationException("Only the authority spawns the board.");
    var init = new BoardState { turn = (byte)Mark.X, result = (byte)GameResult.InProgress };
    _board = _runner.Spawn(_boardType, init, owner: _runner.LocalPlayer);
    // …
    return _board;
}

Verified against: TicTacToeSession.cs:90-99.

NetworkRunner.Spawn
public NetworkObject Spawn<T>(NetworkType type, in T initialState, ulong owner) where T : struct;

Returns null-free: a non-authority spawn throws LatticeException(ErrNotAuthority).

Verified against: NetworkRunner.cs:277-299.

Reading and writing state

A NetworkObject exposes the whole state block as a struct:

BoardState s = obj.ReadState<BoardState>();   // read the current block
// … mutate s on the authority …
obj.WriteState(s);                            // write it back
obj.MarkDirty(BoardField.Turn);               // flag a changed field for replication

Verified against: NetworkObject.cs:100-124.

MarkDirty(fieldIndex) tells the authority to replicate that field on the next tick. The field index is the field's position in the registration order. The sample keeps them as named constants:

BoardField — field indices matching schema order
public static class BoardField
{
    public const uint Cell0 = 0, /* … */ Cell8 = 8, Turn = 9, Result = 10;
    public static uint ForCell(int cell) => (uint)cell;
}

Verified against: TicTacToeProtocol.cs:57-64; used at TicTacToeSession.cs:159-161.

Only the authority writes

WriteState + MarkDirty are authority-side operations. Clients read replicated state in their callbacks and never write the block directly — they ask the authority to change things via RPCs (chapter 06) or events (chapter 07).

Reacting to replication

Every peer learns about objects through the runner's callbacks. The sample subscribes in its session constructor:

Subscribe to spawn / update (from the sample)
_runner.OnSpawned      += HandleSpawned;
_runner.OnStateUpdated += HandleStateUpdated;
Handlers
private void HandleSpawned(NetworkObject obj)
{
    if (_boardType == null || obj.TypeId != _boardType.Id) return;   // filter by type
    _board = obj;
    OnBoardChanged?.Invoke(obj.ReadState<BoardState>());
}

private void HandleStateUpdated(NetworkObject obj)
{
    if (obj == null || _board == null || obj.NetId != _board.NetId) return;
    OnBoardChanged?.Invoke(obj.ReadState<BoardState>());
}

Verified against: TicTacToeSession.cs:56-60, TicTacToeSession.cs:180-191.

  • OnSpawned fires on a peer when an object first replicates in. Filter by TypeId (and, if you have many, track by NetId).
  • OnStateUpdated fires when a replicated field changed. Re-read the block and refresh your view.

NetworkObject identity/authority members you'll use:

Member Meaning
NetId / Id The replicated id
TypeId Which registered type
Owner / InputAuthority The participant that owns it
HasInputAuthority Owner == runner.LocalPlayer
HasStateAuthority The local runner is server/host
IsValid() It carries a netid (spawned)

Verified against: NetworkObject.cs:26-78.

Finding objects

The runner keeps an object table. Objects is the live dictionary; Find(netid) (and the Fusion-compat FindObject / TryFindObject) look one up.

Verified against: NetworkRunner.cs:101, NetworkRunner.cs:306, NetworkRunner.Simulation.cs:125-132.

Despawning

Authority-only. Despawn(netid) or Despawn(obj) removes it; every peer sees OnDespawned(netid).

Verified against: NetworkRunner.cs:301-304.


A note on the [Networked] attribute

The binding ships [Networked], [Rpc], and [OnChanged] attributes. In a full Unity install a Roslyn source generator consumes [Networked] auto-properties and emits the schema for you, so you'd write:

The attribute form (editor-time codegen; see honesty note)
public sealed class Board : NetworkBehaviour
{
    [Networked(RangedMin = 0, RangedMax = 2)] public int Turn { get; set; }
}

Verified against: bindings/lattice-unity/com.lattice.netcode/Runtime/Attributes.cs:18-41 (NetworkedAttribute).

This tutorial teaches the runtime RegisterType path, which is what the sample uses

The [Networked] source generator is an editor/compile-time artefact; the headless build and the tic-tac-toe sample exercise the runtime schema path (FieldSpec + RegisterType) the generator ultimately targets. Because we can't run the Unity editor here, everything in this tutorial is grounded in the runtime path — which is fully verified. If you use the attribute form in a real project, the underlying registration is identical.
Verified against: Attributes.cs:1-8 (generator is "an editor-time build artefact"); bindings/lattice-unity/README.md ("Needs the Unity editor — [Networked] source generator").


Next: 06 — RPCs.