07 — Events & presence¶
Custom events are the object-less sibling of an RPC — a small tagged message you send to other peers, where the receiver learns who sent it. This is the channel the tic-tac-toe sample uses for its chat.
Verified against: bindings/lattice-godot/src/lattice_sync.cpp:326-337,
bindings/lattice-godot/src/lattice_runner.cpp:569-579,
samples/godot-tictactoe/godot/scripts/chat.gd.
Sending an event¶
Events are sent through a LatticeSync (the sample sends through the board's sync), but they ride the
object-less event path, so they work for session-wide messages too:
sync.send_event(event_id: int, args: Array, target: int = LatticeSync.ALL_PEERS, reliable: bool = true)
event_id— aninttag you choose; the receiver sees it on theeventsignal.args— a typedArray, packed exactly like RPC args (chapter 06).target— an authority ordinal (defaults toALL_PEERS).reliable— defaults totrue.
Verified against: lattice_sync.cpp:65-66 (bound with DEFVAL(ALL_PEERS) for target and
DEFVAL(true) for reliable), lattice_sync.h:93-97.
Event targets¶
send_event accepts the same authority ordinals as send_rpc, plus EVT_PEER for a directed send:
| Ordinal | Value | Maps to | Delivers to |
|---|---|---|---|
LatticeSync.STATE_AUTHORITY |
0 | LATTICE_EVENT_SERVER |
The authority (a client's events implicitly go here) |
LatticeSync.INPUT_AUTHORITY |
1 | LATTICE_EVENT_OWNER |
The object's owner |
LatticeSync.ALL_PEERS |
2 | LATTICE_EVENT_ALL |
Every peer |
LatticeSync.OTHER_PEERS |
3 | LATTICE_EVENT_ALL_BUT_OWNER |
Everyone except the owner |
LatticeSync.EVT_PEER |
4 | LATTICE_EVENT_PEER |
One specific connection (see the note) |
Verified against: lattice_sync.h:47-53, lattice_sync.cpp:332-336 (maps via
lgodot::glue_event_target); thirdparty/lattice/lattice.h:124-129 (the lattice_event_target values).
EVT_PEER exists, but send_event can't yet pick which peer
The EVT_PEER ordinal maps to the core's directed-send target, but the node-level send_event
passes the destination connection id as a hardcoded 0 — it exposes no peer argument. So a
"reply to exactly this one connection" send is not parameterizable from the current GDScript
API (the underlying glue forwarder does take a peer argument; the node just doesn't surface it).
Use STATE_AUTHORITY / ALL_PEERS routing, as the sample does. (Unity's binding exposes a separate
SendEventToPeer; the Godot node does not yet.)
Verified against: lattice_sync.cpp:330-336 (send_event calls glue_send_event(… /*peer*/
0 …)), lattice_glue.h:184-186 (the glue forwarder's peer parameter).
Receiving events¶
Subscribe to the runner's event signal. Its arguments are the key difference from an RPC — it
carries the sender:
runner.event.connect(_on_event)
func _on_event(event_id: int, netid: int, sender: int, payload: PackedByteArray) -> void:
...
event_id— 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 sent it).payload— aPackedByteArraycopied from the core, valid after the call returns.
Verified against: lattice_runner.cpp:70-74 (the event signal), lattice_runner.cpp:569-579
(_on_event copies the payload into a PackedByteArray and emits event(event_id, netid, sender,
bytes)); chat.gd:33-35.
The sample's chat protocol¶
Chat uses one event id and a two-string payload [sender_name, text]:
const EVT_CHAT := 100
func setup(p_runner: LatticeRunner, p_sync: LatticeSync, p_local_name: String) -> void:
runner = p_runner
sync = p_sync
_local_name = p_local_name
runner.event.connect(_on_event)
func send_chat(text: String) -> void:
if text.strip_edges().is_empty() or sync == null:
return
var args := [_local_name, text]
if runner.is_server():
sync.send_event(EVT_CHAT, args, LatticeSync.ALL_PEERS, true)
chat_received.emit(_local_name, text) # host echoes its own ALL send locally
else:
sync.send_event(EVT_CHAT, args, LatticeSync.STATE_AUTHORITY, true)
Verified against: chat.gd:21,29-46.
Note the two routing choices, and why:
- A client sends to
STATE_AUTHORITY(the host). - The host sends to
ALL_PEERSand surfaces its own line locally — because an authority'sALLsend does not loop back to itself.
Verified against: chat.gd:42-46.
Pattern: the host as an authoritative relay¶
When the host receives a client's chat, it re-broadcasts to everyone — and it overwrites the sender name with the authenticated peer id, never trusting the name the client packed (a client could otherwise claim to be its opponent). It also caps the length so a peer can't make the host amplify a huge string:
func _on_event(event_id: int, _netid: int, sender: int, payload: PackedByteArray) -> void:
if event_id != EVT_CHAT:
return
var who_text := _decode(payload)
if who_text.is_empty():
return
var who: String = who_text[0]
var text: String = who_text[1]
const MAX_CHAT_LEN := 200
if text.length() > MAX_CHAT_LEN:
text = text.substr(0, MAX_CHAT_LEN)
# Host is the relay: a client's message (sender != 0) is re-broadcast to ALL. The host does NOT
# trust the client-supplied name; it labels the line with the authenticated peer id.
if runner.is_server() and sender != 0 and sync != null:
who = "peer-%d" % sender
sync.send_event(EVT_CHAT, [who, text], LatticeSync.ALL_PEERS, true)
chat_received.emit(who, text)
Verified against: chat.gd:51-73.
Trust the sender id, not the payload
Using sender (from the event) rather than a client-supplied field is the core authoritative-identity
pattern events make possible. sender == 0 means the authority itself; any non-zero value is a
client's authenticated peer id.
Verified against: chat.gd:64-71; lattice_runner.cpp:569-579 (sender comes from the core
callback, not the payload).
Encoding payloads¶
send_event args are packed by the binding's RPC-arg packer: a leading arg count, then per arg a
type tag and value, with strings length-prefixed UTF-8. The sample's chat.gd includes a small pure-GDScript
decoder that mirrors that exact wire form to read [who, text] back out of the PackedByteArray:
const _TAG_STRING := 9 # RpcArg::Kind ordinal for STRING
func _decode(buf: PackedByteArray) -> Array:
var off := [0]
var count := _read_int(buf, off) # leading arg count
if count != 2:
return []
var out := []
for i in range(count):
var tag := _read_enum(buf, off, _RPC_KIND_COUNT)
if tag != _TAG_STRING:
return []
out.append(_read_string(buf, off)) # length-prefixed UTF-8
return out
Verified against: chat.gd:75-148 (the full MSB-first bit reader, varint, and string reader);
lattice_sync.cpp:31-57,326-337 (the packer side these mirror). The sample's harness re-implements this
reader in C++ and decodes a real packed payload to prove the two agree.
Verified against: samples/godot-tictactoe/README.md ("It also proves the GDScript chat decoder
(chat.gd) matches the binding's packer").
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 path, byte-exact, in both directions (client→host and host→all).
Verified against: samples/godot-tictactoe/README.md ("a chat message crosses the wire via the event
path, byte-exact, both client→host and host→all"), samples/godot-tictactoe/harness/ttt_harness.cpp.
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, "join my party" — is a separate service (lattice-social
on port 3009) with its own realtime channel, not the LatticeRunner event channel. From Godot you'd
reach it over HTTP/WebSocket like any other service; it is not part of this binding and the sample
does not use it.
Verified against: run-all.sh:29 (PORT_SOCIAL=3009); the binding surfaces no social API
(bindings/lattice-godot/src/lattice_runner.cpp:27-96).
Next: 08 — Authority.