← All Workshops

MudEngine Part 7: Save & Load

Step 7 / 10

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:

mud-engine/src/main.rs
#[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 },
}
🎯 Why rooms in the State message?

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:

  1. Single source of truth — the server defines the world; clients just display it
  2. 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.

mud-engine/src/main.rs
// ── 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 });
    })
}
💡 What changed from Part 6

The WebSocket handler now works with GAME_STATE instead of PLAYERS and EXITS:

OperationPart 6Part 7
Player joinPLAYERS.lock().unwrap().insert(...)GAME_STATE.lock().unwrap().players.insert(...) then save_game_state()
Movement validationIndex into EXITS[current] arraycan_move(&state, current, dir) — looks up room by ID
Movement updateWrite player.room = nextSame, then save_game_state()
Player leavePLAYERS.lock().unwrap().remove(&id)GAME_STATE.lock().unwrap().players.remove(&id) then save_game_state()
Room info for clientStatic ROOM_NAMES / ROOM_DESCSBuilt 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.

Step 7 / 10