Skip to content

07 — Events & presence

Custom events are the object-less sibling of an RPC — a small tagged message you send to other peers, where the handler learns who sent it. This is the channel the tic-tac-toe sample uses for its moves, chat, join requests, and mark assignment.

Verified against: bindings/lattice-unity/com.lattice.netcode/Runtime/NetworkRunner.cs:324-347, NetworkObject.cs:137-147, LatticeNative.cs:73-83,299-302.

Sending events

There are three send shapes on the runner, plus an object-scoped one:

The event send API
// Global / session-scoped (no object). Target fans out per the routing policy.
public void SendEvent(ushort eventId, LatticeEventTarget target, byte[] payload, bool reliable = true);

// Scoped to a specific object; its netid drives OWNER / ALL_BUT_OWNER routing like an object RPC.
public void SendObjectEvent(ushort eventId, LatticeEventTarget target, ulong netid, byte[] payload, bool reliable = true);

// Directed to exactly one connection (LATTICE_EVENT_PEER).
public void SendEventToPeer(ushort eventId, ulong peer, byte[] payload, bool reliable = true);

Verified against: NetworkRunner.cs:324-329 (SendEvent), NetworkRunner.cs:333-339 (SendObjectEvent), NetworkRunner.cs:342-347 (SendEventToPeer). The object-scoped NetworkObject.SendEvent is at NetworkObject.cs:139-147.

Event targets

LatticeEventTarget mirrors the RPC routing policy, plus a Peer target for a directed send:

