← All Workshops

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

Step 5 / 12

Create the P2P networking module

The P2P layer handles three things:

  1. Host mode — accepts incoming iroh connections, owns the GameEngine, publishes state changes to a gossip topic
  2. Client mode — dials the host's endpoint, subscribes to the gossip topic, sends input messages
  3. 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