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 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:

LatticeSync.send_event
sync.send_event(event_id: int, args: Array, target: int = LatticeSync.ALL_PEERS, reliable: bool = true)
  • event_id — an int tag you choose; the receiver sees it on the event signal.
  • args — a typed Array, packed exactly like RPC args (chapter 06).
  • target — an authority ordinal (defaults to ALL_PEERS).
  • reliable — defaults to true.

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(event_id, netid, sender, payload)
runner.event.connect(_on_event)

func _on_event(event_id: int, netid: int, sender: int, payload: PackedByteArray) -> void:
    ...
  • event_id — 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 sent it).
  • payload — a PackedByteArray copied 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]:

chat.gd — setup + send
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_PEERS and surfaces its own line locally — because an authority's ALL send 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:

chat.gd — host relays and re-stamps identity
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:

chat.gd — decode two STRING args (abridged)
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.