# Run many rooms on one server

**For:** Rust developers who have one plaza room working and want a lobby that runs many of them in one process.

You will build a small card club with `plaza_lobby`. Each table is a room with its own controller, spawned by a factory you write. The lobby lists the tables, places players at them and refuses a full one. A quick-match queue pairs players and fills empty seats with bots. Seat reservations and one-use tickets make sure the player the lobby placed is the one who sits down. Then a wallet follows a player from table to table and a reaper collects a table that has closed. Everything runs in one process on plaza's in-process transport, so there are no ports to open.

Before you start: you need Rust with Cargo. plaza comes from crates.io as `plaza = "0"`.

## Set up the project

Start a new binary crate.

```sh
cargo new rooms
cd rooms
```

`plaza` holds the controller and the in-process transport each table runs on. `plaza_lobby` holds the room directory and the blocks around it. Your `[dependencies]` section should look like this:

```toml title="Cargo.toml"
[dependencies]
async-trait = "0.1"
parking_lot = "0.12"
plaza = "0"
plaza_lobby = "0"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
```

The project grows to four source files. `src/table.rs` holds one table's rules, `src/club.rs` is the glue between the lobby and its tables and `src/main.rs` runs each scenario. `src/wallets.rs` arrives in a later step.

## Spawn rooms from a factory

A table is an ordinary plaza room: one controller that owns the table's state and applies one input at a time. For now it seats whoever connects and tells them so. Write `src/table.rs`:

```rust title="src/table.rs"
use async_trait::async_trait;
use plaza::session::TargetedOp;
use plaza::state_logic::{LogicInput, LogicOutput, StateLogic, StateLogicError};
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum TableOp {
    Seated,
}

#[derive(Clone, Debug, Default)]
pub struct TableState {
    pub players: Vec<u64>,
}

pub struct TableLogic;

#[async_trait]
impl StateLogic<TableOp, u64, TableState> for TableLogic {
    async fn process_input(
        &self,
        state: &mut TableState,
        input: LogicInput<TableOp, u64>,
    ) -> Result<LogicOutput<TableOp, u64>, StateLogicError> {
        match input {
            LogicInput::AgentJoined { agent } => {
                let Some(player) = agent.id_cloned() else {
                    return Ok(LogicOutput::none());
                };
                state.players.push(player);
                Ok(vec![TargetedOp::new_system_to(player, vec![TableOp::Seated])].into())
            }
            LogicInput::AgentLeft { agent_id } => {
                state.players.retain(|player| *player != agent_id);
                Ok(LogicOutput::none())
            }
            _ => Ok(LogicOutput::none()),
        }
    }
}
```

The lobby cannot know how a room of your game is built, so you tell it by implementing `RoomFactory`. Its `spawn_room` gets a room id the lobby has already chosen and the settings the room was asked for. It builds a session and a controller, spawns the controller and wraps the pieces in an `InProcessRoomHandle`. Write `src/club.rs`:

```rust title="src/club.rs"
use std::sync::Arc;

use async_trait::async_trait;
use plaza::controller::StateControllerBuilder;
use plaza::session::InProcessSession;
use plaza_lobby::{
    InMemoryLobbyManager, InProcessRoomHandle, LobbyError, RoomFactory, RoomHandle, RoomId,
    RoomMetadata, RoomSettings,
};

use crate::table::{TableLogic, TableOp, TableState};

pub type Lobby = InMemoryLobbyManager<Club>;

pub struct Club;

#[async_trait]
impl RoomFactory for Club {
    type CustomGameSettings = ();
    type GameOp = TableOp;
    type GameID = u64;
    type GameStateType = TableState;

    async fn spawn_room(
        &self,
        room_id: RoomId,
        settings: &RoomSettings<()>,
    ) -> Result<Arc<dyn RoomHandle<u64, ()>>, LobbyError> {
        let name = settings.name.clone().unwrap_or_default();
        let session = InProcessSession::new();
        let (commands, controller) = StateControllerBuilder::without_snapshots(
            Arc::new(TableLogic),
            session.clone(),
            TableState::default(),
        )
        .build();
        let task = tokio::spawn(controller.run());

        let metadata = RoomMetadata {
            room_id,
            name: name.clone(),
            game_mode: settings.game_mode.clone(),
            current_players: 0,
            max_players: settings.max_players,
            has_password: settings.password_hash.is_some(),
            max_one_way_ms: None,
            custom_game_settings_summary: (),
        };
        let handle = InProcessRoomHandle::new(
            room_id,
            metadata,
            commands.clone(),
            task,
            format!("inproc://{name}"),
            settings.password_hash.clone(),
        );
        Ok(Arc::new(handle))
    }
}
```

`CustomGameSettings` is `()` because these tables take no settings beyond the lobby's own. The handle carries the room's `RoomMetadata`, which is what a client is shown in a listing. It also carries an endpoint saying where to connect. A server on `plaza_session` would put a real URL there. These tables run on `InProcessSession`, so the endpoint is only a label.

`spawn_room` returns the handle as `Arc<dyn RoomHandle<u64, ()>>`. That type names neither the table's op type nor its state type, which is what lets the lobby hold rooms of any game and would let a room run in another process later.

`InMemoryLobbyManager` is the lobby itself. `handle_create_room_request` calls your factory and registers the room it returns. Write `src/main.rs`:

