← All Workshops

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

Step 4 / 12

Extract game logic into a module

The game logic — GameState, RoomData, can_move(), update_quest(), check_quest_completion(), save_game_state() — currently lives inside #[cfg(feature = "server")] mod srv { .. }. Since we are removing the server feature entirely, this code needs to be available unconditionally to the host binary.

Create a new file src/game_engine.rs and move all game types and functions into it. The host binary will own an instance of GameEngine wrapped in a Mutex, just like the server did, but now it runs in-process alongside the Dioxus UI and the iroh endpoint.

mud-engine/src/game_engine.rs
use std::collections::HashMap;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};

// ── Shared types (moved from main.rs) ──

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct PlayerInfo {
    pub id: String,
    pub name: String,
    pub room: usize,
    pub items: Vec<String>,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct RoomInfo {
    pub id: usize,
    pub name: String,
    pub description: String,
    pub items: Vec<String>,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum ClientMessage {
    Join { name: String },
    Move { direction: String },
    Say { message: String },
    Take { item: String },
    Drop { item: String },
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum ServerMessage {
    State {
        players: Vec<PlayerInfo>,
        your_id: String,
        rooms: Vec<RoomInfo>,
        quest_active: bool,
        quest_completed: bool,
    },
    PlayerJoined(PlayerInfo),
    PlayerMoved(PlayerInfo),
    PlayerLeft { id: String },
    Chat { from: String, message: String, room: usize },
    ItemTaken { player_id: String, player_name: String, item: String },
    ItemDropped { player_id: String, player_name: String, item: String, room: usize },
    QuestEvent { active: bool, message: String },
    QuestCompleted { message: String },
}

// ── TOML types ──

#[derive(Deserialize)]
struct TomlExit {
    direction: String,
    destination: usize,
}

#[derive(Deserialize)]
struct TomlRoom {
    id: usize,
    name: String,
    description: String,
    exits: Vec<TomlExit>,
    items: Vec<String>,
}

#[derive(Deserialize)]
struct TomlPlayer {
    id: String,
    name: String,
    room: usize,
    items: Vec<String>,
}

#[derive(Deserialize)]
struct TomlGame {
    rooms: Vec<TomlRoom>,
    players: Vec<TomlPlayer>,
    quest: Option<TomlQuest>,
}

#[derive(Deserialize, Clone)]
struct TomlQuest {
    name: String,
    retrieval_item: String,
    completion_room: usize,
    guardians: Vec<TomlQuestGuardian>,
    reward: TomlQuestReward,
}

#[derive(Deserialize, Clone)]
struct TomlQuestGuardian {
    name: String,
    room_id: usize,
    label: String,
}

#[derive(Deserialize, Clone)]
struct TomlQuestReward {
    source_room_id: usize,
    target_room_id: usize,
    direction: String,
    target_name: String,
    target_description: String,
}

// ── Runtime types ──

#[derive(Clone)]
pub struct RoomData {
    pub id: usize,
    pub name: String,
    pub description: String,
    pub exits: Vec<(String, usize)>,
    pub items: Vec<String>,
}

#[derive(Clone)]
struct QuestStatus {
    config: TomlQuest,
    previously_active: bool,
    completed: bool,
}

#[derive(Clone)]
pub struct GameState {
    pub rooms: Vec<RoomData>,
    pub players: HashMap<String, PlayerInfo>,
    quest: Option<QuestStatus>,
}

// ── GameEngine ──

pub struct GameEngine {
    state: Mutex<GameState>,
}

impl GameEngine {
    pub fn load(path: &str) -> Self {
        let content = std::fs::read_to_string(path)
            .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
        let toml_game: TomlGame = toml::from_str(&content)
            .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e));

        let rooms = toml_game.rooms.iter().map(|r| RoomData {
            id: r.id,
            name: r.name.clone(),
            description: r.description.clone(),
            exits: r.exits.iter().map(|e| (e.direction.clone(), e.destination)).collect(),
            items: r.items.clone(),
        }).collect();

        let players = toml_game.players.iter().map(|p| {
            let pi = PlayerInfo {
                id: p.id.clone(), name: p.name.clone(),
                room: p.room, items: p.items.clone(),
            };
            (p.id.clone(), pi)
        }).collect();

        let quest = toml_game.quest.map(|config| QuestStatus {
            config,
            previously_active: false,
            completed: false,
        });

        GameEngine { state: Mutex::new(GameState { rooms, players, quest }) }
    }

    pub fn save(&self, path: &str) {
        // Serialization logic omitted for brevity — identical to Part 9
    }

    pub fn add_player(&self, id: &str, name: &str, room: usize) -> Vec<RoomInfo> {
        let mut state = self.state.lock().unwrap();
        state.players.insert(id.into(), PlayerInfo {
            id: id.into(), name: name.into(), room, items: vec![],
        });
        state.rooms.iter().map(|r| RoomInfo {
            id: r.id, name: r.name.clone(),
            description: r.description.clone(), items: r.items.clone(),
        }).collect()
    }

    pub fn remove_player(&self, id: &str) {
        let mut state = self.state.lock().unwrap();
        state.players.remove(id);
    }

    pub fn handle_move(&self, id: &str, direction: &str) -> (Option<(bool, String)>, Option<String>) {
        let mut state = self.state.lock().unwrap();
        let dir = match direction {
            "n" => "north", "s" => "south",
            "e" => "east", "w" => "west",
            d => d,
        };

        let moved = {
            let player = match state.players.get_mut(id) {
                Some(p) => p,
                None => return (None, None),
            };
            let current = player.room;

            // Check normal exits
            let next = state.rooms.iter()
                .find(|r| r.id == current)
                .and_then(|r| r.exits.iter().find(|(d, _)| d == dir))
                .map(|(_, dest)| *dest);

            // Check quest portal (down from room 4 when active)
            let next = next.or_else(|| {
                if dir == "down" && current == 4 {
                    let active = state.quest.as_ref()
                        .map(|q| q.previously_active).unwrap_or(false);
                    if active { Some(9) } else { None }
                } else {
                    None
                }
            });

            if let Some(room) = next {
                player.room = room;
                true
            } else {
                false
            }
        };

        if moved {
            let quest_event = self.update_quest_inner(&mut state);
            let completion = self.check_completion_inner(&mut state);
            (quest_event, completion)
        } else {
            (None, None)
        }
    }

    pub fn handle_take(&self, id: &str, item: &str) -> Option<String> {
        let mut state = self.state.lock().unwrap();
        let player = state.players.get_mut(id)?;
        let room_id = player.room;
        let room = state.rooms.iter_mut().find(|r| r.id == room_id)?;
        let pos = room.items.iter().position(|i| i == item)?;
        room.items.remove(pos);
        player.items.push(item.into());
        Some(self.check_completion_inner(&mut state).flatten()?)
    }

    // Quest logic — same as Part 9, adapted for GameEngine
    fn update_quest_inner(&self, state: &mut GameState) -> Option<(bool, String)> {
        let quest = state.quest.as_mut()?;
        if quest.completed { return None; }

        let all_occupied = quest.config.guardians.iter()
            .all(|g| state.players.values().any(|p| p.room == g.room_id));
        let now_active = all_occupied && state.players.len() >= 2;

        if now_active == quest.previously_active { return None; }
        quest.previously_active = now_active;

        if now_active {
            let reward = &quest.config.reward;
            if let Some(room) = state.rooms.iter_mut().find(|r| r.id == reward.source_room_id) {
                if !room.exits.iter().any(|(d, _)| d == &reward.direction) {
                    room.exits.push((reward.direction.clone(), reward.target_room_id));
                }
            }
            if let Some(room) = state.rooms.iter_mut().find(|r| r.id == reward.target_room_id) {
                if !room.exits.iter().any(|(d, _)| d == "up") {
                    room.exits.push(("up".into(), reward.source_room_id));
                }
            }
            Some((true, format!(
                "☀️🌙 The Twin Guardians are united! A shimmering portal opens!"
            )))
        } else {
            let reward = &quest.config.reward;
            if let Some(room) = state.rooms.iter_mut().find(|r| r.id == reward.source_room_id) {
                room.exits.retain(|(d, _)| d != &reward.direction);
            }
            let victims: Vec<String> = state.players.iter()
                .filter(|(_, p)| p.room == reward.target_room_id)
                .map(|(id, _)| id.clone()).collect();
            for vid in &victims {
                if let Some(p) = state.players.get_mut(vid) { p.room = reward.source_room_id; }
            }
            Some((false, format!("🌙☀️ The portal seals shut!")))
        }
    }

    fn check_completion_inner(&self, state: &mut GameState) -> Option<Option<String>> {
        let quest = state.quest.as_mut()?;
        if quest.completed { return Some(None); }
        let item = &quest.config.retrieval_item;
        let room_id = quest.config.completion_room;
        let on_floor = state.rooms.iter().any(|r| r.id == room_id && r.items.contains(item));
        let in_inv = state.players.values().any(|p| p.room == room_id && p.items.contains(item));
        if on_floor || in_inv {
            quest.completed = true;
            Some(Some(format!(
                "👑 King Aldric beams with joy! The Star Fragment is returned!"
            )))
        } else {
            Some(None)
        }
    }
}
🔍 What was extracted

The GameEngine struct wraps Mutex<GameState> and exposes safe methods:

  • add_player() / remove_player() — connection lifecycle
  • handle_move() — movement with quest integration
  • handle_take() — item pickup with quest completion check

Internally it calls the same update_quest_inner() and check_completion_inner() logic from Part 9, but no longer behind #[cfg(feature = "server")].

No server functions (#[get], #[post]), no Axum, no WebSocket upgrade handlers. The game logic is a plain Rust struct.

Step 4 / 12