Mesh (v3) Guide¶
Protocol v3 adds WebRTC mesh signaling: the server can finalize a room into a peer-to-peer session and ferry the WebRTC handshake between peers. This guide covers when to use mesh, how to wire a WebRTC backend, and how to let the SDK drive the whole handshake for you.
Requires the mesh feature
The mesh helpers (WebRtcDriver, MeshController, MeshSession) live behind
the mesh Cargo feature. MeshController additionally needs the
tokio-runtime feature.
The generation-bearing protocol-v3 mesh APIs in this guide are all published as of client release 0.14.0; the changelog says which release added each.
When and why to use mesh¶
By default Signal Fish runs on the relay floor (v2): the server relays all game traffic. That is simple and always works, but every packet makes a round trip through the server. Mesh (v3) lets peers exchange game data directly over WebRTC data channels — lower latency and less server load — while keeping the relay as a universal fallback.
Use mesh when you have (or are willing to integrate) a WebRTC stack and want direct peer-to-peer data. Stay on the relay floor if you want zero extra dependencies. The choice is per-client: see Protocol Versioning.
Signaling-only boundary¶
The SDK bundles no WebRTC stack — no ICE agent, no SDP munging, no TURN, no media. It ferries WebRTC signals (offers, answers, ICE candidates) and orchestrates the handshake; you bring the WebRTC backend. This keeps the core crate small and lets you pick the backend that fits your platform.
Enabling mesh¶
Opt in when building the config:
use signal_fish_client::SignalFishConfig;
let config = SignalFishConfig::new("mb_app_abc123").enable_mesh();
enable_mesh() advertises protocol v3 with the webrtc/relay transports and
the mesh/host/relay topologies. The server still chooses the actual
topology and may keep the room on the relay floor; the client only declares what
it can fulfill.
Don't advertise what you can't fulfill
Only call enable_mesh() if you actually bridge the resulting signaling
events to a WebRTC implementation (or use MeshController). See
Protocol Versioning.
Implementing WebRtcDriver¶
WebRtcDriver is the seam between the SDK and your WebRTC backend. It is
synchronous and poll-based so it fits the async client, the polling client,
and sans-I/O backends like str0m.
use signal_fish_client::protocol::{IceServer, PlayerId, SessionGeneration};
use signal_fish_client::webrtc::{DriverEvent, WebRtcDriver};
use signal_fish_client::PeerSignal;
pub trait WebRtcDriver {
fn set_ice_servers(&mut self, servers: &[IceServer]);
fn connect(&mut self, peer: PlayerId, generation: Option<SessionGeneration>, initiate: bool);
fn on_signal(&mut self, peer: PlayerId, generation: Option<SessionGeneration>, signal: PeerSignal);
fn send(&mut self, peer: PlayerId, data: &[u8]);
fn disconnect(&mut self, peer: PlayerId);
fn poll(&mut self) -> Option<DriverEvent>; // drain your backend's non-blocking event queue
}
| Method | What you do |
|---|---|
set_ice_servers |
Configure your peer connections' STUN/TURN servers. |
connect |
Begin a connection owned by generation. If initiate is true, create an offer; otherwise wait. Obey initiate verbatim. |
on_signal |
Apply a remote signal only to the connection for the supplied generation. |
send |
Send application bytes over peer's data channel. |
disconnect |
Tear down the connection to peer, and retire its queued, unpollled output — it belongs to the abandoned connection, and a replacement plan without a session generation cannot fence it. |
poll |
Pump your stack's I/O and return the next DriverEvent (see below), or None when idle. |
poll returns a DriverEvent:
| Variant | Meaning |
|---|---|
Signal { peer, generation, signal } |
A locally-produced offer/answer/ICE bound to its plan generation. |
Connected { peer, generation } |
The generation-owned data channel opened. |
Disconnected { peer, generation } |
The generation-owned data channel closed or failed. |
Data { peer, generation, data } |
Application bytes arrived on that generation's channel. |
Obey initiate — never both-offer
The server assigns the deterministic WebRTC offerer (lesser-UUID-initiates in
mesh; clients-initiate-to-host in host topology) and tells each client via the
initiate flag (SessionPlan.peers[].initiate) and you_initiate
(NewPeer). The client must copy these verbatim — never compute who
offers, never both-offer. This avoids WebRTC "glare".
The optional set_ready_waker latency hook¶
By default the controller pumps your driver on every signaling event and on a periodic timer (the pump interval). That means trickle ICE or inbound data produced between signaling events can wait up to one pump interval before surfacing.
If your backend can signal when it has output ready, implement the optional
set_ready_waker method (available with the tokio-runtime feature) and call
MeshWaker::wake() to have the controller pump on demand — eliminating that
latency:
use signal_fish_client::webrtc::{MeshWaker, WebRtcDriver};
impl WebRtcDriver for MyDriver {
// ... other methods ...
fn set_ready_waker(&mut self, waker: MeshWaker) {
// Store it; call `waker.wake()` whenever poll() has new output ready
// (e.g. a trickled ICE candidate or received data).
self.waker = Some(waker);
}
}
wake() is cheap and safe to call from any thread and as often as you like (extra
wakes at worst cause a redundant, cheap poll). Implementing it is entirely
optional — drivers that don't override it simply fall back to the periodic
timer.
Using MeshController (batteries-included)¶
MeshController drives the entire v3 handshake against your driver on top of
an async SignalFishClient — it is unavailable on tokio-runtime-less builds
(the SDK ships no WASM profile that drives the async client) and there is no
polling-client variant: SignalFishPollingClient
users integrate their own driver with the manual
send_signal_for_generation / report_transport_status choreography described
below. On a WebRTC SessionPlan/NewPeer it calls
connect(peer, generation, initiate) and set_ice_servers; on a received
signal it feeds on_signal; it
relays the driver's outbound signals via the client (a signal the command
queue refuses is buffered and retried, in order, until the queue accepts it —
congestion never drops a signal, and a buffered signal survives recv()
cancellation; queue congestion or a pending directed room operation retains it
until admission can resume, and it is discarded only if the connection ends or
its target peer's handshake is torn down or its generation changes, so nothing
stale is retagged), reports TransportStatus on the 0↔1 connected boundary, tears
down peers on re-election / PlayerLeft / RoomLeft / Disconnected, and
surfaces a clean MeshEvent stream. If the bounded client command queue is
full—or a directed room operation temporarily fences room-scoped commands—at
a transport-status boundary, the controller retains one coalescing latest-state
report and retries it on later pumps; this never blocks signaling events or
driver data, and room/connection teardown discards obsolete status.
Signaling-stream closure is the terminal controller boundary; a Disconnected
event clears the dead connection's data plane but — with a
ReconnectPolicy continuing the
same client — the controller keeps draining and rebuilds on the fresh
connection's plan. Before returning None for
stream closure, the controller clears its session view and disconnects every
known driver peer. It is then fused: later recv() calls return None without
polling the driver, and send_to ignores data. Explicit shutdown() performs
the same peer cleanup before gracefully stopping the signaling client.
When integrating a driver without MeshController, relay its output with
send_signal_for_generation(peer, generation, signal) (or the corresponding
raw method). The client atomically refuses the send if a replacement plan has
already arrived, preventing old driver output from being relabeled with the new
generation.
use signal_fish_client::webrtc::{MeshController, MeshEvent};
use signal_fish_client::{JoinRoomParams, SignalFishConfig, SignalFishEvent};
// `start` ensures v3 + WebRTC + a P2P topology while preserving compatible choices.
let mut mesh = MeshController::start(transport, SignalFishConfig::new("app"), my_driver);
let mut start_requested = false;
while let Some(event) = mesh.recv().await {
match event {
MeshEvent::Signaling(sig) => match *sig {
SignalFishEvent::Authenticated { .. } =>
mesh.join_room(JoinRoomParams::new("my-game", "Alice"))?,
// Ready-state updates repeat — request the start only once.
SignalFishEvent::LobbyStateChanged { all_ready: true, .. }
if !start_requested =>
{
start_requested = true;
mesh.start_game()?;
}
_ => {}
},
MeshEvent::PeerConnected(peer) => {
// The data channel to `peer` is open — send a packet.
mesh.send_to(peer, b"hello peer");
}
MeshEvent::PeerDisconnected(peer) => println!("peer {peer} left"),
MeshEvent::Data { from, data } => {
println!("{} bytes from {from}", data.len());
}
}
}
mesh.shutdown().await;
| API | Purpose |
|---|---|
MeshController::start(transport, config, driver) |
Build the controller; ensure v3, WebRTC, and a P2P topology while preserving compatible custom choices. |
recv().await -> Option<MeshEvent> |
Drive the handshake and yield the next high-level event. A returned Disconnected has already cleared the session and disconnected known peers; stream closure fuses the controller (None forever), while a configured reconnect policy keeps it draining across reconnect rounds. |
send_to(peer, &[u8]) |
Send application bytes to a peer over its data channel; ignored after the controller becomes terminal. |
with_pump_interval(Duration) |
Tune the periodic driver pump (default 20 ms). |
join_room / set_ready / start_game / leave_room / client() |
Room-lifecycle delegations to the inner client. |
shutdown().await |
Disconnect every known driver peer, then gracefully stop the signaling client. |
MeshEvent has four variants: Signaling(Box<SignalFishEvent>) (every
underlying event, passed through verbatim), PeerConnected(PlayerId),
PeerDisconnected(PlayerId), and Data { from, data }.
Send-ness and !Send drivers
MeshController<D> is Send when D is, so the recv() loop can run on a
spawned task. A !Send driver (e.g. a browser RTCPeerConnection wrapper)
must be driven on the current task instead.
Tuning the pump interval¶
Between signaling events the controller pumps the driver on a timer to surface trickle ICE and inbound data. The default is 20 ms; lower it for snappier trickle ICE, raise it to reduce idle wakeups:
use std::time::Duration;
let mut mesh = MeshController::start(transport, config, my_driver)
.with_pump_interval(Duration::from_millis(10));
If your driver implements set_ready_waker,
output surfaces immediately regardless of the pump interval, which then only acts
as a safety net.
ICE pre-gather¶
To shorten the time-to-connect, the server can deliver STUN/TURN servers
early — in the ice_servers field on RoomJoined / Reconnected, during
the lobby wait — so your WebRTC stack can begin gathering candidates before the
SessionPlan arrives. Feed these into set_ice_servers as soon as you get them
(MeshController does this for you). When the SessionPlan later carries its own
ice_servers, those supersede the pre-gathered set; an empty plan
authoritatively clears the pre-gathered set — plans replace the ICE list
wholesale, they never merge with it.
Transport-status reporting¶
report_transport_status(transport, connected) tells the server whether your
WebRTC data path is up. The server fans it out to peers as PeerTransportStatus
and uses it for fallback decisions. With MeshController this is automatic: it
reports TransportStatus(WebRtc, true) on the first peer to connect (the 0→1
edge) and TransportStatus(WebRtc, false) when the last peer disconnects (the
1→0 edge) — never one report per peer.
Manual choreography without MeshController (polling / WASM)¶
On a tokio-runtime-less build (WASM), combine SignalFishPollingClient with
the mesh feature's MeshSession tracker and drive your WebRtcDriver
yourself from your game loop. Everything needed is public API; the polling
client already performs the wire fencing internally: outbound signals are
refused with StaleSessionGeneration if a replacement plan arrived, and in
generation-carrying sessions stale-generation inbound signals never surface
(legacy generationless pairs can only fence by peer retirement, which re-opens
when a fresh plan re-pairs the peer). Your loop owns four duties the controller
would otherwise do:
- Fold and drive. Drain
poll()events, fold them withMeshSession::apply, and choreograph the driver exactly like the controller:set_ice_serversfromRoomJoined/Reconnected/SessionPlan(plans replace the list wholesale),connect(peer, generation, initiate)for each planned peer (re-connect when the plan changes its generation or role),on_signalforSignalReceived, anddisconnect— retiring the peer's queued driver output — when the plan drops the peer, onPlayerLeft, onReconnected(the old plan is fenced immediately; the fresh one re-drives), on room exits, and onDisconnected. - Relay driver output. Poll your driver and send every
DriverEvent::Signalwithsend_signal_for_generation(peer, generation, signal). ASendBufferFull/RoomOperationPendingrefusal means retry the same event later (a lost offer or ICE candidate stalls the handshake); any other refusal —StaleSessionGeneration,SessionPlanUnavailable,NotInRoom,WrongRoomRole,ProtocolUnsupported,NotConnected— is terminal for that signal: discard it and stop relaying for the session. Do not pop further driver output past a to-be-retried signal if you want wire order preserved. - Report the 0↔1 edge. Track how many peers report an open channel and
call
report_transport_status(TransportKind::WebRtc, connected)when that count crosses between zero and nonzero. Retry only while the command queue refuses (SendBufferFull/RoomOperationPending), keeping the newest state; once the room exits or the client disconnects, drop the pending edge — status is coalescible, so only the latest live report matters. - Retire dead-round output. On every
disconnect, drop the peer's queued, unpollled driver output (the seam contract). A legacy plan pair withoutgenerations cannot fence rounds, so this is what keeps a staleConnected/Datafrom surfacing after a reconnect.
MeshSession remains the source of truth for topology, generation, host, and
peer liveness; the polling client's snapshot() exposes the negotiated
generation and transport when you need them outside the tracker.
Fallback to relay¶
The relay is always the floor. Every SessionPlan carries a fallback field,
which is always TransportKind::Relay. If WebRTC fails or is never
established, traffic falls back to relaying through the server — the connection
never breaks just because a peer-to-peer path didn't form. Reporting transport
status (above) is what lets the server make these fallback decisions.
Reconnect behavior¶
After a reconnect, the server rebuilds the mesh session by re-sending a fresh,
live SessionPlan. Server 0.8 does not place ProtocolInfo, SessionPlan,
Signal, or NewPeer in Reconnected.missed_events; the client rejects those
non-replayable nested variants and waits for the fresh top-level plan.
Treat each SessionPlan as a full replacement
A SessionPlan fully replaces the peer set — replace, never merge. This
is how host re-election and topology changes work. Peers absent from the new
plan are dropped. When generation changes, every surviving physical pair
is also disconnected and rebuilt even if its initiate role is unchanged.
Queued and late driver outputs from the prior generation are discarded —
including when the plans carry no generation at all, because
WebRtcDriver::disconnect retires each torn-down peer's queued output.
Direct and relay plans are not WebRTC
MeshController disconnects its WebRTC state for Direct and Relay
plans and never passes those peers to WebRtcDriver. Read
MeshSession::direct_endpoint() to implement a direct socket separately;
relay remains the universal fallback.
Tracking state by hand: MeshSession¶
If you don't want the full MeshController, MeshSession is a zero-dependency,
no-I/O state tracker. Fold every event into it with apply(&event) -> bool (which
returns whether the view changed) and read the accessors (topology,
generation, transport, host, direct_endpoint, peers, ice_servers,
is_p2p). Peer connected state describes only the transport selected by the
current plan; status reports for other transports are ignored. It handles late joins,
host re-election, PlayerLeft removal, and reconnect replay idempotently — but it
contains no WebRTC and does no signaling. You still "obey the server": every
initiate flag is copied verbatim.
Integrating a real backend¶
Map your backend's primitives onto WebRtcDriver:
- str0m (sans-I/O, native — the recommended backend). Own a
str0m::Rtcplus a UDP socket per peer.connect(peer, true)creates an offer viasdp_api()and emitsDriverEvent::Signal { Offer };on_signalapplies remote SDP oradd_remote_candidate;polldoes a non-blocking UDP read intortc.handle_input, then drainsrtc.poll_outputintoSignal(trickled ICE),Connected,Data, andTransmit(write UDP).sendwrites to the data channel. - web-sys (browser/WASM). Wrap
RtcPeerConnection:connect(_, true)→create_offer→set_local_description→ emit viapoll;on_signal→set_remote_description/add_ice_candidate;onicecandidatecallbacks queueDriverEvent::Signal;ondatachannel/onmessagequeueData. This driver is!Send, so driveMeshController::recv()on the current task. - webrtc-rs. Works via manual signaling, but is Tokio-coupled and heavier; prefer str0m for new native drivers.
The backend must keep any raw UDP inside its WebRTC stack and emit
DriverEvent::Data only for assembled data-channel messages. A sans-I/O stack
accepts those packets from the driver, while managed browser/runtime stacks own
their sockets internally. ICE/DTLS/SCTP own peer authentication,
fragmentation/reassembly, and the channel's configured ordering/reliability.
MeshController does not parse datagrams, and the relay accountability state
machine does not authenticate or sequence MeshEvent::Data.
See also¶
mesh_session.rs— a runnable end-to-end demo.- Protocol Versioning — negotiation, the fail-fast guard, migration.
- Events: Mesh Events — the four v3 events.
- Protocol Types —
Topology,TransportKind,SessionPlanPayload,PeerSignal.