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:
// 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:
public event Action<ushort, ulong, ulong, byte[]> OnEvent;
eventId— the tag you sent.netid—0for a global/session-scoped event, or the owning object's netid for an object-scoped one.sender— the originating peer id (0means 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:
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:
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:
private void HandleConnected()
{
if (!IsHost && !_joinSent)
{
_joinSent = true;
_runner.SendEvent(Net.EventJoin, LatticeEventTarget.Server, Array.Empty<byte>(), reliable: true);
}
}
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:
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:
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.