← All Workshops

MudEngine Part 11: Peer-to-Peer (Hybrid)

Step 6 / 12

The WebSocket relay

The relay bridges browser-based players into the iroh-gossip mesh. It is a standalone tokio task that runs alongside the host's game loop.

It has two jobs:

  1. Serve the WASM bundle so browser clients can load the game UI
  2. Accept WebSocket connections and pipe messages to/from iroh-gossip

Add this to src/p2p.rs behind the relay feature flag:

mud-engine/src/p2p.rs
/// Spawn a WebSocket relay that bridges browser clients into iroh-gossip.
/// Browser players connect via WebSocket; the relay pipes their messages
/// to the gossip topic and forwards gossip events back to them.
#[cfg(feature = "relay")]
pub async fn spawn_relay(
    gossip_sink: GossipSender<TopicId>,
    gossip_stream: broadcast::Receiver<ServerMessage>,
    relay_port: u16,
) -> anyhow::Result<()> {
    use futures_lite::StreamExt;
    use tokio::net::TcpListener;
    use tokio_tungstenite::accept_async;
    use tokio_tungstenite::tungstenite::Message;

    let listener = TcpListener::bind(format!("0.0.0.0:{}", relay_port)).await?;
    println!("🌐 Relay listening on ws://0.0.0.0:{}/", relay_port);

    loop {
        let (stream, _) = listener.accept().await?;
        let ws_stream = accept_async(stream).await?;
        let (mut ws_sender, mut ws_receiver) = ws_stream.split();

        let sink = gossip_sink.clone();
        let mut rx = gossip_stream.resubscribe();

        // Forward: gossip → this WebSocket client
        tokio::spawn(async move {
            while let Ok(msg) = rx.recv().await {
                let bytes = serde_json::to_vec(&msg).unwrap();
                if ws_sender.send(Message::Binary(bytes.into())).await.is_err() {
                    break;
                }
            }
        });

        // Forward: this WebSocket client → gossip
        tokio::spawn(async move {
            while let Some(Ok(msg)) = ws_receiver.next().await {
                if let Message::Binary(data) = msg {
                    let _ = sink.broadcast(data).await;
                }
            }
        });
    }
}
🔍 Relay dependencies

The relay uses two additional crates:

  • tokio-tungstenite — WebSocket server implementation
  • futures-lite — StreamExt for the WebSocket receiver

Add them to Cargo.toml only when the relay feature is enabled:

🎯 Relay vs. fullstack server

The relay is not a game server. It:

  • Does not own GameState
  • Does not validate moves or quest logic
  • Does not persist anything
  • Does not know what a "room" or "item" is

Its only job is transport: WebSocket bytes in → gossip bytes out, and vice versa. This is the key insight of the hybrid architecture: the relay is ~30 lines of plumbing, not a fullstack application.

Step 6 / 12