
Lightweight Swarm Communication for Drones & Robots
Robotics teams building multi-robot swarms on ROS2 run into a recurring scaling problem. DDS, the middleware underneath ROS2, uses multicast for peer discovery. On WiFi networks -- where most mobile robots and drones actually operate -- multicast is unreliable, generates broadcast storms, and consumes bandwidth that the robots need for telemetry and coordination. The result: adding more robots to the swarm makes communication worse, not better.
The question robotics teams keep asking is straightforward: what affordable, stable system provides communication between multiple robots without the overhead of ROS2/DDS? This article examines why the DDS multicast model breaks for swarms, what lightweight alternatives exist, and how a single-socket UDP overlay with registry-based discovery can coordinate robot swarms on constrained hardware.
The DDS/ROS2 Multicast Problem
ROS2 uses DDS (Data Distribution Service) as its communication middleware. DDS was designed for military and industrial systems where participants are on the same wired LAN with reliable multicast support. It works well in that environment. The problems start when you move to WiFi, cellular, or mixed networks -- which is where most modern robot swarms actually operate.
Discovery storms. DDS uses SPDP (Simple Participant Discovery Protocol) to find other participants on the network. Each participant periodically multicasts its presence. With 10 robots, that is 10 discovery messages every few seconds, each broadcast to all network participants. On WiFi, multicast frames are transmitted at the lowest data rate (often 1 Mbps on 802.11b/g compatibility mode), consuming disproportionate airtime. With 50 robots, the discovery traffic alone can saturate the WiFi channel.
Middleware latency overhead. The ROS2 middleware layer -- type support, serialization, executor callbacks -- adds delay to every message on top of raw DDS. For a swarm doing position updates at 10 Hz, that delay makes the position data each robot receives staler than it needs to be.
WiFi multicast is fundamentally broken. WiFi access points handle multicast by converting it to broadcast, which is transmitted without acknowledgment or retransmission. If a robot misses a multicast frame due to interference (common in outdoor environments with 2.4 GHz congestion), the message is silently lost. DDS has reliability mechanisms, but they depend on the transport layer delivering the multicast in the first place. On WiFi, this assumption fails routinely.
Memory and CPU overhead. A ROS2 node with DDS middleware carries memory and CPU overhead that grows with the number of topics and participants. On a Raspberry Pi 4 or a Jetson Nano that is also running a navigation stack, vision processing, and motor control, that overhead competes with the robot's real work. Every megabyte matters when you are running neural networks on edge hardware.
Why Swarms Need Something Lighter Than ROS2
Robot swarms have specific communication requirements that differ from single-robot ROS2 systems:
- Many-to-many messaging. Every robot needs to share its state (position, battery, task status) with every other robot. This is not point-to-point; it is a broadcast pattern. But it needs to work on WiFi, where real broadcast is unreliable.
- Heterogeneous networks. Drones on cellular, ground robots on WiFi, the ground station on ethernet. The communication system must bridge these networks without requiring all participants to be on the same LAN.
- Constrained hardware. Swarm robots are cost-optimized. They run on Raspberry Pi, Jetson Nano, ESP32, or similarly constrained platforms. The communication stack cannot consume a large share of RAM or a full CPU core.
- Dynamic membership. Robots join when powered on, leave when battery dies, and may rejoin later. The system must handle this without manual reconfiguration.
- Field deployment. Swarms operate in warehouses, agricultural fields, disaster zones, and construction sites. There is no IT department to configure routers, open ports, or manage certificates.
ROS2/DDS was not designed for these constraints. It was designed for fixed installations with wired networks and powerful compute nodes. Using it for swarms works in the lab (where the WiFi is clean and the robot count is small) but fails in the field.
Pilot's Approach: Single Socket, No Multicast, Registry Discovery
Pilot Protocol takes a fundamentally different approach to the problems DDS struggles with.
Single UDP socket. Each Pilot daemon uses one UDP socket for all communication. There is no multicast, no broadcast, no SPDP discovery traffic. All messages are unicast -- sent directly from one peer to another. On WiFi, unicast frames are transmitted at the highest negotiated data rate with acknowledgment and retransmission. This means every Pilot message gets the reliability that WiFi provides for unicast, which multicast discards.
Registry-based discovery. Instead of multicast discovery, robots register with a registry server under a hostname and can label themselves with up to three tags. Other robots look a peer up by hostname to get its virtual address. The registry (with its companion beacon) can be the public one run by Vulture Labs, or your own on the ground station or a cloud VM. Discovery traffic is unicast TCP to the registry, not multicast on the WiFi channel.
# Each drone registers a name and labels itself on startup
pilotctl set-hostname drone-07
pilotctl extras set-tags drone survey-team-alpha quadcopter # max 3 tags
# Ground station resolves a drone it trusts (or shares a network with)
pilotctl find drone-07
# The drone's tags come back with a registry lookup
pilotctl --json lookup drone-07
There is no search by tag: the ground station keeps the list of drone hostnames. On a registry you run yourself, an operator can put the swarm in one network, and pilotctl network members <id> lists every member with its hostname (its --json output includes the tags).
Small footprint. The daemon and pilotctl are static Go binaries with no runtime dependencies, so they run next to the robot's navigation, perception, and control workloads without a middleware installation.
Architecture: Drone Swarm Coordinating via Event Stream
Here is a concrete architecture for a swarm of 10 survey drones coordinated by a ground station. Every Pilot daemon runs an event-stream broker on port 1002; the swarm shares the broker of one always-on hub node. Each drone publishes telemetry (position, battery, status) to the hub. The ground station subscribes to all telemetry, computes the survey plan, and publishes waypoints back to individual drones.
Components:
- 10 drones -- each runs Pilot daemon + flight controller + camera
- 1 ground station -- runs Pilot daemon + mission planner + telemetry display
- 1 hub -- an always-on node (a small VM or a companion computer at the base) whose broker carries the swarm's topics
- Registry and beacon -- the public ones, or your own on the ground station or a cloud VM
Communication flow:
- Each drone publishes telemetry to
swarm.telemetryevery 100ms, with its drone ID in the payload. - Ground station subscribes to
swarm.telemetry-- receives all drone telemetry. - Ground station publishes waypoints to
swarm.command.drone-Nfor each drone. - Each drone subscribes to
swarm.command.drone-N(its own command channel). - Drones publish events to
swarm.events(low battery, obstacle detected, survey complete).
Topics match exactly. A subscriber can use * to receive every topic on the hub, but there are no prefix wildcards such as swarm.telemetry.*, which is why the drone ID travels in the payload.
# Drone startup sequence
pilotctl daemon start
pilotctl set-hostname drone-07
pilotctl extras set-tags drone survey-team-alpha quadcopter # max 3 tags
# Ground station startup (each subscribe runs until stopped; use one terminal per topic)
pilotctl daemon start
pilotctl set-hostname ground-station
pilotctl extras set-tags ground-station survey-team-alpha
pilotctl subscribe <hub-address|hostname> swarm.telemetry
pilotctl subscribe <hub-address|hostname> swarm.events
No network join is needed on the public backbone: once the hub approves a node's trust handshake, they can talk. Private networks are created by a registry operator, so to keep the swarm in one, run your own rendezvous.
Code Example: Telemetry Publishing and Subscribing
Here is a Go program for each drone, publishing GPS telemetry over Pilot. It uses the eventstream package: eventstream.Subscribe connects to the hub's broker and subscribes to one topic, and the same client can Publish to any topic and Recv the events of the topic it subscribed to.
package main
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/pilot-protocol/common/driver"
"github.com/pilot-protocol/common/protocol"
"github.com/pilot-protocol/eventstream"
)
type Telemetry struct {
DroneID string `json:"drone_id"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
AltM float64 `json:"alt_m"`
SpeedMps float64 `json:"speed_mps"`
HeadingDeg float64 `json:"heading_deg"`
BatteryPct int `json:"battery_pct"`
State string `json:"state"` // "idle", "flying", "returning", "landed"
Ts int64 `json:"ts"`
}
func main() {
droneID := os.Getenv("DRONE_ID") // e.g., "drone-07"
if droneID == "" {
droneID = "drone-01"
}
// The hub whose broker the swarm shares, e.g. "0:0000.0000.1A2B"
hub, err := protocol.ParseAddr(os.Getenv("HUB_ADDR"))
if err != nil {
panic(err)
}
d, err := driver.Connect("")
if err != nil {
panic(err)
}
// Subscribe to commands addressed to this drone; the same client publishes telemetry
cmdTopic := fmt.Sprintf("swarm.command.%s", droneID)
stream, err := eventstream.Subscribe(d, hub, cmdTopic)
if err != nil {
panic(err)
}
defer stream.Close()
// Handle incoming commands in background
go func() {
for {
event, err := stream.Recv()
if err != nil {
return
}
var cmd struct {
Action string `json:"action"`
WaypointLat float64 `json:"waypoint_lat,omitempty"`
WaypointLon float64 `json:"waypoint_lon,omitempty"`
WaypointAlt float64 `json:"waypoint_alt,omitempty"`
}
json.Unmarshal(event.Payload, &cmd)
fmt.Printf("[CMD] %s: %+v\n", cmd.Action, cmd)
// Send command to flight controller...
}
}()
// Publish telemetry at 10 Hz
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
telem := Telemetry{
DroneID: droneID,
Lat: readGPSLat(),
Lon: readGPSLon(),
AltM: readAltimeter(),
SpeedMps: readAirspeed(),
HeadingDeg: readCompass(),
BatteryPct: readBattery(),
State: getFlightState(),
Ts: time.Now().UnixMilli(),
}
data, _ := json.Marshal(telem)
stream.Publish("swarm.telemetry", data)
}
}
// Hardware interface stubs -- replace with actual sensor reads
func readGPSLat() float64 { return 37.7749 }
func readGPSLon() float64 { return -122.4194 }
func readAltimeter() float64 { return 50.0 }
func readAirspeed() float64 { return 5.2 }
func readCompass() float64 { return 270.0 }
func readBattery() int { return 85 }
func getFlightState() string { return "flying" }
And the ground station subscriber that aggregates telemetry from all drones and sends waypoint commands:
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/pilot-protocol/common/driver"
"github.com/pilot-protocol/common/protocol"
"github.com/pilot-protocol/eventstream"
)
type Telemetry struct {
DroneID string `json:"drone_id"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
AltM float64 `json:"alt_m"`
BatteryPct int `json:"battery_pct"`
State string `json:"state"`
Ts int64 `json:"ts"`
}
type Waypoint struct {
Action string `json:"action"`
WaypointLat float64 `json:"waypoint_lat"`
WaypointLon float64 `json:"waypoint_lon"`
WaypointAlt float64 `json:"waypoint_alt"`
}
func main() {
hub, err := protocol.ParseAddr(os.Getenv("HUB_ADDR"))
if err != nil {
panic(err)
}
d, err := driver.Connect("")
if err != nil {
panic(err)
}
// One subscription per topic (topics match exactly; "*" would mean every topic)
telemStream, err := eventstream.Subscribe(d, hub, "swarm.telemetry")
if err != nil {
panic(err)
}
eventStream, err := eventstream.Subscribe(d, hub, "swarm.events")
if err != nil {
panic(err)
}
// Handle events (low battery, obstacles, etc.) in background
go func() {
for {
event, err := eventStream.Recv()
if err != nil {
return
}
fmt.Printf("[EVENT] %s: %s\n", event.Topic, string(event.Payload))
}
}()
// Process telemetry
droneState := make(map[string]Telemetry)
for {
event, err := telemStream.Recv()
if err != nil {
panic(err)
}
var telem Telemetry
json.Unmarshal(event.Payload, &telem)
droneState[telem.DroneID] = telem
// Check for low battery and send return-to-home command
if telem.BatteryPct < 20 && telem.State == "flying" {
fmt.Printf("[ALERT] %s battery at %d%%, sending RTH\n",
telem.DroneID, telem.BatteryPct)
cmd := Waypoint{
Action: "return-to-home",
WaypointLat: 37.7749, // Home base coordinates
WaypointLon: -122.4194,
WaypointAlt: 30.0,
}
data, _ := json.Marshal(cmd)
cmdTopic := fmt.Sprintf("swarm.command.%s", telem.DroneID)
telemStream.Publish(cmdTopic, data)
}
// Display swarm status every 100 updates
if len(droneState) > 0 {
fmt.Printf("[SWARM] %d drones active\n", len(droneState))
}
}
}
NAT Traversal for Field Deployment
Field deployments introduce a network topology that lab setups do not have. Drones connect via cellular (4G/5G), ground robots via WiFi, and the ground station via a wired connection or a different WiFi network. These networks assign private IPs behind NAT, and the participants cannot directly reach each other.
Pilot handles this transparently with its three-stage NAT traversal:
- STUN discovery. Each drone discovers its public IP and port by querying the beacon server over UDP.
- Hole-punching. When a direct packet does not get through, the beacon coordinates a simultaneous hole-punch. Both peers send a UDP packet to each other's public endpoint at the same time, creating NAT mappings that allow bidirectional traffic. This works through the cone-type NATs common on consumer and carrier networks.
- Relay fallback. When hole-punching fails (symmetric NAT, common on enterprise networks and some cellular carriers), the beacon relays the encrypted traffic. That adds a trip through the beacon, and it works as long as both peers can reach the beacon.
# Drone on cellular (behind carrier-grade NAT)
pilotctl daemon start
# Direct path first, then hole-punch, then relay -- tried automatically
# No configuration needed on the drone
# Ground station on WiFi (behind home router NAT)
pilotctl daemon start
# Same: automatic NAT traversal
# Once they trust each other, both can communicate whatever the NAT
pilotctl ping 0:0000.0002.0001 # Ground station pings drone
This is critical for field deployment. You do not ask a farmer to configure port forwarding on the cellular modem attached to a survey drone. You do not ask a search-and-rescue team to set up a VPN before deploying robots in a disaster zone. The communication just works because NAT traversal is built into the protocol, not bolted on as a configuration step.
Comparison: Pilot vs ROS2/DDS vs MAVLink vs Zenoh
| Property | ROS2/DDS | MAVLink | Zenoh | Pilot |
|---|---|---|---|---|
| Discovery | Multicast SPDP | Heartbeat | Scouting (multicast) | Registry (unicast) |
| Transport | UDP multicast + unicast | Serial / UDP | UDP / TCP / QUIC | UDP unicast |
| WiFi compatibility | Poor (multicast issues) | Good (unicast) | Mixed (multicast scout) | Good (unicast only) |
| NAT traversal | No | No | Router-based | STUN + hole-punch + relay |
| Encryption | DDS Security (complex) | MAVLink 2 signing | TLS / QUIC | AES-256-GCM (built-in) |
| QoS/Reliability | Full DDS QoS | None (best-effort) | Reliability levels | Congestion control, flow control |
| Pub/Sub | Yes (DDS topics) | No (point-to-point) | Yes | Yes (event stream) |
| Ecosystem | Massive (ROS packages) | Autopilot-focused | Growing | Agent-focused |
| Target use case | General robotics | UAV telemetry/command | IoT / Edge / Robotics | Agent networks / Swarms |
ROS2/DDS has the largest ecosystem. If you need MoveIt for manipulation, Nav2 for navigation, or integration with hundreds of sensor drivers, ROS2 is the pragmatic choice despite its communication overhead. The communication layer is the price you pay for the ecosystem.
MAVLink is purpose-built for drone autopilots. It is extremely lightweight and well-suited for telemetry and command between a ground station and drones using standard autopilot firmware (ArduPilot, PX4). But it is point-to-point and has no pub/sub, no encryption, and no NAT traversal. For swarm coordination beyond basic telemetry, you need something on top of MAVLink.
Zenoh is the closest competitor for this use case. It has low overhead, supports pub/sub, and has a router-based approach to bridging networks. Its scouting mechanism still uses multicast by default (though it can be configured for unicast), and its NAT traversal relies on Zenoh routers rather than built-in hole-punching. For constrained environments, Zenoh is a strong option.
Pilot occupies the niche of zero-configuration swarm communication. No multicast, built-in NAT traversal, static binaries, and encrypted by default. It trades the rich QoS model of DDS for simplicity and the massive ecosystem of ROS2 for lightweight deployment.
Limitations: Not Real-Time, Not Safety-Critical
Honesty about limitations matters more than feature lists. Pilot is not suitable for everything in robotics:
- No hard real-time guarantees. Pilot uses UDP with congestion control and flow control, but it does not provide deterministic latency. For safety-critical control loops -- motor commands, emergency stops, collision avoidance at 100+ Hz -- you need a real-time transport like EtherCAT, ROS2 with real-time Linux, or direct serial communication. Pilot is for coordination (telemetry, task assignment, swarm planning), not for actuator control.
- No QoS profiles. DDS provides configurable QoS: reliable vs best-effort, transient-local vs volatile, deadline constraints, liveliness assertions. Pilot's event stream has none of these: events travel over the daemon's reliable stream, but nothing is stored, so a subscriber that is disconnected when an event is published never receives it. For telemetry at 10 Hz (where the next update replaces the previous one), this is acceptable. For critical commands, use
send-message(the data exchange port, 1001), where the receiver acknowledges each message it stores. - Registry dependency. Unlike DDS's decentralized multicast discovery, Pilot uses a registry for peer discovery. If the network is temporarily unreachable, new robots cannot discover peers, but existing connections continue working.
- No ROS2 integration. Pilot does not provide a ROS2 transport plugin. If your robot stack depends on ROS2 packages (Nav2, MoveIt, sensor drivers), you cannot replace DDS with Pilot. You can, however, run Pilot alongside ROS2 for swarm-level coordination while keeping ROS2 for intra-robot communication.
- Not for high-frequency control loops. The event stream is designed for coordination messages (telemetry, status updates, waypoints), not control loops. For servo commands or IMU fusion at 100+ Hz, use a dedicated real-time transport.
- 64-bit hosts only. Release builds cover Linux and macOS on amd64 and arm64. Microcontrollers such as the ESP32 cannot run the daemon; bridge them through a companion computer.
The practical architecture: Use ROS2 for intra-robot communication (sensors, actuators, navigation). Use Pilot for inter-robot communication (swarm telemetry, task assignment, coordination). Each layer does what it is good at.
Getting Started with a Robot Swarm
Set up a 3-robot swarm with a ground station and a hub:
# On every node: install pilotctl and pilot-daemon, then start the daemon
curl -fsSL https://pilotprotocol.network/install.sh | sh
pilotctl daemon start
# On the hub (always on): name it, then approve each node's handshake
pilotctl set-hostname swarm-hub
pilotctl pending
pilotctl approve <node_id>
# On the ground station
pilotctl set-hostname ground-station
pilotctl extras set-tags ground-station swarm-controller
pilotctl handshake <hub-address> "ground station for survey-team-alpha"
pilotctl subscribe <hub-address|hostname> swarm.telemetry
# On each robot (Raspberry Pi / Jetson with a 64-bit OS)
pilotctl set-hostname robot-01
pilotctl extras set-tags swarm-member robot ground-unit
pilotctl handshake <hub-address> "robot-01 in survey-team-alpha"
# Verify connectivity
pilotctl ping <hub-address|hostname>
# Start publishing telemetry
pilotctl publish <hub-address|hostname> swarm.telemetry --data '{"drone_id":"robot-01","lat":37.77,"lon":-122.42,"battery_pct":95}'
The communication stack -- daemon, registry client, NAT traversal, encryption -- is two static binaries, pilotctl and pilot-daemon, with no runtime dependencies. They need a registry and beacon: the public ones, or your own. Release builds include Linux arm64, and the install script picks the right one. To build from source for an arm64 robot instead:
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o pilotctl ./cmd/pilotctl
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o pilot-daemon ./cmd/daemon
# Copy both binaries to the robot
scp pilotctl pilot-daemon robot@192.168.1.42:/usr/local/bin/
No ROS2 installation. No DDS configuration. No multicast tuning. Two binaries that run on 64-bit Linux ARM devices and provide encrypted, NAT-traversing swarm communication out of the box.
Try Pilot Protocol
One UDP socket per daemon, no multicast storms. Coordinate robot swarms on constrained hardware with zero-configuration networking.
View on GitHub