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:

plaza = "0"

Set up the project#

Start a new binary crate.

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:

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:

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:

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:

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:

$ 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:

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:

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)
}

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:

$ 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:

src/main.rs
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:

$ 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:

src/table.rs
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:

src/club.rs
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:

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)
}

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:

$ 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:

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:

src/table.rs
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:

src/club.rs
pub struct Club {
    tables: Mutex<HashMap<RoomId, Arc<Table>>>,
    tickets: MapTicketRegistry<u64>,
    wallets: Arc<Wallets>,
}
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:

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:

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,
    })
}
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:

src/main.rs
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:

$ 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:

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(()):

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:

$ 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 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 has standing arenas with latency routing, a shared wallet and bots. parlour_game creates a table per match over WebSockets.

The plaza_lobby usage guide 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 and plaza.