```rust title="src/main.rs"
mod club;
mod table;

use std::sync::Arc;

use plaza_lobby::{LobbyError, RoomId, RoomSettings};

use crate::club::{Club, Lobby};

const HOUSE: u64 = 0;

async fn open(lobby: &Lobby, name: &str, seats: u32) -> Result<RoomId, LobbyError> {
    let settings = RoomSettings {
        name: Some(name.to_owned()),
        game_mode: "hearts".to_owned(),
        max_players: seats,
        is_private: false,
        password_hash: None,
        custom_game_settings: (),
    };
    let created = lobby.handle_create_room_request(&HOUSE, settings).await?;
    Ok(created.room_id)
}

fn show(lobby: &Lobby) {
    let mut rooms = lobby.list_rooms(None);
    rooms.sort_by(|a, b| a.name.cmp(&b.name));
    for room in rooms {
        println!(
            "  {:<8} {:<7} {}/{}",
            room.name, room.game_mode, room.current_players, room.max_players
        );
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let club = Arc::new(Club);
    let lobby = Lobby::new(club.clone());

    let red = open(&lobby, "red", 2).await?;
    open(&lobby, "blue", 2).await?;
    show(&lobby);

    if let Some(room) = lobby.room(&red) {
        println!("red is reached at {}", room.session_endpoint_info());
    }
    Ok(())
}
```

`list_rooms` reads from a `HashMap`, so `show` sorts by name to print in the same order every time. Run it:

```shell-session
$ cargo run
  blue     hearts  0/2
  red      hearts  0/2
red is reached at inproc://red
```

## Join a room and fill it

Joining is two steps. The lobby authorizes the player and returns the room's endpoint, then the player connects to the room itself. The lobby never carries any game traffic.

To connect a player, the club needs the room's session. The lobby only holds the anonymous handle, so the club keeps its own map from room id to everything the factory built. That map also solves a second problem. The lobby checks capacity against `current_players` in the room's metadata, which the application keeps current. `refresh` asks each table how many players it holds with `query_with` and writes the answer back with `update_player_count_in_metadata`. Replace `src/club.rs`:

```rust title="src/club.rs"
use std::collections::HashMap;
use std::sync::Arc;

use async_trait::async_trait;
use parking_lot::Mutex;
use plaza::agent::Agent;
use plaza::controller::{CommandSender, StateControllerBuilder, query_with};
use plaza::session::InProcessSession;
use plaza::session::in_process::ClientInbox;
use plaza_lobby::{
    InMemoryLobbyManager, InProcessRoomHandle, JoinRoomOutcomePayload, JoinRoomRequestPayload,
    LobbyError, RoomFactory, RoomHandle, RoomId, RoomMetadata, RoomSettings,
};

use crate::table::{TableLogic, TableOp, TableState};

pub type Lobby = InMemoryLobbyManager<Club>;

pub struct Table {
    name: String,
    session: Arc<InProcessSession<TableOp, u64>>,
    commands: CommandSender<TableOp, u64, TableState>,
    handle: Arc<InProcessRoomHandle<TableOp, u64, TableState, ()>>,
}

pub struct Club {
    tables: Mutex<HashMap<RoomId, Arc<Table>>>,
}

impl Club {
    pub fn new() -> Self {
        Self {
            tables: Mutex::default(),
        }
    }

    fn table(&self, room: RoomId) -> Option<Arc<Table>> {
        self.tables.lock().get(&room).cloned()
    }

    pub async fn refresh(&self) {
        let tables: Vec<Arc<Table>> = self.tables.lock().values().cloned().collect();
        for table in tables {
            if let Ok(taken) = query_with(&table.commands, |s| s.players.len()).await {
                table.handle.update_player_count_in_metadata(taken as u32);
            }
        }
    }

    pub async fn place(
        &self,
        lobby: &Lobby,
        player: u64,
        room: RoomId,
    ) -> Result<JoinRoomOutcomePayload, LobbyError> {
        self.refresh().await;
        let request = JoinRoomRequestPayload {
            room_id: room,
            password_attempt: None,
            measured_one_way_ms: None,
        };
        lobby
            .handle_join_room_request(&player, Agent::new_human(player), &request)
            .await
    }

    pub async fn dial(&self, room: RoomId, player: u64) -> Option<Guest> {
        let table = self.table(room)?;
        let (_conn, inbox) = table.session.connect(Agent::new_human(player)).await.ok()?;
        Some(Guest {
            player,
            inbox,
            table,
        })
    }
}

#[async_trait]
impl RoomFactory for Club {
    type CustomGameSettings = ();
    type GameOp = TableOp;
    type GameID = u64;
    type GameStateType = TableState;

    async fn spawn_room(
        &self,
        room_id: RoomId,
        settings: &RoomSettings<()>,
    ) -> Result<Arc<dyn RoomHandle<u64, ()>>, LobbyError> {
        let name = settings.name.clone().unwrap_or_default();
        let session = InProcessSession::new();
        let (commands, controller) = StateControllerBuilder::without_snapshots(
            Arc::new(TableLogic),
            session.clone(),
            TableState::default(),
        )
        .build();
        let task = tokio::spawn(controller.run());

        let metadata = RoomMetadata {
            room_id,
            name: name.clone(),
            game_mode: settings.game_mode.clone(),
            current_players: 0,
            max_players: settings.max_players,
            has_password: settings.password_hash.is_some(),
            max_one_way_ms: None,
            custom_game_settings_summary: (),
        };
        let handle = InProcessRoomHandle::new(
            room_id,
            metadata,
            commands.clone(),
            task,
            format!("inproc://{name}"),
            settings.password_hash.clone(),
        );
        let handle = Arc::new(handle);

        let table = Table {
            name,
            session,
            commands,
            handle: handle.clone(),
        };
        self.tables.lock().insert(room_id, Arc::new(table));
        Ok(handle)
    }
}

pub struct Guest {
    player: u64,
    inbox: ClientInbox<TableOp, u64>,
    table: Arc<Table>,
}

impl Guest {
    pub async fn hear(&mut self) {
        if let Ok(message) = self.inbox.recv().await {
            println!(
                "player {} at {}: {:?}",
                self.player, self.table.name, message.ops
            );
        }
    }
}
```

