MudEngine Part 7: Save & Load
Update ServerMessage and WebSocket Endpoint
The ServerMessage::State payload now includes room data so the client can render room names without hardcoded statics.
Add the rooms field to the State variant:
#[derive(Serialize, Deserialize, Debug, Clone)] enum ServerMessage { State { players: Vec<PlayerInfo>, your_id: String, rooms: Vec<RoomInfo>, // NEW — room names + descriptions }, PlayerJoined(PlayerInfo), PlayerMoved(PlayerInfo), PlayerLeft { id: String }, }
The client needs room names and descriptions to render the grid, sidebar, and description panel. Previously these came from static ROOM_NAMES and static ROOM_DESCS arrays compiled into the WASM bundle.
By sending them as part of the initial State message, we achieve two things:
- Single source of truth — the server defines the world; clients just display it
- Dynamic worlds — the TOML file can be edited without recompiling the client
The room data is included in the State message (sent once per connection), not in every broadcast. The client caches it in a Signal<HashMap<usize, RoomInfo>> for the component's lifetime.
// ── WebSocket endpoint ── #[get("/api/mud_ws?name")] async fn mud_ws( name: String, options: WebSocketOptions, ) -> Result<Websocket<ClientMessage, ServerMessage>> { let id = uuid::Uuid::new_v4().to_string(); let player_name = name.clone(); // 1. Register the new player and build room info for the client let room_infos: Vec<RoomInfo>; { let mut state = srv::GAME_STATE.lock().unwrap(); state.players.insert( id.clone(), PlayerInfo { id: id.clone(), name: name.clone(), room: 4, // Town Square }, ); srv::save_game_state(&state); room_infos = state .rooms .iter() .map(|r| RoomInfo { id: r.id, name: r.name.clone(), description: r.description.clone(), }) .collect(); } // 2. Subscribe to the broadcast channel let mut rx = srv::BROADCAST.subscribe(); // 3. Announce the new player to everyone let _ = srv::BROADCAST.send(ServerMessage::PlayerJoined(PlayerInfo { id: id.clone(), name: name.clone(), room: 4, })); // 4. Build the initial state snapshot for this new player let initial_state = { let state = srv::GAME_STATE.lock().unwrap(); ServerMessage::State { players: state.players.values().cloned().collect(), your_id: id.clone(), rooms: room_infos, } }; // 5. Upgrade the HTTP connection to a WebSocket options.on_upgrade(move |mut socket| async move { let _ = socket.send(initial_state).await; // ── Message loop ── loop { tokio::select! { msg = socket.recv() => { match msg { Ok(ClientMessage::Move { direction }) => { // Normalise short direction names let dir = match direction.as_str() { "n" => "north", "s" => "south", "e" => "east", "w" => "west", d => d, }; // Validate the move and persist let new_room = { let mut state = srv::GAME_STATE.lock().unwrap(); let player = state.players.get_mut(&id).unwrap(); let current = player.room; if let Some(next) = srv::can_move(&state, current, dir) { player.room = next; srv::save_game_state(&state); Some(next) } else { None } }; // Broadcast the move to EVERY client if let Some(room) = new_room { let _ = srv::BROADCAST.send( ServerMessage::PlayerMoved(PlayerInfo { id: id.clone(), name: player_name.clone(), room, }), ); } } Err(_) => break, } } msg = rx.recv() => { match msg { Ok(server_msg) => { if socket.send(server_msg).await.is_err() { break; } } Err(_) => break, } } } } // ── Cleanup on disconnect ── { let mut state = srv::GAME_STATE.lock().unwrap(); state.players.remove(&id); srv::save_game_state(&state); } let _ = srv::BROADCAST.send(ServerMessage::PlayerLeft { id }); }) }
The WebSocket handler now works with GAME_STATE instead of PLAYERS and EXITS:
| Operation | Part 6 | Part 7 |
|---|---|---|
| Player join | PLAYERS.lock().unwrap().insert(...) | GAME_STATE.lock().unwrap().players.insert(...) then save_game_state() |
| Movement validation | Index into EXITS[current] array | can_move(&state, current, dir) — looks up room by ID |
| Movement update | Write player.room = next | Same, then save_game_state() |
| Player leave | PLAYERS.lock().unwrap().remove(&id) | GAME_STATE.lock().unwrap().players.remove(&id) then save_game_state() |
| Room info for client | Static ROOM_NAMES / ROOM_DESCS | Built from state.rooms as Vec<RoomInfo> |
The key insight: every mutation is followed immediately by persistence. This is the simplest correct approach — no write queuing, no background task, no crash recovery. For a demo MUD with 9 rooms and a handful of players, it's plenty fast.