← All Workshops

MudEngine Part 7: Save & Load

Step 6 / 10

Replace Server State

This is the biggest change. We replace three static items (PLAYERS, BROADCAST, EXITS) with two statics (GAME_STATE, BROADCAST) and add load/save functions.

The old EXITS constant defined movement connections as room-index-based slices. The new code uses room IDs directly from the TOML data, so movement validation becomes a simple lookup: find the room by ID, then search its exits for the direction.

Replace the entire mod srv block with this:

mud-engine/src/main.rs
// ── Server-only state ──

#[cfg(feature = "server")]
mod srv {
    use std::collections::HashMap;
    use std::sync::{LazyLock, Mutex};
    use tokio::sync::broadcast;

    use super::*;

    /// The authoritative game state — rooms and connected players.
    /// Loaded from game.toml at startup, persisted on every change.
    pub static GAME_STATE: LazyLock<Mutex<GameState>> =
        LazyLock::new(|| Mutex::new(load_game_state("game.toml")));

    /// Broadcast channel — every connected WebSocket subscribes.
    pub static BROADCAST: LazyLock<broadcast::Sender<ServerMessage>> =
        LazyLock::new(|| {
            let (tx, _) = broadcast::channel(64);
            tx
        });

    /// Load `game.toml` and build the runtime `GameState`.
    /// Panics if the file is missing or malformed.
    fn load_game_state(path: &str) -> GameState {
        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: Vec<RoomData> = 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(),
            })
            .collect();

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

        GameState { rooms, players }
    }

    /// Serialize `GameState` back to `game.toml` on disk.
    /// Called after every player join, move, and leave.
    pub fn save_game_state(state: &GameState) {
        let toml_game = TomlGame {
            rooms: state
                .rooms
                .iter()
                .map(|r| TomlRoom {
                    id: r.id,
                    name: r.name.clone(),
                    description: r.description.clone(),
                    exits: r
                        .exits
                        .iter()
                        .map(|(d, dest)| TomlExit {
                            direction: d.clone(),
                            destination: *dest,
                        })
                        .collect(),
                })
                .collect(),
            players: state
                .players
                .values()
                .map(|p| TomlPlayer {
                    id: p.id.clone(),
                    name: p.name.clone(),
                    room: p.room,
                })
                .collect(),
        };
        let toml_str = toml::to_string_pretty(&toml_game).unwrap();
        std::fs::write("game.toml", toml_str).unwrap();
    }

    /// Check whether a movement is valid: does `from_room` have an exit
    /// in the given direction? Returns the destination room ID on success.
    pub fn can_move(state: &GameState, from_room: usize, direction: &str) -> Option<usize> {
        state
            .rooms
            .iter()
            .find(|r| r.id == from_room)
            .and_then(|r| {
                r.exits
                    .iter()
                    .find(|(d, _)| d == direction)
                    .map(|(_, dest)| *dest)
            })
    }
}
💡 Why LazyLock + Mutex instead of parking_lot?

std::sync::Mutex and std::sync::LazyLock are both part of std — no extra dependency needed.

  • LazyLock initialises the game state the first time GAME_STATE.lock() is called, not at program start. This means game.toml is loaded lazily, which works fine since the first WebSocket connection triggers the load.
  • Mutex protects the state across the threaded Tokio runtime. All game operations (join, move, leave, save) hold the lock briefly and then release it.
  • std::fs::write is synchronous and blocking, but since game.toml is tiny (~1 KB), the lock is held for under a millisecond — negligible in a Tokio context, and simpler than spawning a write task.

The save_game_state call happens inside the locked scope, right after the mutation. This guarantees the disk file is always consistent with the in-memory state.

Step 6 / 10