Target Value Delivers to
Server 0 The authority (a client's events implicitly go here)
Owner 1 The object's owner
All 2 Every peer
AllButOwner 3 Everyone except the owner
Peer 4 One specific connection (via SendEventToPeer)

Verified against: LatticeNative.cs:76-83.

Receiving events

Subscribe to OnEvent. Its signature is the key difference from OnRpc — it carries the sender:

OnEvent — (eventId, netid, sender, payload)
public event Action<ushort, ulong, ulong, byte[]> OnEvent;
  • eventId — the tag you sent.
  • netid0 for a global/session-scoped event, or the owning object's netid for an object-scoped one.
  • sender — the originating peer id (0 means the local authority).
  • payload — a managed copy, valid after the call.

Verified against: NetworkRunner.cs:31-34 (event + doc), NetworkRunner.cs:158-159 (trampoline).

The sample's event protocol

The sample assigns stable ushort ids to its message kinds:

Net — the sample's event ids
public const ushort EventJoin       = 99;    // client → host on connect: "give me a seat"
public const ushort EventMove       = 100;   // client → host: place my mark at a cell
public const ushort EventChat       = 101;   // chat line (client → host → all)
public const ushort EventAssignMark = 102;   // host → one peer: your assigned mark

Verified against: TicTacToeProtocol.cs:70-89.

A single OnEvent handler dispatches on the id:

HandleEvent — from the sample
private void HandleEvent(ushort eventId, ulong netid, ulong sender, byte[] payload)
{
    switch (eventId)
    {
        case Net.EventJoin:       if (IsHost) AssignMarkIfNeeded(sender);      break;
        case Net.EventMove:       if (IsHost) HandleMoveEvent(sender, payload); break;
        case Net.EventChat:       HandleChatEvent(sender, payload);            break;
        case Net.EventAssignMark: LocalMark = Payloads.ReadAssignMark(payload);
                                  OnMarkAssigned?.Invoke(LocalMark);           break;
    }
}

Verified against: TicTacToeSession.cs:193-214.

Pattern 1 — a client join handshake (directed reply)

When a client connects, it sends an empty EventJoin to the host (target Server). The host learns the client's peer id from sender, assigns it a mark, and replies to that one peer with SendEventToPeer:

Client: request a seat on connect
private void HandleConnected()
{
    if (!IsHost && !_joinSent)
    {
        _joinSent = true;
        _runner.SendEvent(Net.EventJoin, LatticeEventTarget.Server, Array.Empty<byte>(), reliable: true);
    }
}
Host: assign a mark and reply to just that peer
public void AssignMarkIfNeeded(ulong peer)
{
    if (!IsHost || peer == 0 || _peerMarks.ContainsKey(peer)) return;
    Mark mark = _oAssigned ? Mark.Empty : Mark.O;   // first client is O, later peers spectate
    _oAssigned = true;
    _peerMarks[peer] = mark;
    _runner.SendEventToPeer(Net.EventAssignMark, peer, Payloads.AssignMark(mark), reliable: true);
}

Verified against: TicTacToeSession.cs:169-178 (HandleConnected), TicTacToeSession.cs:252-259 (AssignMarkIfNeeded).

This is exactly why the sample uses events, not RPCs: the host needs sender to map a connection to a seat.

Pattern 2 — chat relayed through the authority

Chat shows the host as an authoritative relay. A client sends its line to the host (Server); the host re-broadcasts to everyone (All). The host also surfaces its own line locally, because an authority's All send does not loop back to itself:

SendChat — from the sample
public void SendChat(string text)
{
    byte[] payload = Payloads.Chat(LocalMark, text);
    if (IsHost)
    {
        _runner.SendEvent(Net.EventChat, LatticeEventTarget.All, payload, reliable: true);
        OnChat?.Invoke(LocalMark, text);   // authority's own All send doesn't echo back — surface locally
    }
    else
    {
        _runner.SendEvent(Net.EventChat, LatticeEventTarget.Server, payload, reliable: true);
    }
}

Verified against: TicTacToeSession.cs:119-132.

Trust the sender, not the payload — stamp identity server-side

When the host re-broadcasts a client's chat, it overwrites the sender mark with the one it assigned to that peer, never the mark the client packed — a client could otherwise claim to be its opponent. It also caps the text length so a peer can't make the host amplify a huge string to everyone. This is the authoritative-identity pattern events make possible.
Verified against: TicTacToeSession.cs:226-248.

Encoding payloads

Events carry raw byte[], so you encode/decode yourself. The sample keeps this tiny and dependency-free (plain buffers + UTF-8) so Unity and the headless harness produce byte-identical wire data:

Payloads — chat is [mark byte][UTF-8 text]
public static byte[] Chat(Mark sender, string text)
{
    byte[] body = Encoding.UTF8.GetBytes(text ?? string.Empty);
    byte[] buf  = new byte[body.Length + 1];
    buf[0] = (byte)sender;                       // leading identity byte
    Array.Copy(body, 0, buf, 1, body.Length);
    return buf;
}

Verified against: TicTacToeProtocol.cs:103-118.

For structured payloads, use the BitWriter/BitReader

For anything beyond a few bytes, the core exposes a bit-packing writer/reader (lattice_bw_* / lattice_br_*) bound in LatticeNative, which the binding's headless test round-trips. Small hand-rolled buffers (like the sample's) are perfectly fine for simple messages.
Verified against: LatticeNative.cs:315-443; bindings/lattice-unity/README.md (BitWriter_BitReader_RoundTrip).

Is this verified? Yes

The event channel is proven by the headless harness: a chat line sent by one peer is received by the other via the event API, with text and sender round-tripped byte-exact, in both directions.

Verified against: samples/unity-tictactoe/headless/Program.cs:109-126; samples/unity-tictactoe/README.md ("a chat line sent by one peer is received by the other via the event API").


A word on "presence"

Custom events (this chapter) are your in-session, netcode-level message channel. Presence in the social sense — online/away/in-game status, rich presence, "join my party" — is a separate service (lattice-social) with its own SDK (LatticeSocialClient) and realtime WebSocket, not the NetworkRunner event channel.

The social client models presence states (Online, Away, Busy, DoNotDisturb, InGame) and pushes changes over a realtime channel (FriendPresenceChanged). That's the layer you'd use to show a friends list and let players invite each other into a session (whose handle then flows through the matchmaking path in chapter 04).

Verified against: control-plane/lattice-social-client/src/Models.cs:12-22 (PresenceState), Models.cs:175-179 (FriendPresenceChanged).

Not exercised by the Unity sample

Social presence is a real, separate subsystem, but the tic-tac-toe sample does not use it — the verified event content in this chapter is the NetworkRunner custom-event channel. Treat the presence paragraph as a pointer to where that capability lives.


Next: 08 — Authority.