`handle_join_room_request` finds the room, checks the password if there is one, checks capacity and finally asks the room to accept the player. The room checks capacity again in `accept_authorized_player`, because the room may have filled between the two checks. `place` refreshes the counts before it asks, so both checks see the current number.

Replace `src/main.rs`. Two players take both seats at red and a third one tries:

```rust title="src/main.rs" {25-26,39,44-53,55-57}
mod club;
mod table;

use std::sync::Arc;

use plaza_lobby::{LobbyError, RoomId, RoomSettings};

use crate::club::{Club, Lobby};

const HOUSE: u64 = 0;

async fn open(lobby: &Lobby, name: &str, seats: u32) -> Result<RoomId, LobbyError> {
    let settings = RoomSettings {
        name: Some(name.to_owned()),
        game_mode: "hearts".to_owned(),
        max_players: seats,
        is_private: false,
        password_hash: None,
        custom_game_settings: (),
    };
    let created = lobby.handle_create_room_request(&HOUSE, settings).await?;
    Ok(created.room_id)
}

async fn show(club: &Club, lobby: &Lobby) {
    club.refresh().await;
    let mut rooms = lobby.list_rooms(None);
    rooms.sort_by(|a, b| a.name.cmp(&b.name));
    for room in rooms {
        println!(
            "  {:<8} {:<7} {}/{}",
            room.name, room.game_mode, room.current_players, room.max_players
        );
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let club = Arc::new(Club::new());
    let lobby = Lobby::new(club.clone());
    let red = open(&lobby, "red", 2).await?;
    open(&lobby, "blue", 2).await?;

    let mut seated = Vec::new();
    for player in [1, 2] {
        let outcome = club.place(&lobby, player, red).await?;
        let endpoint = outcome.room_session_endpoint.unwrap_or_default();
        println!("player {player} sent to {endpoint}");
        let mut guest = club.dial(red, player).await.ok_or("no such table")?;
        guest.hear().await;
        seated.push(guest);
    }
    show(&club, &lobby).await;

    match club.place(&lobby, 3, red).await {
        Ok(_) => println!("player 3 placed"),
        Err(error) => println!("player 3 refused: {error}"),
    }
    Ok(())
}
```

Run it:

```shell-session
$ cargo run
player 1 sent to inproc://red
player 1 at red: [Seated]
player 2 sent to inproc://red
player 2 at red: [Seated]
  blue     hearts  0/2
  red      hearts  2/2
player 3 refused: Joining room failed: Room is full.
```

The listing shows red as full and the lobby refuses player 3 before anything reaches the table.

## Pair players with quick match

Some players would rather be paired than pick a table. `MatchQueue` is the bookkeeping for that. It holds no timers and spawns nothing: you pass it the current time on every call, in whatever unit you choose. This program passes fixed values so it runs the same way every time. A real server passes the lobby's own clock from its time steps.

`MatchQueue::new(2, at(5))` forms matches of two and waits five seconds before giving up on a full match. `drain_ready` returns every match that can start. When the player who has waited longest runs out of patience, it forms a match with the empty seats counted as `bots`, rather than leaving that player waiting. Each formed match gets a table made for it.

The club is unchanged. Replace `src/main.rs`:

```rust title="src/main.rs" {5,7,9,12,39-55,57-59,65-66,68-73,75-81,83-85}
mod club;
mod table;

use std::sync::Arc;
use std::time::Duration;

use plaza_lobby::{Formed, LobbyError, MatchQueue, RoomId, RoomSettings};

use crate::club::{Club, Guest, Lobby};

const HOUSE: u64 = 0;
const FIRST_BOT: u64 = 1000;

async fn open(lobby: &Lobby, name: &str, seats: u32) -> Result<RoomId, LobbyError> {
    let settings = RoomSettings {
        name: Some(name.to_owned()),
        game_mode: "hearts".to_owned(),
        max_players: seats,
        is_private: false,
        password_hash: None,
        custom_game_settings: (),
    };
    let created = lobby.handle_create_room_request(&HOUSE, settings).await?;
    Ok(created.room_id)
}

async fn show(club: &Club, lobby: &Lobby) {
    club.refresh().await;
    let mut rooms = lobby.list_rooms(None);
    rooms.sort_by(|a, b| a.name.cmp(&b.name));
    for room in rooms {
        println!(
            "  {:<8} {:<7} {}/{}",
            room.name, room.game_mode, room.current_players, room.max_players
        );
    }
}

async fn start(
    club: &Club,
    lobby: &Lobby,
    name: &str,
    formed: Formed<u64>,
) -> Result<Vec<Guest>, Box<dyn std::error::Error>> {
    let room = open(lobby, name, formed.size() as u32).await?;
    let bots = (0..formed.bots as u64).map(|n| FIRST_BOT + n);
    let mut seated = Vec::new();
    for player in formed.players.into_iter().chain(bots) {
        club.place(lobby, player, room).await?;
        let mut guest = club.dial(room, player).await.ok_or("no such table")?;
        guest.hear().await;
        seated.push(guest);
    }
    Ok(seated)
}

fn at(seconds: u64) -> Duration {
    Duration::from_secs(seconds)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let club = Arc::new(Club::new());
    let lobby = Lobby::new(club.clone());
    let mut queue: MatchQueue<u64, Duration> = MatchQueue::new(2, at(5));
    let mut matches = Vec::new();

    queue.enqueue(3, at(0));
    queue.enqueue(4, at(1));
    for formed in queue.drain_ready(at(1)) {
        println!("at 1s: {formed:?}");
        matches.push(start(&club, &lobby, "match-1", formed).await?);
    }

    queue.enqueue(5, at(2));
    let formed = queue.drain_ready(at(4));
    println!(
        "at 4s: {} formed, player 5 waiting since {:?}",
        formed.len(),
        queue.waiting_since(&5)
    );

    for formed in queue.drain_ready(at(8)) {
        println!("at 8s: {formed:?}");
        matches.push(start(&club, &lobby, "match-2", formed).await?);
    }
    show(&club, &lobby).await;
    Ok(())
}
```

