← All Workshops
MudEngine Part 11: Peer-to-Peer (Hybrid)
Step 5 / 12
Create the P2P networking module
The P2P layer handles three things:
- Host mode — accepts incoming iroh connections, owns the
GameEngine, publishes state changes to a gossip topic - Client mode — dials the host's endpoint, subscribes to the gossip topic, sends input messages
- Relay mode — bridges WebSocket clients into the gossip topic
Create src/p2p.rs with the host and client logic:
mud-engine/src/p2p.rs
use std::sync::Arc; use iroh::{ Endpoint, RelayMode, endpoint::RemoteInfo, protocol::Router, }; use iroh_gossip::{Gossip, GossipEvent, GossipSender, TopicId}; use iroh_mdns_address_lookup::MdnsAddressLookup; use tokio::sync::{broadcast, mpsc}; use crate::game_engine::*; /// Topic identifier shared by all game clients. const GAME_TOPIC: &str = "mud-engine-game"; /// Start the host endpoint — owns the game engine, accepts peers, /// and publishes state changes to the gossip topic. pub async fn start_host( engine: Arc<GameEngine>, enable_relay: bool, ) -> anyhow::Result<(Endpoint, GossipSender<TopicId>, tokio::task::JoinHandle<()>)> { let secret_key = iroh::SecretKey::generate(); let mut builder = Endpoint::builder() .secret_key(secret_key.clone()) .relay_mode(RelayMode::Disabled) .address_lookup(MdnsAddressLookup::builder().advertise(true)); let endpoint = builder.bind().await?; let gossip = Gossip::builder().spawn(endpoint.clone()).await?; let router = Router::builder(endpoint.clone()) .accept(iroh_gossip::ALPN, gossip.clone()) .spawn() .await?; let topic = TopicId::from_string(GAME_TOPIC); let (mut sink, mut stream) = gossip.subscribe(topic, vec![]).await?.split(); // Spawn the message loop: receive gossip events and update game engine let engine_clone = engine.clone(); let handle = tokio::spawn(async move { while let Some(event) = stream.next().await { match event { GossipEvent::Received(msg) => { if let Ok(cmd) = serde_json::from_slice::<ClientMessage>(&msg.content) { handle_client_message(&engine_clone, &cmd, msg.delayed_sender_id()); } } _ => {} } } }); println!("🟢 Host endpoint ID: {}", secret_key.public()); Ok((endpoint, sink, handle)) } /// Create a client endpoint that dials the host and subscribes to gossip. pub async fn connect_to_host( host_endpoint_id: &str, ) -> anyhow::Result<(Endpoint, GossipSender<TopicId>, mpsc::Receiver<ServerMessage>)> { let secret_key = iroh::SecretKey::generate(); let host_id: iroh::PublicKey = host_endpoint_id.parse()?; let mut builder = Endpoint::builder() .secret_key(secret_key.clone()) .relay_mode(RelayMode::Disabled) .address_lookup(MdnsAddressLookup::builder().advertise(false)); let endpoint = builder.bind().await?; let gossip = Gossip::builder().spawn(endpoint.clone()).await?; let router = Router::builder(endpoint.clone()) .accept(iroh_gossip::ALPN, gossip.clone()) .spawn() .await?; let topic = TopicId::from_string(GAME_TOPIC); let (mut sink, mut stream) = gossip.subscribe(topic, vec![host_id]).await?.split(); let (tx, rx) = mpsc::channel(256); // Forward gossip messages to the game UI tokio::spawn(async move { while let Some(event) = stream.next().await { match event { GossipEvent::Received(msg) => { if let Ok(server_msg) = serde_json::from_slice::<ServerMessage>(&msg.content) { let _ = tx.send(server_msg).await; } } _ => {} } } }); Ok((endpoint, sink, rx)) } /// Handle an incoming client message on the host side. fn handle_client_message(engine: &GameEngine, msg: &ClientMessage, sender: iroh::PublicKey) { match msg { ClientMessage::Join { name } => { let id = sender.to_string(); engine.add_player(&id, name, 4); // broadcast PlayerJoined via gossip } ClientMessage::Move { direction } => { let id = sender.to_string(); engine.handle_move(&id, direction); } _ => {} } } /// Publish a server message to the gossip topic. pub async fn broadcast(sink: &GossipSender<TopicId>, msg: &ServerMessage) { let bytes = serde_json::to_vec(msg).unwrap(); let _ = sink.broadcast(bytes.into()).await; }
💡 Why RelayMode::Disabled?
We set RelayMode::Disabled because this workshop focuses on LAN play.
On a local network, mDNS discovery provides direct IP addresses — no relay needed.
If you want to play over the internet, change this to RelayMode::Default and
iroh will use its built-in relay servers for NAT hole-punching. The mDNS address
lookup still works for LAN fallback.
Step 5 / 12