NUClearNet: Peer-to-Peer Networking¶
NUClearNet is NUClear's built-in networking layer — a decentralized, peer-to-peer messaging system that lets NUClear nodes communicate transparently across a network. It's designed for robotics and distributed systems where nodes need to discover each other automatically and exchange typed messages with minimal configuration.
Architecture & Design¶
graph TD
A[Node A] <-->|UDP| B[Node B]
A <-->|UDP| C[Node C]
A <-->|UDP| D[Node D]
B <-->|UDP| C
B <-->|UDP| D
C <-->|UDP| D
style A fill:#4a9eff,color:#fff
style B fill:#4a9eff,color:#fff
style C fill:#4a9eff,color:#fff
style D fill:#4a9eff,color:#fff
Key design principles:
- Decentralized mesh — no central server or message broker. Every node is equal.
- Autonomous discovery — nodes find each other via periodic announcements, no manual configuration of peer addresses.
- UDP-only — both discovery and data transfer use UDP (no TCP). This keeps the implementation simple and avoids head-of-line blocking.
- Two socket types — each node has an announce socket (for discovery) and a data socket (for payload transfer).
The announce socket listens on a shared multicast/broadcast address that all nodes agree on. The data socket uses an ephemeral port unique to each node — peers learn each other's data address through announce packets.
Component Layers¶
graph TB
subgraph "User Code"
DSL["on< Network< T > >().then(...)"]
EMIT["emit< Scope::NETWORK >(data)"]
end
subgraph "NUClear DSL Layer"
NW["Network< T > word"]
NE["emit::Network< T > word"]
end
subgraph "NetworkController Reactor"
NC[NetworkController]
end
subgraph "NUClearNetwork Engine"
NN[NUClearNetwork class]
end
subgraph "Operating System"
AS[Announce Socket - UDP multicast/broadcast]
DS[Data Socket - UDP unicast]
end
DSL --> NW --> NC
EMIT --> NE --> NC
NC <--> NN
NN <--> AS
NN <--> DS
Peer Discovery¶
Every node periodically broadcasts an AnnouncePacket on the announce address.
This is how nodes find each other.
Discovery Sequence¶
sequenceDiagram
participant A as Node A (existing)
participant Net as Network (multicast)
participant B as Node B (joining)
Note over A: Running, announcing every ~interval
A->>Net: AnnouncePacket (name="A")
Note over B: Starts up, begins announcing
B->>Net: AnnouncePacket (name="B")
Note over A: New peer heard!
A->>A: Add B to peer list
A->>B: Immediate announce back (unicast)
A->>A: Fire NetworkJoin event
Note over B: Hears A's announce
B->>B: Add A to peer list
B->>B: Fire NetworkJoin event
loop Ongoing
A->>Net: AnnouncePacket every ~500ms
B->>Net: AnnouncePacket every ~500ms
end
Note over A: No packet from B for 2+ seconds
A->>A: Remove B from peer list
A->>A: Fire NetworkLeave event
Announce Address Options¶
The announce address can be:
- Multicast (e.g.,
239.226.152.162) — the most common setup. All nodes on the same network join the multicast group and hear each other's announcements. - Broadcast (e.g.,
255.255.255.255) — works on simple LANs without multicast support. - Unicast — for point-to-point setups or testing.
Peer Timeout¶
Each peer's last_update timestamp is refreshed every time a packet arrives from them.
If no packet is received for approximately 2 seconds (configurable), the peer is considered gone — it's removed from the peer list and a NetworkLeave event fires.
Wire Protocol¶
All NUClearNet packets share a common header format.
Packet Header¶
block-beta
columns 8
h1["0xE2"]:1 h2["0x98"]:1 h3["0xA2"]:1 ver["Version 0x02"]:1 type["Type"]:1 payload["Payload..."]:3
style h1 fill:#ff6b6b,color:#fff
style h2 fill:#ff6b6b,color:#fff
style h3 fill:#ff6b6b,color:#fff
style ver fill:#4ecdc4,color:#fff
style type fill:#45b7d1,color:#fff
style payload fill:#96ceb4,color:#fff
- Bytes 0-2:
0xE2 0x98 0xA2— the ☢ (radioactive) symbol in UTF-8. Acts as a magic number to identify NUClear packets. - Byte 3: Version — currently
0x02 - Byte 4: Packet type
Packet Types¶
| Type | Value | Purpose |
|---|---|---|
| ANNOUNCE | 1 | Periodic discovery broadcast |
| LEAVE | 2 | Graceful departure notification |
| DATA | 3 | Normal data payload |
| DATA_RETRANSMISSION | 4 | Retransmitted data fragment |
| ACK | 5 | Acknowledgment of received fragments |
| NACK | 6 | Request for specific missing fragments |
DataPacket Structure¶
block-beta
columns 12
hdr["Header (5B)"]:2 pid["packet_id (2B)"]:2 pno["packet_no (2B)"]:2 pcnt["packet_count (2B)"]:2 rel["reliable (1B)"]:1 hash["type_hash (8B)"]:1 data["payload..."]:2
style hdr fill:#ff6b6b,color:#fff
style pid fill:#ffd93d,color:#333
style pno fill:#6bcb77,color:#fff
style pcnt fill:#4d96ff,color:#fff
style rel fill:#ff922b,color:#fff
style hash fill:#9775fa,color:#fff
style data fill:#96ceb4,color:#fff
- packet_id — a semi-unique identifier for this message (groups fragments together)
- packet_no — which fragment this is (0-indexed)
- packet_count — total number of fragments in this message
- reliable — whether this packet requires acknowledgment
- hash — 64-bit type hash identifying what kind of data this is
- data — the serialized payload bytes
Fragmentation & Reassembly¶
UDP datagrams have a practical size limit (the network MTU). Large messages must be split across multiple packets.
MTU Calculation¶
With a typical 1500-byte MTU, this gives roughly 1441 bytes per fragment (accounting for the DataPacket fields).
Sending Large Messages¶
flowchart LR
MSG["Message (5000 bytes)"] --> SPLIT[Split into fragments]
SPLIT --> F1["Fragment 0 (1441B)"]
SPLIT --> F2["Fragment 1 (1441B)"]
SPLIT --> F3["Fragment 2 (1441B)"]
SPLIT --> F4["Fragment 3 (677B)"]
F1 --> UDP1[UDP Datagram]
F2 --> UDP2[UDP Datagram]
F3 --> UDP3[UDP Datagram]
F4 --> UDP4[UDP Datagram]
Reassembly on the Receiver¶
The receiver collects fragments keyed by (source_address, packet_id).
Once all packet_count fragments arrive, the original message is reassembled and delivered.
- Stale assemblies: If an incomplete message hasn't received new fragments in
10 × RTT(round-trip time to that peer), it's discarded. This prevents memory leaks from lost unreliable packets.
Reliable Delivery¶
By default, NUClearNet is unreliable — packets are fire-and-forget, just like raw UDP. But when you need guaranteed delivery, the reliable mode adds ACK-based retransmission.
Unreliable (Default)¶
- Send and forget
- No ACKs, no retransmission
- Fastest possible — zero overhead
- Fine for high-frequency data where missing one update doesn't matter (sensor streams, video frames)
Reliable Mode¶
sequenceDiagram
participant S as Sender
participant R as Receiver
S->>R: DataPacket (id=42, no=0, reliable=true)
S->>R: DataPacket (id=42, no=1, reliable=true)
S->>R: DataPacket (id=42, no=2, reliable=true)
Note over R: Received 0 and 2, missing 1
R->>S: ACK (id=42, no=2, bitset=[1,0,1])
Note over S: Sees fragment 1 not ACKed
Note over S: Wait RTT timeout...
S->>R: DataRetransmission (id=42, no=1)
R->>S: ACK (id=42, no=1, bitset=[1,1,1])
Note over R: All fragments received, deliver message
Key mechanisms:
- ACK per fragment — when the receiver gets a fragment, it responds with an ACK that includes a bitset of all received fragments for that packet_id. This gives the sender full visibility.
- RTT-based retransmission — the sender waits one estimated RTT before retransmitting un-ACKed fragments. Retransmitting too early wastes bandwidth; too late adds latency.
- Adaptive RTT estimation — each peer's round-trip time is tracked using a Kalman filter. This adapts to changing network conditions smoothly.
- NACK support — the receiver can proactively request specific missing fragments via NACK packets.
- Duplicate detection — a circular buffer of recent
packet_idvalues prevents processing the same message twice.
RTT Estimation (Kalman Filter)¶
Rather than using a simple moving average, NUClearNet uses a single-state Kalman filter per peer:
K = (P + Q) / (P + Q + R) // Kalman gain
P = R * (P + Q) / (R + P + Q) // Update variance
X = X + (measurement - X) * K // Update estimate
Where Q is process noise (how much RTT might change), R is measurement noise (how noisy individual measurements are), and X is the current RTT estimate.
This gives smooth, responsive RTT tracking.
Type Routing¶
Messages are identified by a type hash rather than string names or channel IDs.
flowchart LR
subgraph Sender
T1["Type: SensorData"] --> H1["xxHash64('SensorData', seed='NUCl')"]
H1 --> HASH["hash = 0x7a3f..."]
HASH --> PKT[DataPacket with hash field]
end
subgraph Network
PKT -->|UDP| RECV[Received packet]
end
subgraph Receiver
RECV --> LOOKUP["Look up hash in reaction map"]
LOOKUP --> R1["Reaction registered for SensorData"]
R1 --> DESER["Deserialize → SensorData"]
end
The hash is computed as:
Both sender and receiver must use exactly the same type name. It's not enough to have structurally identical types — the demangled name must match. In practice, this means sharing header files between nodes.
Serialization¶
NUClearNet doesn't prescribe a single serialization format.
Instead, it uses the Serialise<T> template which selects a strategy based on the type:
- Trivially copyable types — direct
memcpy(fast but architecture-dependent) - Protobuf messages —
SerializeToString/ParseFromString - Custom types — user provides a
Serialise<T>specialization
See Serialization for the full details.
Integration with the NUClear DSL¶
The networking system integrates with NUClear through the NetworkController reactor — a built-in extension that bridges the low-level network engine with the task system.
Receiving: Network<T>¶
When you use Network<T>:
- At bind time, the reaction's type hash is registered with the
NetworkController - The
NetworkControllermapshash → reactionin its internal multimap - When a packet arrives with that hash, the
NetworkController:- Stores the raw bytes in ThreadStore
- Calls
get_task()on the matched reactions - The
Network<T>word'sget()deserializes the bytes into aT
Sending: emit<Scope::NETWORK>¶
This triggers:
emit::Network<SensorData>serializes the data and computes the type hash- A
NetworkEmitmessage is emitted locally NetworkControllercatches it and callsNUClearNetwork::send(hash, payload, target, reliable)- The network engine fragments and transmits the packet
Peer Lifecycle Events¶
on<Trigger<NetworkJoin>>().then([](const NetworkJoin& event) {
log("Peer joined:", event.name);
});
on<Trigger<NetworkLeave>>().then([](const NetworkLeave& event) {
log("Peer left:", event.name);
});
These are emitted by the NetworkController when its join/leave callbacks fire from the network engine.
Data Transmission Flow¶
sequenceDiagram
participant Sender as Sender Node
participant Net as UDP Network
participant Receiver as Receiver Node
Note over Sender: Serialise data + compute type hash
Sender->>Sender: Fragment if larger than MTU
Sender->>Net: UDP DataPacket(s)
Net->>Receiver: UDP DataPacket(s)
Receiver->>Receiver: Reassemble fragments
Note over Receiver: Look up reactions by type hash
Note over Receiver: Deserialise → callback executes
In more detail, the sender side proceeds as: emit<Scope::NETWORK>(data) → serialise → compute type hash → emit a local NetworkEmit message → NetworkController calls NUClearNetwork::send() → fragment and transmit via UDP.
On the receiver side: NUClearNetwork reassembles fragments → calls packet_callback on NetworkController → looks up reactions by hash → creates tasks → Network<T>::get() deserialises → callback runs.
Configuration¶
The network is configured by emitting a NetworkConfiguration message:
emit(std::make_unique<NetworkConfiguration>(
"my_node_name", // This node's name
"239.226.152.162", // Announce address (multicast)
7447 // Announce port
));
When a new configuration is received, the NetworkController tears down existing sockets and reinitializes with the new settings.
The node name becomes the identifier that other peers see in NetworkJoin events.