bevy_symbios_multiuser
A decentralized, low-latency multiplayer plugin for the Bevy engine. Combines ATProto for federated identity with WebRTC (via Matchbox) for peer-to-peer data transfer.
Overview
bevy_symbios_multiuser provides a plug-and-play networking crate that allows any Bevy app to support collaborative, real-time multiplayer without requiring a centralized, authoritative game server.
The architecture follows a Sovereign Broker pattern: clients authenticate with an ATProto PDS to obtain a JWT, present it to the relay during WebSocket signaling, and then communicate peer-to-peer over WebRTC data channels. The plugin accepts any serializable type T and exposes Bevy messages for broadcasting and receiving, completely decoupled from specific game logic.
Key Features
- Generic Message Bus — Define your own domain-specific protocol type
T: Serialize + Deserialize, and the plugin handles serialization (viabincode) and transport. - Dual Channels — Reliable (ordered, guaranteed) for state mutations and Unreliable (best-effort) for ephemeral presence data.
- ATProto Authentication — Federated identity via
com.atproto.server.createSessionfor Bluesky/ATProto-based auth. The JWT is passed to the relay via theAuthorizationheader (native) orSec-WebSocket-Protocolsubprotocol trick (WASM). - Sovereign Broker Relay — Optional signaling server (Axum + Tokio) with ATProto JWT verification, room-based peer isolation, and defense-in-depth hardening. When
auth_requiredis enabled, the relay resolves the signer's DID document (viaplc.directoryfordid:plc, or HTTPS fordid:web— domain-only DIDs use/.well-known/did.json, path-based DIDs use/{path}/did.json), extracts the#atprotosigning key (P-256/ES256 or secp256k1/ES256K), and cryptographically verifies the JWT signature. The URL path determines the room — peers in different rooms are fully isolated and cannot exchange signals. See Relay Hardening for the full security inventory. - Custom Signaller — A
matchbox_socket::Signallerimplementation (SymbiosSignallerBuilder) that bridges between the matchbox protocol and the relay's wire format, injecting the JWT during WebSocket upgrade. Supports static tokens (signaller_for_session), a refreshableTokenSource(signaller_with_token_source) for long-lived applications where ATProto access tokens expire between reconnects, and anonymous mode (signaller_anonymous) for unauthenticated connections. - Cross-Platform — Runs on native targets (via
async-tungstenite) and in the browser (viaws_stream_wasmon WASM). - Bevy-Native — Outbound messages use Bevy's
MessageWriter<Broadcast<T>>. Inbound messages are delivered viaNetworkQueue<T>andPeerStateQueue<T>resources, which are safe to drain from any schedule (Update,FixedUpdate, etc.) without risk of silent message loss. The queues are bounded (4,096 messages and 64 MiB total byte budget forNetworkQueue) to prevent memory exhaustion from a malicious flood — excess messages are dropped with a warning. - Single Plugin Per App — Only one
SymbiosMultiuserPlugin<T>instance should be added per Bevy app.SymbiosMultiuserConfig<T>,NetworkQueue<T>, andPeerStateQueue<T>are generic overT, but the underlyingMatchboxSocketresource is not — adding two plugin instances would cause them to share and overwrite the same socket, resulting in message theft and connection instability. - Deferred Connections — Use
SymbiosMultiuserPlugin::<T>::deferred()to register systems without opening a socket. The socket opens automatically on the first frame after aSymbiosMultiuserConfig<T>resource is inserted, letting developers connect after login or menu screens instead of at app launch. For full control (e.g. custom ICE servers for NAT traversal), useSymbiosMultiuserPlugin::<T>::with_config(config)to pass a completeSymbiosMultiuserConfig<T>at plugin registration time. - Dynamic Room Switching — Changing
room_urlin theSymbiosMultiuserConfig<T>resource (or removing the resource entirely) automatically tears down the existing socket and opens a new connection to the updated room on the next frame, without restarting the app. - Size-Limited Messages — All network payloads are capped at 1 MiB to prevent OOM from malicious length-prefixed data. Unreliable-channel messages are additionally capped at 1200 bytes (a conservative WebRTC MTU-safe limit) and silently dropped if oversized.
Quick Start
use *;
use *;
use ;
Architecture
┌────────────┐ ┌────────────┐
│ ATProto │ │ ATProto │
│ PDS │ │ PDS │
└─────┬──────┘ └─────┬──────┘
│ JWT │ JWT
│ ┌──────────────────┐ │
┌─────┴──────┐ │ Broker (Relay) │ ┌─────┴──────┐
│ Peer A │ JWT │ │ JWT │ Peer B │
│ (Bevy App) │────────►│ Axum + JWT │◄────────│ (Bevy App) │
└─────┬──────┘ │ Validation │ └─────┬──────┘
│ └──────────────────┘ │
│ │
│ │
└───────────────────────────────────────────────────┘
WebRTC P2P Data
- Authentication — Each peer authenticates with their ATProto PDS to obtain a JWT access token.
- Signaling — Peers connect to the relay via a room-specific URL (e.g.
wss://relay/my_room) and present the JWT during the WebSocket handshake. The URL path determines the room — peers only see and communicate with other peers in the same room. On native targets, the token is sent as anAuthorization: Bearerheader. On WASM targets, the token is sent via theSec-WebSocket-Protocolsubprotocol trick (the browserWebSocketAPI does not support custom headers; the relay echoes the selected subprotocol back per RFC 6455). A legacy?token=<jwt>query parameter fallback is also supported. Whenauth_requiredis enabled, the relay resolves the issuer's DID document (viaplc.directoryfordid:plc, or HTTPS fordid:web), extracts the#atprotosigning key, and cryptographically verifies the JWT signature (ES256/P-256 or ES256K/secp256k1). Whenservice_didis configured, the JWTaudclaim is also validated to prevent cross-service token replay. The authenticated DID becomes the peer's session identity. SDP offers/answers and ICE candidates are exchanged via the relay'sSignalEnvelopewire format. - P2P Transport — Once signaling completes, data flows directly between peers over WebRTC data channels.
- Message Bus — The
SymbiosMultiuserPlugin<T>serializes/deserializesTvia bincode (with a 1 MiB size limit). Outbound messages are sent viaMessageWriter<Broadcast<T>>. Inbound messages accumulate in aNetworkQueue<T>resource that the host app drains at its own pace.
Features
| Feature | Default | Description |
|---|---|---|
client |
Yes | ATProto authentication, custom signaller for authenticated relay connections |
tls |
Yes | Enables TLS (via rustls) for both reqwest HTTPS (PDS) and async-tungstenite WebSocket (wss://) connections |
relay |
No | Sovereign Broker relay with DID-based JWT signature verification (ES256 + ES256K), room isolation, atomic connection limits, SSRF-hardened DID resolution (100-client cap), message size caps, HTTP-level Slowloris protection, idle/handshake/write timeouts, server-side pings (WASM keep-alive), per-sender token-bucket rate limiting, per-target burst limiting, per-domain and global did:web fetch concurrency limiting, request coalescing, negative DID caching, JWT audience validation (service_did) (axum/tokio/p256/k256/moka/dashmap) |
Running the Relay Server
Relay Configuration
The RelayConfig struct accepts a service_did field for JWT audience validation. When set, the relay rejects tokens whose aud claim does not match, preventing cross-service token replay attacks:
let config = RelayConfig ;
Relay Hardening
The relay applies multiple independent layers of defense:
- HTTP timeout (10 s) — Slowloris protection: connections that do not complete the WebSocket upgrade within 10 seconds are dropped.
- Atomic connection limits —
max_peersslots are reserved before DID resolution to prevent TOCTOU bypasses. New connections receive HTTP 503 when the limit is reached. - Handshake slot budget — At most
max_peers / 4connections may be in the auth/DID-resolution phase simultaneously, preventing DID tarpit attacks from exhausting all slots. - Handshake timeout (15 s) — The authentication phase is capped at 15 seconds per connection.
- WebSocket message cap (64 KiB) — Incoming WebSocket frames are limited to 64 KiB.
- SSRF protection —
did:webdomains are resolved and validated against private/loopback IPs; the address is pinned via DNS to prevent rebinding. HTTP redirects are disabled. - DID document body limit (256 KiB) — Responses are streamed with an incremental size check; the fetch aborts before buffering an oversized payload.
- DID key cache (5-min TTL, 10 000 entries) — Resolved keys are cached using W-TinyLFU eviction.
moka::future::Cachecoalesces concurrent lookups for the same DID to prevent DDoS amplification. - Negative DID cache (60 s) — Failed resolutions are cached for 60 seconds to prevent DDoS reflection against DID hosting servers.
- Domain client cap (100) — Per-domain
reqwest::Clientinstances (DNS-pinned for SSRF) are capped at 100, bounding connection-pool and worker overhead. - Per-domain
did:webfetch concurrency (10) — At most 10 concurrent in-flight fetches perdid:webdomain. - Global
did:webfetch concurrency (50) — Total concurrentdid:webfetches across all domains. - Idle timeout (120 s) + server-side pings (30 s) — Idle connections are disconnected after 120 seconds. Server pings keep WASM clients alive (browsers cannot initiate WebSocket pings).
- WebSocket write timeout (5 s) — Each outbound write is capped at 5 seconds, preventing an attacker that never drains their TCP receive buffer from holding a connection slot while periodic pings suppress the idle timeout.
- Self-targeting rejection — SDP offers/answers addressed to the sender's own session ID are dropped.
- Control signal filtering — Clients cannot forge
PeerJoined/PeerLeftevents; only the relay originates these. - Invalid message disconnect (10 cumulative) — Peers that send 10 cumulative invalid messages (malformed JSON, binary frames, forged control signals) are disconnected.
- Peer ID length cap (512 bytes) —
peer_idfields inSignalEnvelopeare validated after deserialization. - Per-target backpressure (50 strikes) — When a target's relay channel (256 slots) is full, delivery silently stops after 50 consecutive channel-full strikes. Successful sends reset the counter; closed channels do not accumulate strikes so reconnected peers recover immediately.
- Per-target burst limit (64 msg/window) — Each sender may route at most 64 messages to the same target per rate window, preventing one sender from monopolising a target's relay channel and starving signals from other peers.
- Per-sender token-bucket rate limit (burst 500, refill 20/s) — Messages are dropped when the token budget is exhausted; the peer remains connected so that ICE/SDP retry logic can recover.
- Unique target cap (256/window) — Each sender may address at most 256 distinct targets per window; senders that exceed this are disconnected.
- JWT audience validation — When
service_didis set, the relay validates the JWTaudclaim to prevent cross-service token replay. - Room isolation — Cross-room signals are dropped; peers only see events from peers in the same room.
Running the Basic Chat Example
Running the Oasis Example
A multiplayer sandbox that demonstrates the full authentication flow: ATProto login, service token acquisition, TokenSourceRes setup, deferred plugin connection, and in-game identity display.
ATProto Authentication
The auth module provides functions to create and refresh ATProto sessions, and to obtain service auth tokens for relay authentication.
use ;
let client = new;
let credentials = AtprotoCredentials ;
let session = create_session.await?;
println!;
Important: The access_jwt from create_session is signed by the PDS's own service key, which third-party relays cannot verify. When connecting to a relay with auth_required = true, use get_service_auth to obtain a service auth token — this JWT is signed by the user's #atproto key (held by the PDS on their behalf) and can be verified by any relay that resolves the user's DID document:
// Obtain a service auth token for relay authentication.
// `aud` must match the relay's `service_did` if audience validation is enabled.
let service_token = get_service_auth.await?;
Wrap the service token in a TokenSourceRes resource. The plugin prefers this over AtprotoSession and reads the latest token on each reconnect:
use ;
use ;
let token_source: TokenSource = new;
app.insert_resource; // used for identity (DID, handle)
app.insert_resource; // used for relay authentication
app.add_plugins;
For games with a login screen, use the deferred constructor to register systems without opening a socket. Insert all resources later when ready:
// Deferred connection (session obtained after login):
app.add_plugins;
// Later, after login completes (e.g. polled from an async task result in an
// Update system — see examples/oasis.rs for a full implementation), insert
// these resources to open the connection:
let source: TokenSource = new;
commands.insert_resource;
commands.insert_resource;
commands.insert_resource;
Token Refresh for Long-Lived Apps
Both ATProto access tokens and service auth tokens are short-lived. For long-lived applications, refresh both when they expire and update the TokenSource with a new service auth token:
use ;
use ;
use ;
let token_source: TokenSource = new;
app.insert_resource;
// Later, when tokens are near expiry:
let new_session = refresh_session.await?;
let new_service_token = get_service_auth.await?;
*token_source.write.unwrap = Some;
Modules
| Module | Description |
|---|---|
plugin |
SymbiosMultiuserPlugin<T>, SymbiosMultiuserConfig<T> — the main Bevy plugin and its generic configuration |
messages |
Broadcast<T>, NetworkReceived<T>, NetworkQueue<T>, ChannelKind, PeerConnectionState, PeerStateChanged, PeerStateQueue<T> |
systems |
ECS systems for transmit, receive, and peer state polling; bincode_options() for serialization compatibility |
protocol |
Shared signaling wire format (SignalEnvelope, SignalPayload) |
auth |
ATProto session creation (create_session), refresh (refresh_session), and service auth token acquisition for relay authentication (get_service_auth) (feature: client) |
signaller |
Custom matchbox_socket::Signaller with JWT injection, refreshable TokenSource/TokenSourceRes for reconnects, and anonymous mode (feature: client) |
relay |
Sovereign Broker relay server with DID-based JWT verification (feature: relay) |
error |
SymbiosError error types |
Platform Support
The crate supports both native and WASM targets:
| Platform | WebSocket Transport | JWT Delivery |
|---|---|---|
| Native | async-tungstenite |
Authorization: Bearer <token> header |
| WASM | ws_stream_wasm |
Sec-WebSocket-Protocol subprotocol trick |
On WASM targets, the browser WebSocket API does not allow custom HTTP headers, so the JWT is sent by requesting subprotocols ["access_token", "<jwt>"] during the handshake. The relay extracts the token from the second element.
Compatibility
| Crate | Version |
|---|---|
bevy |
0.18 |
bevy_matchbox |
0.14 |
serde |
1.0 |
License
MIT