`start` opens a table sized for the match and seats the players, followed by one bot id per empty seat. The bots here only take their seats. What a bot plays like is up to you.

Run it:

```shell-session
$ cargo run
at 1s: Formed { players: [3, 4], bots: 0, timed_out: false }
player 3 at match-1: [Seated]
player 4 at match-1: [Seated]
at 4s: 0 formed, player 5 waiting since Some(2s)
at 8s: Formed { players: [5], bots: 1, timed_out: true }
player 5 at match-2: [Seated]
player 1000 at match-2: [Seated]
  match-1  hearts  2/2
  match-2  hearts  2/2
```

Players 3 and 4 form a full match the moment the second one arrives. Player 5 is alone and still waiting at 4 seconds. By 8 seconds player 5 has waited longer than five seconds, so the queue forms a match with one bot and `timed_out` set.

## Hold the seat and hand out a ticket

Two things are still wrong. The table seats whoever connects, so it cannot tell a player the lobby placed from a passer-by. And `dial` takes the player id from the caller, so a client could name somebody else's id and take their seat.

The first fix is a reservation. The lobby holds a seat for the player it placed and the table spends that hold when the player connects. The hold lives in the table's own state, in a `SeatReservations`. The lobby reaches it through `RoomHandle::reserve_seat` and `withdraw_seat`. Those name no game type, so the factory tells the handle how this table spells a reservation in its own ops. Replace `src/table.rs`:

```rust title="src/table.rs" {4,9-12,14-17,23,36-50,55-62,68}
use async_trait::async_trait;
use plaza::session::TargetedOp;
use plaza::state_logic::{LogicInput, LogicOutput, StateLogic, StateLogicError};
use plaza_lobby::SeatReservations;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum TableOp {
    Reserve { player: u64 },
    Withdraw { player: u64 },
    Seated(Seat),
}

#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub enum Seat {
    Player,
    Spectator,
}

#[derive(Clone, Debug, Default)]
pub struct TableState {
    pub players: Vec<u64>,
    pub reserved: SeatReservations<u64>,
}

pub struct TableLogic;

#[async_trait]
impl StateLogic<TableOp, u64, TableState> for TableLogic {
    async fn process_input(
        &self,
        state: &mut TableState,
        input: LogicInput<TableOp, u64>,
    ) -> Result<LogicOutput<TableOp, u64>, StateLogicError> {
        match input {
            LogicInput::AgentOps { source, ops } => {
                let system = source.is_system();
                for op in ops {
                    match op {
                        TableOp::Reserve { player } if system => {
                            state.reserved.reserve(player);
                        }
                        TableOp::Withdraw { player } if system => {
                            state.reserved.withdraw(&player);
                        }
                        _ => {}
                    }
                }
                Ok(LogicOutput::none())
            }
            LogicInput::AgentJoined { agent } => {
                let Some(player) = agent.id_cloned() else {
                    return Ok(LogicOutput::none());
                };
                let seat = if state.reserved.consume(&player) {
                    state.players.push(player);
                    Seat::Player
                } else {
                    Seat::Spectator
                };
                let seated = TableOp::Seated(seat);
                Ok(vec![TargetedOp::new_system_to(player, vec![seated])].into())
            }
            LogicInput::AgentLeft { agent_id } => {
                state.players.retain(|player| *player != agent_id);
                Ok(LogicOutput::none())
            }
            LogicInput::TimeStep { .. } => Ok(LogicOutput::none()),
        }
    }
}
```

`Reserve` and `Withdraw` are only accepted from a system source, which is how the handle submits them. A client that could send them could seat itself. An `OpGuard` could do the same screening before the rules run. `consume` spends the hold once, so a second connection with the same id arrives as a spectator. `AgentLeft` leaves the holds alone.

The second fix is a ticket. When the lobby places a player it issues a one-use token and the table's route resolves the player from that token, never from what the client says. Replace `src/club.rs`:

