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 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:
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.
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:
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:
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
modclub;modtable;usestd::sync::Arc;useplaza_lobby::{LobbyError, RoomId, RoomSettings};usecrate::club::{Club, Lobby};constHOUSE:u64=0;async fnopen(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)}fnshow(lobby:&Lobby){letmut 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 fnmain()->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);ifletSome(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/2red is reached at inproc://red
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:
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
modclub;modtable;usestd::sync::Arc;useplaza_lobby::{LobbyError, RoomId, RoomSettings};usecrate::club::{Club, Lobby};constHOUSE:u64=0;async fnopen(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 fnshow(club:&Club, lobby:&Lobby){ club.refresh().await;letmut 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 fnmain()->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?;letmut 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}");letmut 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 runplayer 1 sent to inproc://redplayer 1 at red: [Seated]player 2 sent to inproc://redplayer 2 at red: [Seated] blue hearts 0/2 red hearts 2/2player 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.
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
modclub;modtable;usestd::sync::Arc;usestd::time::Duration;useplaza_lobby::{Formed, LobbyError, MatchQueue, RoomId, RoomSettings};usecrate::club::{Club, Guest, Lobby};constHOUSE:u64=0;constFIRST_BOT:u64=1000;async fnopen(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 fnshow(club:&Club, lobby:&Lobby){ club.refresh().await;letmut 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 fnstart(club:&Club,lobby:&Lobby,name:&str,formed:Formed<u64>,)->Result<Vec<Guest>, Box<dyn std::error::Error>>{let room =open(lobby, name, formed.size()asu32).await?;let bots =(0..formed.bots asu64).map(|n|FIRST_BOT+ n);letmut seated =Vec::new();for player in formed.players.into_iter().chain(bots){ club.place(lobby, player, room).await?;letmut guest = club.dial(room, player).await.ok_or("no such table")?; guest.hear().await; seated.push(guest);}Ok(seated)}fnat(seconds:u64)-> Duration{Duration::from_secs(seconds)}#[tokio::main]async fnmain()->Result<(), Box<dyn std::error::Error>>{let club =Arc::new(Club::new());let lobby =Lobby::new(club.clone());letmut queue:MatchQueue<u64, Duration>=MatchQueue::new(2,at(5));letmut 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 runat 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.
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
useasync_trait::async_trait;useplaza::session::TargetedOp;useplaza::state_logic::{LogicInput, LogicOutput, StateLogic, StateLogicError};useplaza_lobby::SeatReservations;useserde::{Deserialize, Serialize};#[derive(Clone, Debug, Serialize, Deserialize)]pubenumTableOp{ Reserve { player:u64}, Withdraw { player:u64}, Seated(Seat),}#[derive(Clone, Copy, Debug, Serialize, Deserialize)]pubenumSeat{ Player, Spectator,}#[derive(Clone, Debug, Default)]pubstructTableState{pubplayers:Vec<u64>,pubreserved:SeatReservations<u64>,}pubstructTableLogic;#[async_trait]implStateLogic<TableOp, u64, TableState>forTableLogic{ async fnprocess_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 }=>{letSome(player)= agent.id_cloned()else{returnOk(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:
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
modclub;modtable;usestd::sync::Arc;useplaza_lobby::{LobbyError, RoomId, RoomSettings};usecrate::club::{Club, Lobby};constHOUSE:u64=0;async fnopen(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 fnshow(club:&Club, lobby:&Lobby){ club.refresh().await;letmut 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 fnmain()->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}");}letmut 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;letmut passer_by = club.watch(red,9).await.ok_or("no such table")?; passer_by.hear().await;show(&club,&lobby).await;Ok(())}
Run it:
$cargo runplayer 1 holds ticket t0blue refuses ticket t0player 1 at red: [Seated(Player)]red refuses ticket t0 a second timeplayer 2 holds ticket t1 blue hearts 0/2 red hearts 2/2player 2 cancelled blue hearts 0/2 red hearts 1/2player 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.
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:
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:
pubstructGuest{player:u64,conn: ConnectionId,inbox:ClientInbox<TableOp, u64>,table:Arc<Table>,}implGuest{pub async fnhear(&mutself){ifletOk(message)=self.inbox.recv().await {println!("player {} at {}: {:?}",self.player,self.table.name, message.ops);}}pub async fnsend(&self, op: TableOp){let me =Agent::new_human(self.player);self.table.session.client_send(me,vec![op]).await;}pub async fnleave(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
modclub;modtable;modwallets;usestd::sync::Arc;useplaza_lobby::{JoinRoomOutcomePayload, LobbyError, RoomId, RoomSettings};usecrate::club::{Club, Lobby};usecrate::table::TableOp;constHOUSE:u64=0;async fnopen(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 fnshow(club:&Club, lobby:&Lobby){ club.refresh().await;letmut 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);}}fnticket(outcome: JoinRoomOutcomePayload)-> String{ outcome.player_game_token.unwrap_or_default()}#[tokio::main]async fnmain()->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?);letmut 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;letmut 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");letmut 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;letmut 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 runplayer 1 at red: [Seated(Player)]player 1 at red: [Paid { balance: 10 }]player 1 moves to blueplayer 1 at blue: [Seated(Player)]player 1 at blue: [Paid { balance: 20 }]player 2 watches blue, then takes a seat thereplayer 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.
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:
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");ifletSome(room)= lobby.room(&red){ room.request_shutdown().await;}letmut 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 runplayer 1 at red: [Seated(Player)]player 1 at red: [Paid { balance: 10 }]player 1 moves to blueplayer 1 at blue: [Seated(Player)]player 1 at blue: [Paid { balance: 20 }]player 2 watches blue, then takes a seat thereplayer 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/2red 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.
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.