For Rust developers running a plaza game with a fixed number of seats who need dropped players to get back in without losing their place
You will build a three-seat table that is never empty. Bots sit in every seat until people arrive. A person takes a bot's seat on the next tick. When a player's link drops, the seat is held for a grace period with everything in it. If they come back in time they resume where they were. If they don't, the hold runs out and a bot takes the seat. Last, the host kicks a player. The table frees that seat at once, because it knows the close was ordered and was not a drop.
Before you start
You need Rust with Cargo. plaza comes from crates.io:
Add plaza, plaza_server_utils, Tokio and async-trait. The grace bookkeeping (ReconnectTracker) and the closure log (ClosureLog) are in plaza itself. The seating blocks (Roster and Crew) are in plaza_server_utils. Your [dependencies] section should look like this:
The table runs over InProcessSession, so a dropped link is session.disconnect and a reconnect is another session.connect with the same agent. Time moves with TickDriver::run_virtual, which sends the controller a batch of time steps without waiting on a clock. The grace period is counted in ticks. Together with Tokio's current_thread runtime that makes every run print the same lines.
Seating needs more policy than "first come, first seated". Roster holds the seats and adds the policies you switch on. with_waitlist queues a player who arrives at a full table instead of turning them away. Every admission carries a rank, where lower is better. People come in at rank 0 through admit. Crew seats bots at rank 1 through the same roster and remembers which seats are theirs, since a bot holds a seat but no connection.
The roster settles an arrival or a departure at once, but it only promotes from the waitlist in resolve. Your TimeStep arm calls it once a tick, so seating is decided in one place. A waiting person outranks a seated bot, so resolve returns a Displaced for the bot and a Promoted for the person. crew.prune then forgets the displaced bots and takes them off the waitlist, so they can't come back as strangers when a seat opens.
Replace src/main.rs with this:
src/main.rs
usestd::error::Error;usestd::sync::Arc;usestd::time::Duration;useasync_trait::async_trait;useplaza::controller::{ControllerCommand, StateControllerBuilder, query_with};useplaza::session::in_process::ClientInbox;useplaza::state_logic::{LogicInput, LogicOutput, StateLogic, StateLogicError};useplaza::{Agent, InProcessSession, SnapshotFn, TargetedOp, TickDriver};useplaza_server_utils::{Admission, Crew, Roster, SeatState, Shuffle};typePlayerId=u64;constSEATS:usize=3;constBOT_BASE: PlayerId =1000;constSTEP: Duration =Duration::from_millis(100);#[derive(Clone, Debug)]enumTableOp{ Welcome { seat:Option<usize>}, Seated { seat:usize},}#[derive(Debug)]structTable{tick:u64,roster:Roster<PlayerId>,crew:Crew<PlayerId>,next_bot: PlayerId,}implTable{fnnew()->Self{letmut table = Table { tick:0, roster:Roster::new(SEATS).with_waitlist(), crew:Crew::new(), next_bot:BOT_BASE,}; table.fill_with_bots(); table}fnfill_with_bots(&mutself){let open =self.roster.capacity()-self.roster.occupied_count();let first =self.next_bot;let seats =self.crew.fill(&mutself.roster, open,1,|i|first + i as PlayerId);self.next_bot += seats.len()as PlayerId;for seat in seats {println!("tick {}: {} takes seat {seat}",self.tick,name(self.seated(seat)));}}fnseated(&self, seat:usize)-> PlayerId{matchself.roster.seat_state(seat){SeatState::Human(id)|SeatState::Held(id)=>*id,SeatState::Open =>0,}}fnseat_map(&self)-> String{let seats:Vec<String>=self.roster.seats().enumerate().map(|(seat,state)|match state {SeatState::Human(id)=>format!("{seat}: {}",name(*id)),SeatState::Held(id)=>format!("{seat}: held for {}",name(*id)),SeatState::Open =>format!("{seat}: open"),}).collect(); seats.join(", ")}}fnname(id: PlayerId)-> String{match id {1=>"alice".into(),2=>"bob".into(),3=>"carol".into(), bot =>format!("bot {}", bot -BOT_BASE),}}fnseat_round(table:&mut Table, out:&mutVec<TargetedOp<TableOp, PlayerId>>){letmut promoted =Vec::new();for shuffle in table.roster.resolve(){match shuffle {Shuffle::Displaced { key, seat }=>{println!("tick {}: {} gives up seat {seat}", table.tick,name(key));}Shuffle::Promoted { key, seat }=>{println!("tick {}: {} takes seat {seat}", table.tick,name(key)); promoted.push((key, seat));}}} table.crew.prune(&mut table.roster);for(key, seat)in promoted {if!table.crew.holds(seat){ out.push(TargetedOp::new_system_to( key,vec![TableOp::Seated { seat }],));}} table.fill_with_bots();}structTableRules;#[async_trait]implStateLogic<TableOp, PlayerId, Table>forTableRules{ async fnprocess_input(&self,table:&mut Table,input:LogicInput<TableOp, PlayerId>,)->Result<LogicOutput<TableOp, PlayerId>, StateLogicError>{letmut out =Vec::new();match input {LogicInput::AgentJoined { agent }=>{letSome(id)= agent.id_cloned()else{returnOk(LogicOutput::none());};match table.roster.admit(id){Admission::Seated { seat,..}=>{println!("tick {}: {} sits in open seat {seat}", table.tick,name(id))}Admission::Waitlisted { position }=>{println!("tick {}: {} waits for a seat, position {position}", table.tick,name(id))} other =>println!("tick {}: {} not seated: {other:?}", table.tick,name(id)),}}LogicInput::AgentLeft { agent_id }=>{ table.roster.depart(&agent_id);}LogicInput::TimeStep {..}=>{ table.tick +=1;seat_round(table,&mut out);}LogicInput::AgentOps {..}=>{}}Ok(out.into())}}fnwelcome(table:&Table, target:Option<&Agent<PlayerId>>)->Option<TableOp>{let id = target?.id_cloned()?;Some(TableOp::Welcome { seat: table.roster.seat_of(&id),})}async fnheard(id: PlayerId, inbox:&ClientInbox<TableOp, PlayerId>)->Result<(), Box<dyn Error>>{let msg = inbox.recv().await?;for op in msg.ops {match op {TableOp::Welcome { seat:Some(seat)}=>{println!("{} hears: welcome, seat {seat}",name(id))}TableOp::Welcome { seat:None}=>{println!("{} hears: welcome, no seat yet",name(id))}TableOp::Seated { seat }=>println!("{} hears: you are in seat {seat}",name(id)),}}Ok(())}#[tokio::main(flavor ="current_thread")]async fnmain()->Result<(), Box<dyn Error>>{let session =InProcessSession::<TableOp, PlayerId>::new();let(tx, controller)=StateControllerBuilder::new(Arc::new(TableRules), session.clone(),Arc::new(SnapshotFn(welcome)),Table::new(),).build();let running =tokio::spawn(controller.run());let(_alice_conn, alice)= session.connect(Agent::new_human(1)).await?;heard(1,&alice).await?;let(_bob_conn, bob)= session.connect(Agent::new_human(2)).await?;heard(2,&bob).await?;TickDriver::run_virtual(&tx,STEP,1).await;heard(1,&alice).await?;heard(2,&bob).await?;println!("seats: {}",query_with(&tx,|table:&Table|table.seat_map()).await?); tx.send(ControllerCommand::Shutdown).await?; running.await??;Ok(())}
Table::new fills all three seats with bots before anyone connects. fill_with_bots gives every fill its own range of keys from next_bot, because Crew::fill names bots 0..count through the closure and expects the caller to keep them unique. Keys from 1000 up stay clear of the people's ids. The snapshot provider is a plain function wrapped in SnapshotFn. It tells a joining client which seat it has, if any.
Run it:
$cargo runtick 0: bot 0 takes seat 0tick 0: bot 1 takes seat 1tick 0: bot 2 takes seat 2tick 0: alice waits for a seat, position 0 alice hears: welcome, no seat yettick 0: bob waits for a seat, position 1 bob hears: welcome, no seat yettick 1: bot 2 gives up seat 2tick 1: alice takes seat 2tick 1: bot 1 gives up seat 1tick 1: bob takes seat 1 alice hears: you are in seat 2 bob hears: you are in seat 1seats: 0: bot 0, 1: bob, 2: alice
Alice and Bob both arrive at a full table and wait. On tick 1 resolve displaces the bots in the two highest seats, since among equal ranks the later seat goes first. The people sit down in them. Each of them gets a Seated op addressed only to them. Bot 0 keeps seat 0.
The transport only knows that a socket closed. It can't tell whether the player quit or is back in ten seconds. In plaza, your rules decide that with a ReconnectTracker. It holds no timers and spawns no tasks. It keeps a record of who left and when. You drive it from your rules: on_disconnect from AgentLeft, on_reconnect from AgentJoined and expired from your tick.
holding_seats makes the roster keep a departed player's seat. depart returns Departure::Held instead of freeing it. The seat stays held until you call expire. When the player comes back, admit returns Admission::Resumed and everything that belongs to the seat is still there. That is different from Admission::Seated { fresh: true }, which tells you to reset the seat because its state belonged to someone else.
A returning player has to arrive with the same agent id. Here that is Agent::new_human(1) again. On a real server, derive the id from something durable such as an auth token, never from the connection.
Each occupied seat now earns a chip per tick, standing in for whatever a player builds up during a game. A held seat earns nothing. Replace src/main.rs with the full program:
ticks advances the table and prints the seats. The controller takes a join or a leave ahead of any queued command, so the ticks main sends after session.disconnect land after the departure.
Run it:
$cargo runtick 0: bot 0 takes seat 0tick 0: bot 1 takes seat 1tick 0: bot 2 takes seat 2tick 0: alice waits for a seat, position 0 alice hears: welcome, no seat yettick 0: bob waits for a seat, position 1 bob hears: welcome, no seat yettick 1: bot 2 gives up seat 2tick 1: alice takes seat 2tick 1: bot 1 gives up seat 1tick 1: bob takes seat 1 alice hears: you are in seat 2 bob hears: you are in seat 1seats: 0: bot 0 (chips 4), 1: bob (chips 4), 2: alice (chips 4)tick 4: alice dropped, seat 2 held until tick 9seats: 0: bot 0 (chips 6), 1: bob (chips 6), 2: held for alice (chips 4)tick 6: alice is back (returning: true), seat 2 resumes with 4 chips alice hears: welcome, seat 2, 4 chipsseats: 0: bot 0 (chips 7), 1: bob (chips 7), 2: alice (chips 5)
Alice drops on tick 4 with 4 chips. Her seat stays hers for 5 ticks and stops earning. She reconnects on tick 6, on_reconnect returns true and the welcome snapshot she gets shows seat 2 and the same 4 chips. On the next tick she is earning again.
Nothing in the tracker or the roster frees a held seat by itself. Your tick asks the tracker who ran out of grace with expired(now) and tells the roster with expire. The seat is then open. The same seat_round that runs every tick puts a bot in it. Replace the TimeStep arm:
src/main.rs
LogicInput::TimeStep {..}=>{ table.tick +=1;for id in table.tracker.expired(table.tick){ifletSome(seat)= table.roster.expire(&id){println!("tick {}: {}'s grace ran out, seat {seat} opens", table.tick,name(id));}}seat_round(table,&mut out);play(table);}
The script now drops Alice a second time and lets the grace run out, then brings her back. Her second reconnect keeps its connection id, so change that _alice_conn to alice_conn. Then add this to the end of the script in main:
$cargo runtick 0: bot 0 takes seat 0tick 0: bot 1 takes seat 1tick 0: bot 2 takes seat 2tick 0: alice waits for a seat, position 0 alice hears: welcome, no seat yettick 0: bob waits for a seat, position 1 bob hears: welcome, no seat yettick 1: bot 2 gives up seat 2tick 1: alice takes seat 2tick 1: bot 1 gives up seat 1tick 1: bob takes seat 1 alice hears: you are in seat 2 bob hears: you are in seat 1seats: 0: bot 0 (chips 4), 1: bob (chips 4), 2: alice (chips 4)tick 4: alice dropped, seat 2 held until tick 9seats: 0: bot 0 (chips 6), 1: bob (chips 6), 2: held for alice (chips 4)tick 6: alice is back (returning: true), seat 2 resumes with 4 chips alice hears: welcome, seat 2, 4 chipsseats: 0: bot 0 (chips 7), 1: bob (chips 7), 2: alice (chips 5)tick 7: alice dropped, seat 2 held until tick 12tick 12: alice's grace ran out, seat 2 openstick 12: bot 3 takes seat 2seats: 0: bot 0 (chips 12), 1: bob (chips 12), 2: bot 3 (chips 1)tick 12: alice waits for a seat, position 0 alice hears: welcome, no seat yettick 13: bot 3 gives up seat 2tick 13: alice takes seat 2 alice hears: you are in seat 2seats: 0: bot 0 (chips 14), 1: bob (chips 14), 2: alice (chips 2)
Alice's second drop on tick 7 is held until tick 12. On tick 12 expired names her, expire opens seat 2 and bot 3 sits down in the same tick. When Alice comes back after that she is a new arrival. She waits, displaces bot 3 on the next tick and starts again from zero chips, because Promoted always means a fresh seat.
Every departure reaches your rules as the same AgentLeft, whether the network failed or the host removed the player. Only the code that ordered a close knows the difference. ClosureLog records it: call order when you decide to close someone. Then departed in AgentLeft returns Departed::Ordered(reason) for that close and Departed::Netdrop for everything else. A drop holds the seat. A removal clears it.
Add a reason and two ops, one for the host's order and one for the player's goodbye:
Add closures: ClosureLog<PlayerId, Reason> to Table and closures: ClosureLog::new() to Table::new. Import ClosureLog and Departed from plaza::common::closure. The kick arrives as a system op. order returns true only the first time for a key, so the goodbye goes out once even if several rules decide to close the same player. Add this arm to the rules, above the existing AgentOps arm:
src/main.rs
LogicInput::AgentOps { source, ops }if source.is_system()=>{for op in ops {letTableOp::Kick(id)= op else{continue};if table.closures.order(id,Reason::Kicked){println!("tick {}: the host orders {} out", table.tick,name(id)); out.push(TargetedOp::new_system_to( id,vec![TableOp::Farewell(Reason::Kicked)],));}}}
Replace the AgentLeft arm so a departure is checked against the log first:
src/main.rs
LogicInput::AgentLeft { agent_id }=>match table.closures.departed(&agent_id){Departed::Ordered(reason)=>{ table.roster.depart(&agent_id);ifletSome(seat)= table.roster.expire(&agent_id){println!("tick {}: {} left as ordered ({reason:?}), seat {seat} opens", table.tick,name(agent_id));}}Departed::Netdrop =>match table.roster.depart(&agent_id){Departure::Held { seat }=>{ table.tracker.on_disconnect(agent_id, table.tick);let until = table.tracker.deadline_for(&agent_id).unwrap_or_default();println!("tick {}: {} dropped, seat {seat} held until tick {until}", table.tick,name(agent_id));} other =>println!("tick {}: {} left: {other:?}", table.tick,name(agent_id)),},},
The log holds no sockets. The rules send the farewell and the application closes the connection, which over InProcessSession is session.disconnect. Give heard arms for the two new ops:
src/main.rs
async fnheard(id: PlayerId, inbox:&ClientInbox<TableOp, PlayerId>)->Result<(), Box<dyn Error>>{let msg = inbox.recv().await?;for op in msg.ops {match op {TableOp::Welcome { seat:Some(seat), chips,}=>{println!("{} hears: welcome, seat {seat}, {chips} chips",name(id))}TableOp::Welcome { seat:None,..}=>{println!("{} hears: welcome, no seat yet",name(id))}TableOp::Seated { seat }=>println!("{} hears: you are in seat {seat}",name(id)),TableOp::Farewell(reason)=>println!("{} hears: farewell, {reason:?}",name(id)),TableOp::Kick(_)=>{}}}Ok(())}
Keep Bob's connection id by renaming _bob_conn to bob_conn. Then add this to the end of the script:
$cargo runtick 0: bot 0 takes seat 0tick 0: bot 1 takes seat 1tick 0: bot 2 takes seat 2tick 0: alice waits for a seat, position 0 alice hears: welcome, no seat yettick 0: bob waits for a seat, position 1 bob hears: welcome, no seat yettick 1: bot 2 gives up seat 2tick 1: alice takes seat 2tick 1: bot 1 gives up seat 1tick 1: bob takes seat 1 alice hears: you are in seat 2 bob hears: you are in seat 1seats: 0: bot 0 (chips 4), 1: bob (chips 4), 2: alice (chips 4)tick 4: alice dropped, seat 2 held until tick 9seats: 0: bot 0 (chips 6), 1: bob (chips 6), 2: held for alice (chips 4)tick 6: alice is back (returning: true), seat 2 resumes with 4 chips alice hears: welcome, seat 2, 4 chipsseats: 0: bot 0 (chips 7), 1: bob (chips 7), 2: alice (chips 5)tick 7: alice dropped, seat 2 held until tick 12tick 12: alice's grace ran out, seat 2 openstick 12: bot 3 takes seat 2seats: 0: bot 0 (chips 12), 1: bob (chips 12), 2: bot 3 (chips 1)tick 12: alice waits for a seat, position 0 alice hears: welcome, no seat yettick 13: bot 3 gives up seat 2tick 13: alice takes seat 2 alice hears: you are in seat 2seats: 0: bot 0 (chips 14), 1: bob (chips 14), 2: alice (chips 2)tick 14: the host orders bob out bob hears: farewell, Kickedtick 14: bob left as ordered (Kicked), seat 1 openstick 15: bot 4 takes seat 1seats: 0: bot 0 (chips 15), 1: bot 4 (chips 1), 2: alice (chips 3)
Bob hears his farewell, his connection closes and departed reports the close as ordered. His seat opens on the spot with no grace period. Bot 4 takes it on the next tick. Alice's two drops earlier in the run went through the Netdrop arm and were held.
An op from a closed player can still arrive after the close, since ops and presence travel on separate streams. was_ordered stays true after the departure, so the rules can drop those ops.
When seating needs no policy at all, SeatTable is the smaller block: seat returns Seating::Fresh, Seating::Existing or Seating::Full and unseat frees the seat. Roster also has lock and unlock for games that only seat people between rounds. A grace rule that depends on more than a fixed window, such as a longer hold for a ranked player, still uses the tracker: it reports who ran out and leaves the decision to you.