```rust title="src/club.rs" {3,13-14,30,37,48-49,67,69,71-72,74-75,78-83,85-88,90-92,94,144-147}
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use parking_lot::Mutex;
use plaza::agent::Agent;
use plaza::controller::{CommandSender, StateControllerBuilder, query_with};
use plaza::session::InProcessSession;
use plaza::session::in_process::ClientInbox;
use plaza_lobby::{
    InMemoryLobbyManager, InProcessRoomHandle, JoinRoomOutcomePayload, JoinRoomRequestPayload,
    LobbyError, MapTicketRegistry, RoomFactory, RoomHandle, RoomId, RoomMetadata, RoomSettings,
    TicketStore,
};

use crate::table::{TableLogic, TableOp, TableState};

pub type Lobby = InMemoryLobbyManager<Club>;

pub struct Table {
    name: String,
    session: Arc<InProcessSession<TableOp, u64>>,
    commands: CommandSender<TableOp, u64, TableState>,
    handle: Arc<InProcessRoomHandle<TableOp, u64, TableState, ()>>,
}

pub struct Club {
    tables: Mutex<HashMap<RoomId, Arc<Table>>>,
    tickets: MapTicketRegistry<u64>,
}

impl Club {
    pub fn new() -> Self {
        Self {
            tables: Mutex::default(),
            tickets: MapTicketRegistry::with_expiry(Duration::from_secs(20)),
        }
    }

    fn table(&self, room: RoomId) -> Option<Arc<Table>> {
        self.tables.lock().get(&room).cloned()
    }

    pub async fn refresh(&self) {
        let tables: Vec<Arc<Table>> = self.tables.lock().values().cloned().collect();
        for table in tables {
            let taken = query_with(&table.commands, |s| s.players.len() + s.reserved.count()).await;
            if let Ok(taken) = taken {
                table.handle.update_player_count_in_metadata(taken as u32);
            }
        }
    }

    pub async fn place(
        &self,
        lobby: &Lobby,
        player: u64,
        room: RoomId,
    ) -> Result<JoinRoomOutcomePayload, LobbyError> {
        self.refresh().await;
        let request = JoinRoomRequestPayload {
            room_id: room,
            password_attempt: None,
            measured_one_way_ms: None,
        };
        let mut outcome = lobby
            .handle_join_room_request(&player, Agent::new_human(player), &request)
            .await?;

        let handle = lobby.room(&room).ok_or(LobbyError::RoomNotFound(room))?;
        handle.reserve_seat(&player).await?;

        outcome.player_game_token = Some(self.tickets.issue(player, room));
        Ok(outcome)
    }

    pub async fn cancel(&self, lobby: &Lobby, player: u64, room: RoomId, token: &str) {
        self.tickets.revoke(token);
        if let Some(handle) = lobby.room(&room) {
            let _ = handle.withdraw_seat(&player).await;
        }
    }

    pub async fn dial(&self, room: RoomId, token: &str) -> Option<Guest> {
        let ticket = self.tickets.redeem(token, &room)?;
        self.connect(room, ticket.player).await
    }

    pub async fn watch(&self, room: RoomId, viewer: u64) -> Option<Guest> {
        self.connect(room, viewer).await
    }

    async fn connect(&self, room: RoomId, player: u64) -> Option<Guest> {
        let table = self.table(room)?;
        let (_conn, inbox) = table.session.connect(Agent::new_human(player)).await.ok()?;
        Some(Guest {
            player,
            inbox,
            table,
        })
    }
}

#[async_trait]
impl RoomFactory for Club {
    type CustomGameSettings = ();
    type GameOp = TableOp;
    type GameID = u64;
    type GameStateType = TableState;

    async fn spawn_room(
        &self,
        room_id: RoomId,
        settings: &RoomSettings<()>,
    ) -> Result<Arc<dyn RoomHandle<u64, ()>>, LobbyError> {
        let name = settings.name.clone().unwrap_or_default();
        let session = InProcessSession::new();
        let (commands, controller) = StateControllerBuilder::without_snapshots(
            Arc::new(TableLogic),
            session.clone(),
            TableState::default(),
        )
        .build();
        let task = tokio::spawn(controller.run());

        let metadata = RoomMetadata {
            room_id,
            name: name.clone(),
            game_mode: settings.game_mode.clone(),
            current_players: 0,
            max_players: settings.max_players,
            has_password: settings.password_hash.is_some(),
            max_one_way_ms: None,
            custom_game_settings_summary: (),
        };
        let handle = InProcessRoomHandle::new(
            room_id,
            metadata,
            commands.clone(),
            task,
            format!("inproc://{name}"),
            settings.password_hash.clone(),
        )
        .with_reservations(
            |player| TableOp::Reserve { player },
            |player| TableOp::Withdraw { player },
        );
        let handle = Arc::new(handle);

        let table = Table {
            name,
            session,
            commands,
            handle: handle.clone(),
        };
        self.tables.lock().insert(room_id, Arc::new(table));
        Ok(handle)
    }
}

pub struct Guest {
    player: u64,
    inbox: ClientInbox<TableOp, u64>,
    table: Arc<Table>,
}

impl Guest {
    pub async fn hear(&mut self) {
        if let Ok(message) = self.inbox.recv().await {
            println!(
                "player {} at {}: {:?}",
                self.player, self.table.name, message.ops
            );
        }
    }
}
```

`with_reservations` in the factory teaches the handle the two ops. `place` now reserves the seat through the room's handle and issues a ticket into `player_game_token`. The client then dials with that ticket.

`dial` redeems the ticket and connects whoever the ticket names. `redeem` checks the room before it spends the ticket, so a ticket shown at the wrong table is refused and stays valid. `cancel` revokes a ticket and withdraws its hold, for a placement that will never be used. `watch` connects without a ticket and gets a spectator seat. `refresh` now counts held seats as taken, so the lobby does not place a second player into a seat that is promised.

