← 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.
LazyLockinitialises the game state the first timeGAME_STATE.lock()is called, not at program start. This meansgame.tomlis loaded lazily, which works fine since the first WebSocket connection triggers the load.Mutexprotects the state across the threaded Tokio runtime. All game operations (join, move, leave, save) hold the lock briefly and then release it.std::fs::writeis synchronous and blocking, but sincegame.tomlis 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