`MapTicketRegistry::with_expiry` drops a ticket that is not used within 20 seconds. The reservations here never lapse, because `SeatReservations::default` holds a seat until it is consumed or withdrawn. If you give the reservations an expiry with `SeatReservations::with_expiry`, keep the ticket's window shorter than the reservation's. Otherwise a player can arrive holding a spent ticket and no seat.

`issue` mints tickets from a counter, `t0`, `t1` and so on. That stops a client naming another player, but anyone can guess the next ticket. A server facing untrusted clients mints its own signed, expiring token and records it with `issue_with`.

Replace `src/main.rs`:

```rust title="src/main.rs" {6,8,41-42,44-56,59-65,67-68}
mod club;
mod table;

use std::sync::Arc;

use plaza_lobby::{LobbyError, RoomId, RoomSettings};

use crate::club::{Club, Lobby};

const HOUSE: u64 = 0;

async fn open(lobby: &Lobby, name: &str, seats: u32) -> Result<RoomId, LobbyError> {
    let settings = RoomSettings {
        name: Some(name.to_owned()),
        game_mode: "hearts".to_owned(),
        max_players: seats,
        is_private: false,
        password_hash: None,
        custom_game_settings: (),
    };
    let created = lobby.handle_create_room_request(&HOUSE, settings).await?;
    Ok(created.room_id)
}

async fn show(club: &Club, lobby: &Lobby) {
    club.refresh().await;
    let mut rooms = lobby.list_rooms(None);
    rooms.sort_by(|a, b| a.name.cmp(&b.name));
    for room in rooms {
        println!(
            "  {:<8} {:<7} {}/{}",
            room.name, room.game_mode, room.current_players, room.max_players
        );
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let club = Arc::new(Club::new());
    let lobby = Lobby::new(club.clone());
    let red = open(&lobby, "red", 2).await?;
    let blue = open(&lobby, "blue", 2).await?;

    let outcome = club.place(&lobby, 1, red).await?;
    let token = outcome.player_game_token.unwrap_or_default();
    println!("player 1 holds ticket {token}");
    if club.dial(blue, &token).await.is_none() {
        println!("blue refuses ticket {token}");
    }
    let mut ann = club
        .dial(red, &token)
        .await
        .ok_or("red refused the ticket")?;
    ann.hear().await;
    if club.dial(red, &token).await.is_none() {
        println!("red refuses ticket {token} a second time");
    }

    let outcome = club.place(&lobby, 2, red).await?;
    let token = outcome.player_game_token.unwrap_or_default();
    println!("player 2 holds ticket {token}");
    show(&club, &lobby).await;
    club.cancel(&lobby, 2, red, &token).await;
    println!("player 2 cancelled");
    show(&club, &lobby).await;

    let mut passer_by = club.watch(red, 9).await.ok_or("no such table")?;
    passer_by.hear().await;
    show(&club, &lobby).await;
    Ok(())
}
```

Run it:

```shell-session
$ cargo run
player 1 holds ticket t0
blue refuses ticket t0
player 1 at red: [Seated(Player)]
red refuses ticket t0 a second time
player 2 holds ticket t1
  blue     hearts  0/2
  red      hearts  2/2
player 2 cancelled
  blue     hearts  0/2
  red      hearts  1/2
player 9 at red: [Seated(Spectator)]
  blue     hearts  0/2
  red      hearts  1/2
```

Blue refuses red's ticket and red still accepts it afterwards. The ticket works once. Player 2's hold counts against red's capacity until the club cancels it. The passer-by is let in to watch but takes no seat, so red stays at one of two.

## Move a player between rooms

A player who moves tables should keep what they won. That value cannot live on the `Agent`, which is identity only. It cannot live in a table's state either, because leaving the table throws that state away. It lives in a registry that the club and every table share, keyed by the player's id. Write `src/wallets.rs`:

```rust title="src/wallets.rs"
use std::collections::HashMap;

use parking_lot::Mutex;

#[derive(Default)]
pub struct Wallets {
    balances: Mutex<HashMap<u64, u64>>,
}

impl Wallets {
    pub fn add(&self, player: u64, coins: u64) -> u64 {
        let mut balances = self.balances.lock();
        let balance = balances.entry(player).or_default();
        *balance += coins;
        *balance
    }
}
```

Add `mod wallets;` to `src/main.rs`. A seated player can now claim ten coins, which go into the shared wallet. Replace `src/table.rs`:

```rust title="src/table.rs" {1,9,15,17,32-34,46,55-64,68}
use std::sync::Arc;

use async_trait::async_trait;
use plaza::session::TargetedOp;
use plaza::state_logic::{LogicInput, LogicOutput, StateLogic, StateLogicError};
use plaza_lobby::SeatReservations;
use serde::{Deserialize, Serialize};

use crate::wallets::Wallets;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum TableOp {
    Reserve { player: u64 },
    Withdraw { player: u64 },
    Claim,
    Seated(Seat),
    Paid { balance: u64 },
}

#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub enum Seat {
    Player,
    Spectator,
}

#[derive(Clone, Debug, Default)]
pub struct TableState {
    pub players: Vec<u64>,
    pub reserved: SeatReservations<u64>,
}

pub struct TableLogic {
    pub wallets: Arc<Wallets>,
}

#[async_trait]
impl StateLogic<TableOp, u64, TableState> for TableLogic {
    async fn process_input(
        &self,
        state: &mut TableState,
        input: LogicInput<TableOp, u64>,
    ) -> Result<LogicOutput<TableOp, u64>, StateLogicError> {
        match input {
            LogicInput::AgentOps { source, ops } => {
                let system = source.is_system();
                let mut out = Vec::new();
                for op in ops {
                    match op {
                        TableOp::Reserve { player } if system => {
                            state.reserved.reserve(player);
                        }
                        TableOp::Withdraw { player } if system => {
                            state.reserved.withdraw(&player);
                        }
                        TableOp::Claim => {
                            let Some(player) = source.id_cloned() else {
                                continue;
                            };
                            if state.players.contains(&player) {
                                let balance = self.wallets.add(player, 10);
                                let paid = TableOp::Paid { balance };
                                out.push(TargetedOp::new_system_to(player, vec![paid]));
                            }
                        }
                        _ => {}
                    }
                }
                Ok(out.into())
            }
            LogicInput::AgentJoined { agent } => {
                let Some(player) = agent.id_cloned() else {
                    return Ok(LogicOutput::none());
                };
                let seat = if state.reserved.consume(&player) {
                    state.players.push(player);
                    Seat::Player
                } else {
                    Seat::Spectator
                };
                let seated = TableOp::Seated(seat);
                Ok(vec![TargetedOp::new_system_to(player, vec![seated])].into())
            }
            LogicInput::AgentLeft { agent_id } => {
                state.players.retain(|player| *player != agent_id);
                Ok(LogicOutput::none())
            }
            LogicInput::TimeStep { .. } => Ok(LogicOutput::none()),
        }
    }
}
```

The club creates the wallets and hands them to every table it spawns. In `src/club.rs`, add `use crate::wallets::Wallets;` and change the session import to `use plaza::session::{ConnectionId, InProcessSession};`. Then give `Club` a `wallets` field:

```rust title="src/club.rs"
pub struct Club {
    tables: Mutex<HashMap<RoomId, Arc<Table>>>,
    tickets: MapTicketRegistry<u64>,
    wallets: Arc<Wallets>,
}
```

```rust title="src/club.rs"
pub fn new() -> Self {
    Self {
        tables: Mutex::default(),
        tickets: MapTicketRegistry::with_expiry(Duration::from_secs(20)),
        wallets: Arc::default(),
    }
}
```

In `spawn_room`, build the table's logic with the shared wallets:

```rust title="src/club.rs"
let logic = Arc::new(TableLogic {
    wallets: self.wallets.clone(),
});
let (commands, controller) = StateControllerBuilder::without_snapshots(
    logic,
    session.clone(),
    TableState::default(),
)
.build();
```

A guest needs to send ops and to leave, so `Guest` keeps its connection id. Change `connect` and `Guest`:

```rust title="src/club.rs"
async fn connect(&self, room: RoomId, player: u64) -> Option<Guest> {
    let table = self.table(room)?;
    let (conn, inbox) = table.session.connect(Agent::new_human(player)).await.ok()?;
    Some(Guest {
        player,
        conn,
        inbox,
        table,
    })
}
```

```rust title="src/club.rs"
pub struct Guest {
    player: u64,
    conn: ConnectionId,
    inbox: ClientInbox<TableOp, u64>,
    table: Arc<Table>,
}

impl Guest {
    pub async fn hear(&mut self) {
        if let Ok(message) = self.inbox.recv().await {
            println!(
                "player {} at {}: {:?}",
                self.player, self.table.name, message.ops
            );
        }
    }

    pub async fn send(&self, op: TableOp) {
        let me = Agent::new_human(self.player);
        self.table.session.client_send(me, vec![op]).await;
    }

    pub async fn leave(self) {
        self.table.session.disconnect(&self.player, self.conn).await;
    }
}
```

A move between tables happens in a fixed order. The club places the player at the new table first, which reserves the seat. Only then does the old connection close. The old table sees an `AgentLeft` after the new seat was promised, which is harmless because a departure never cancels a hold. The same order matters when the move stays inside one table: a spectator who takes a seat at the table they were watching closes their watching connection after the reservation was made. If `AgentLeft` withdrew holds, that player would come back as a spectator. Only the club cancels a placement, through `cancel`.

Replace `src/main.rs`:

```rust title="src/main.rs" {3,7,10,39-41,50-51,53-54,56-62,64-70,72-73,75}
mod club;
mod table;
mod wallets;

use std::sync::Arc;

use plaza_lobby::{JoinRoomOutcomePayload, LobbyError, RoomId, RoomSettings};

use crate::club::{Club, Lobby};
use crate::table::TableOp;

const HOUSE: u64 = 0;

async fn open(lobby: &Lobby, name: &str, seats: u32) -> Result<RoomId, LobbyError> {
    let settings = RoomSettings {
        name: Some(name.to_owned()),
        game_mode: "hearts".to_owned(),
        max_players: seats,
        is_private: false,
        password_hash: None,
        custom_game_settings: (),
    };
    let created = lobby.handle_create_room_request(&HOUSE, settings).await?;
    Ok(created.room_id)
}

async fn show(club: &Club, lobby: &Lobby) {
    club.refresh().await;
    let mut rooms = lobby.list_rooms(None);
    rooms.sort_by(|a, b| a.name.cmp(&b.name));
    for room in rooms {
        println!(
            "  {:<8} {:<7} {}/{}",
            room.name, room.game_mode, room.current_players, room.max_players
        );
    }
}

fn ticket(outcome: JoinRoomOutcomePayload) -> String {
    outcome.player_game_token.unwrap_or_default()
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let club = Arc::new(Club::new());
    let lobby = Lobby::new(club.clone());
    let red = open(&lobby, "red", 2).await?;
    let blue = open(&lobby, "blue", 2).await?;

    let token = ticket(club.place(&lobby, 1, red).await?);
    let mut ann = club.dial(red, &token).await.ok_or("ticket refused")?;
    ann.hear().await;
    ann.send(TableOp::Claim).await;
    ann.hear().await;

    println!("player 1 moves to blue");
    let token = ticket(club.place(&lobby, 1, blue).await?);
    ann.leave().await;
    let mut ann = club.dial(blue, &token).await.ok_or("ticket refused")?;
    ann.hear().await;
    ann.send(TableOp::Claim).await;
    ann.hear().await;

    println!("player 2 watches blue, then takes a seat there");
    let mut ben = club.watch(blue, 2).await.ok_or("no such table")?;
    ben.hear().await;
    let token = ticket(club.place(&lobby, 2, blue).await?);
    ben.leave().await;
    let mut ben = club.dial(blue, &token).await.ok_or("ticket refused")?;
    ben.hear().await;

    println!("player 3 is placed at red and never arrives");
    let token = ticket(club.place(&lobby, 3, red).await?);
    show(&club, &lobby).await;
    club.cancel(&lobby, 3, red, &token).await;
    show(&club, &lobby).await;
    Ok(())
}
```

Run it:

```shell-session
$ cargo run
player 1 at red: [Seated(Player)]
player 1 at red: [Paid { balance: 10 }]
player 1 moves to blue
player 1 at blue: [Seated(Player)]
player 1 at blue: [Paid { balance: 20 }]
player 2 watches blue, then takes a seat there
player 2 at blue: [Seated(Spectator)]
player 2 at blue: [Seated(Player)]
player 3 is placed at red and never arrives
  blue     hearts  2/2
  red      hearts  1/2
  blue     hearts  2/2
  red      hearts  0/2
```

Player 1 claims ten coins at red, moves to blue and claims again: the balance carries over to 20. Player 2 watches blue as a spectator, then gets a real seat at the same table through a reservation. Player 3's unused placement holds a seat at red until the club cancels it.

## Close and reap a room

Nothing in the lobby closes or removes a room on its own. `reap_finished_rooms` removes the rooms whose controller task has ended, so closing a room is two steps: end its controller, then reap. `RoomHandle::request_shutdown` sends the controller `Shutdown`. Reaping polls and when to poll is up to you.

Red is empty after player 3's placement is cancelled. A table with people still in it would tell them first. On `plaza_session` that is `disconnect_all` with a `Farewell` before the shutdown, so nobody's connection ends in silence.

The club also keeps its own entry for each table, which has to go too. Add this method to `Club` in `src/club.rs`:

```rust title="src/club.rs"
pub fn forget(&self, room: RoomId) {
    self.tables.lock().remove(&room);
}
```

Add `use std::time::Duration;` to `src/main.rs` and add this to the end of `main`, before `Ok(())`:

```rust title="src/main.rs"
println!("red is empty, so the club closes it");
if let Some(room) = lobby.room(&red) {
    room.request_shutdown().await;
}
let mut reaper = tokio::time::interval(Duration::from_millis(20));
while lobby.room(&red).is_some() {
    reaper.tick().await;
    lobby.reap_finished_rooms().await;
}
club.forget(red);
show(&club, &lobby).await;
```

The reaper here runs every 20 milliseconds until red is gone. A real server would run it on its own timer for as long as it serves. Run it:

```shell-session
$ cargo run
player 1 at red: [Seated(Player)]
player 1 at red: [Paid { balance: 10 }]
player 1 moves to blue
player 1 at blue: [Seated(Player)]
player 1 at blue: [Paid { balance: 20 }]
player 2 watches blue, then takes a seat there
player 2 at blue: [Seated(Spectator)]
player 2 at blue: [Seated(Player)]
player 3 is placed at red and never arrives
  blue     hearts  2/2
  red      hearts  1/2
  blue     hearts  2/2
  red      hearts  0/2
red is empty, so the club closes it
  blue     hearts  2/2
```

The output matches the previous step until red is closed. After the reaper's pass only blue is listed.

## Where to go next

The lobby can also choose tables by link quality. A room states the worst one-way delay it can carry in `RoomMetadata::max_one_way_ms`, the server passes the delay it measured in `measured_one_way_ms` and the lobby refuses with `LobbyError::UnsuitableConnection`, which carries both numbers. `rooms_playable_at` lists the rooms a connection can actually play in, tightest fit first. Passwords go through `with_password_verifier`, whose default is a plain string compare.

Guide chapter [41, Rooms, lobbies and travel](https://github.com/excsn/plaza/blob/main/docs/guide/41-rooms-lobbies-and-travel.md) explains why the lobby holds rooms through an anonymous handle and why only the lobby may cancel a reservation. Its two labs show the two shapes of lobby. [lobby_world](https://github.com/excsn/plaza/tree/main/examples/lobby_world) has standing arenas with latency routing, a shared wallet and bots. [parlour_game](https://github.com/excsn/plaza/tree/main/examples/parlour_game) creates a table per match over WebSockets.

The [plaza_lobby usage guide](https://github.com/excsn/plaza/blob/main/lobby/README.USAGE.md) on GitHub covers every block in this tutorial plus latency routing and the ticket registries. The full API references are on docs.rs: [plaza_lobby](https://docs.rs/plaza_lobby) and [plaza](https://docs.rs/plaza).
