Keep a seat for a player who drops

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:

plaza = "0"

Set up the project#

Start a new binary crate.

cargo new gracetable
cd gracetable

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:

Cargo.toml
[dependencies]
plaza = "0"
plaza_server_utils = "0"
async-trait = "0.1"
tokio = { version = "1", features = ["macros", "rt"] }

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.

Seat players and fill the rest with bots#

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
use std::error::Error;
use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use plaza::controller::{ControllerCommand, StateControllerBuilder, query_with};
use plaza::session::in_process::ClientInbox;
use plaza::state_logic::{LogicInput, LogicOutput, StateLogic, StateLogicError};
use plaza::{Agent, InProcessSession, SnapshotFn, TargetedOp, TickDriver};
use plaza_server_utils::{Admission, Crew, Roster, SeatState, Shuffle};

type PlayerId = u64;

const SEATS: usize = 3;
const BOT_BASE: PlayerId = 1000;
const STEP: Duration = Duration::from_millis(100);

#[derive(Clone, Debug)]
enum TableOp {
    Welcome { seat: Option<usize> },
    Seated { seat: usize },
}

#[derive(Debug)]
struct Table {
    tick: u64,
    roster: Roster<PlayerId>,
    crew: Crew<PlayerId>,
    next_bot: PlayerId,
}

impl Table {
    fn new() -> Self {
        let mut table = Table {
            tick: 0,
            roster: Roster::new(SEATS).with_waitlist(),
            crew: Crew::new(),
            next_bot: BOT_BASE,
        };
        table.fill_with_bots();
        table
    }

    fn fill_with_bots(&mut self) {
        let open = self.roster.capacity() - self.roster.occupied_count();
        let first = self.next_bot;
        let seats = self
            .crew
            .fill(&mut self.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))
            );
        }
    }

    fn seated(&self, seat: usize) -> PlayerId {
        match self.roster.seat_state(seat) {
            SeatState::Human(id) | SeatState::Held(id) => *id,
            SeatState::Open => 0,
        }
    }

    fn seat_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(", ")
    }
}

fn name(id: PlayerId) -> String {
    match id {
        1 => "alice".into(),
        2 => "bob".into(),
        3 => "carol".into(),
        bot => format!("bot {}", bot - BOT_BASE),
    }
}

fn seat_round(table: &mut Table, out: &mut Vec<TargetedOp<TableOp, PlayerId>>) {
    let mut 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();
}

struct TableRules;

#[async_trait]
impl StateLogic<TableOp, PlayerId, Table> for TableRules {
    async fn process_input(
        &self,
        table: &mut Table,
        input: LogicInput<TableOp, PlayerId>,
    ) -> Result<LogicOutput<TableOp, PlayerId>, StateLogicError> {
        let mut out = Vec::new();
        match input {
            LogicInput::AgentJoined { agent } => {
                let Some(id) = agent.id_cloned() else {
                    return Ok(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())
    }
}

fn welcome(table: &Table, target: Option<&Agent<PlayerId>>) -> Option<TableOp> {
    let id = target?.id_cloned()?;
    Some(TableOp::Welcome {
        seat: table.roster.seat_of(&id),
    })
}

async fn heard(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 fn main() -> 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 run
tick 0: bot 0 takes seat 0
tick 0: bot 1 takes seat 1
tick 0: bot 2 takes seat 2
tick 0: alice waits for a seat, position 0
  alice hears: welcome, no seat yet
tick 0: bob waits for a seat, position 1
  bob hears: welcome, no seat yet
tick 1: bot 2 gives up seat 2
tick 1: alice takes seat 2
tick 1: bot 1 gives up seat 1
tick 1: bob takes seat 1
  alice hears: you are in seat 2
  bob hears: you are in seat 1
seats: 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.

Hold the seat of a player who drops#

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:

src/main.rs
use std::error::Error;
use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use plaza::common::reconnect::ReconnectTracker;
use plaza::controller::{CommandSender, ControllerCommand, StateControllerBuilder, query_with};
use plaza::session::in_process::ClientInbox;
use plaza::state_logic::{LogicInput, LogicOutput, StateLogic, StateLogicError};
use plaza::{Agent, InProcessSession, SnapshotFn, TargetedOp, TickDriver};
use plaza_server_utils::{Admission, Crew, Departure, Roster, SeatState, Shuffle};

type PlayerId = u64;

const SEATS: usize = 3;
const GRACE_TICKS: u64 = 5;
const BOT_BASE: PlayerId = 1000;
const STEP: Duration = Duration::from_millis(100);

#[derive(Clone, Debug)]
enum TableOp {
    Welcome { seat: Option<usize>, chips: u32 },
    Seated { seat: usize },
}

#[derive(Debug)]
struct Table {
    tick: u64,
    roster: Roster<PlayerId>,
    crew: Crew<PlayerId>,
    next_bot: PlayerId,
    tracker: ReconnectTracker<PlayerId, u64>,
    chips: [u32; SEATS],
}

impl Table {
    fn new() -> Self {
        let mut table = Table {
            tick: 0,
            roster: Roster::new(SEATS).with_waitlist().holding_seats(),
            crew: Crew::new(),
            next_bot: BOT_BASE,
            tracker: ReconnectTracker::new(GRACE_TICKS),
            chips: [0; SEATS],
        };
        table.fill_with_bots();
        table
    }

    fn fill_with_bots(&mut self) {
        let open = self.roster.capacity() - self.roster.occupied_count();
        let first = self.next_bot;
        let seats = self
            .crew
            .fill(&mut self.roster, open, 1, |i| first + i as PlayerId);
        self.next_bot += seats.len() as PlayerId;
        for seat in seats {
            self.chips[seat] = 0;
            println!(
                "tick {}: {} takes seat {seat}",
                self.tick,
                name(self.seated(seat))
            );
        }
    }

    fn seated(&self, seat: usize) -> PlayerId {
        match self.roster.seat_state(seat) {
            SeatState::Human(id) | SeatState::Held(id) => *id,
            SeatState::Open => 0,
        }
    }

    fn seat_map(&self) -> String {
        let seats: Vec<String> = self
            .roster
            .seats()
            .enumerate()
            .map(|(seat, state)| match state {
                SeatState::Human(id) => {
                    format!("{seat}: {} (chips {})", name(*id), self.chips[seat])
                }
                SeatState::Held(id) => format!(
                    "{seat}: held for {} (chips {})",
                    name(*id),
                    self.chips[seat]
                ),
                SeatState::Open => format!("{seat}: open"),
            })
            .collect();
        seats.join(", ")
    }
}

fn name(id: PlayerId) -> String {
    match id {
        1 => "alice".into(),
        2 => "bob".into(),
        3 => "carol".into(),
        bot => format!("bot {}", bot - BOT_BASE),
    }
}

fn seat_round(table: &mut Table, out: &mut Vec<TargetedOp<TableOp, PlayerId>>) {
    let mut 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));
                table.chips[seat] = 0;
                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();
}

fn play(table: &mut Table) {
    for seat in 0..SEATS {
        if let SeatState::Human(_) = table.roster.seat_state(seat) {
            table.chips[seat] += 1;
        }
    }
}

struct TableRules;

#[async_trait]
impl StateLogic<TableOp, PlayerId, Table> for TableRules {
    async fn process_input(
        &self,
        table: &mut Table,
        input: LogicInput<TableOp, PlayerId>,
    ) -> Result<LogicOutput<TableOp, PlayerId>, StateLogicError> {
        let mut out = Vec::new();
        match input {
            LogicInput::AgentJoined { agent } => {
                let Some(id) = agent.id_cloned() else {
                    return Ok(LogicOutput::none());
                };
                match table.roster.admit(id) {
                    Admission::Resumed { seat } => {
                        let returning = table.tracker.on_reconnect(&id);
                        println!(
                            "tick {}: {} is back (returning: {returning}), seat {seat} resumes with {} chips",
                            table.tick,
                            name(id),
                            table.chips[seat]
                        );
                    }
                    Admission::Seated { seat, fresh } => {
                        if fresh {
                            table.chips[seat] = 0;
                        }
                        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)
                        );
                    }
                    Admission::Turned(why) => {
                        println!("tick {}: {} turned away: {why:?}", table.tick, name(id))
                    }
                }
            }
            LogicInput::AgentLeft { agent_id } => 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)),
            },
            LogicInput::TimeStep { .. } => {
                table.tick += 1;
                seat_round(table, &mut out);
                play(table);
            }
            LogicInput::AgentOps { .. } => {}
        }
        Ok(out.into())
    }
}

fn welcome(table: &Table, target: Option<&Agent<PlayerId>>) -> Option<TableOp> {
    let id = target?.id_cloned()?;
    let seat = table.roster.seat_of(&id);
    let chips = seat.map_or(0, |seat| table.chips[seat]);
    Some(TableOp::Welcome { seat, chips })
}

async fn heard(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)),
        }
    }
    Ok(())
}

type Tx = CommandSender<TableOp, PlayerId, Table>;

async fn ticks(tx: &Tx, count: u64) -> Result<(), Box<dyn Error>> {
    TickDriver::run_virtual(tx, STEP, count).await;
    println!(
        "seats: {}",
        query_with(tx, |table: &Table| table.seat_map()).await?
    );
    Ok(())
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> 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?;
    ticks(&tx, 3).await?;

    session.disconnect(&1, alice_conn).await;
    ticks(&tx, 2).await?;

    let (_alice_conn, alice) = session.connect(Agent::new_human(1)).await?;
    heard(1, &alice).await?;
    ticks(&tx, 1).await?;

    tx.send(ControllerCommand::Shutdown).await?;
    running.await??;
    Ok(())
}

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 run
tick 0: bot 0 takes seat 0
tick 0: bot 1 takes seat 1
tick 0: bot 2 takes seat 2
tick 0: alice waits for a seat, position 0
  alice hears: welcome, no seat yet
tick 0: bob waits for a seat, position 1
  bob hears: welcome, no seat yet
tick 1: bot 2 gives up seat 2
tick 1: alice takes seat 2
tick 1: bot 1 gives up seat 1
tick 1: bob takes seat 1
  alice hears: you are in seat 2
  bob hears: you are in seat 1
seats: 0: bot 0 (chips 4), 1: bob (chips 4), 2: alice (chips 4)
tick 4: alice dropped, seat 2 held until tick 9
seats: 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 chips
seats: 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.

Give an unclaimed seat to a bot#

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) {
        if let Some(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:

src/main.rs
session.disconnect(&1, alice_conn).await;
ticks(&tx, GRACE_TICKS).await?;

let (_alice_conn, alice) = session.connect(Agent::new_human(1)).await?;
heard(1, &alice).await?;
TickDriver::run_virtual(&tx, STEP, 1).await;
heard(1, &alice).await?;
ticks(&tx, 1).await?;

Run it:

$ cargo run
tick 0: bot 0 takes seat 0
tick 0: bot 1 takes seat 1
tick 0: bot 2 takes seat 2
tick 0: alice waits for a seat, position 0
  alice hears: welcome, no seat yet
tick 0: bob waits for a seat, position 1
  bob hears: welcome, no seat yet
tick 1: bot 2 gives up seat 2
tick 1: alice takes seat 2
tick 1: bot 1 gives up seat 1
tick 1: bob takes seat 1
  alice hears: you are in seat 2
  bob hears: you are in seat 1
seats: 0: bot 0 (chips 4), 1: bob (chips 4), 2: alice (chips 4)
tick 4: alice dropped, seat 2 held until tick 9
seats: 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 chips
seats: 0: bot 0 (chips 7), 1: bob (chips 7), 2: alice (chips 5)
tick 7: alice dropped, seat 2 held until tick 12
tick 12: alice's grace ran out, seat 2 opens
tick 12: bot 3 takes seat 2
seats: 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 yet
tick 13: bot 3 gives up seat 2
tick 13: alice takes seat 2
  alice hears: you are in seat 2
seats: 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.

Tell a kick from a drop#

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:

src/main.rs
#[derive(Clone, Copy, Debug)]
enum Reason {
    Kicked,
}

#[derive(Clone, Debug)]
enum TableOp {
    Welcome { seat: Option<usize>, chips: u32 },
    Seated { seat: usize },
    Kick(PlayerId),
    Farewell(Reason),
}

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 {
        let TableOp::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);
        if let Some(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 fn heard(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:

src/main.rs
tx.send(ControllerCommand::SubmitSystemOps {
    source_description: "host".into(),
    ops: vec![TableOp::Kick(2)],
})
.await?;
heard(2, &bob).await?;
session.disconnect(&2, bob_conn).await;
ticks(&tx, 1).await?;

Run it:

$ cargo run
tick 0: bot 0 takes seat 0
tick 0: bot 1 takes seat 1
tick 0: bot 2 takes seat 2
tick 0: alice waits for a seat, position 0
  alice hears: welcome, no seat yet
tick 0: bob waits for a seat, position 1
  bob hears: welcome, no seat yet
tick 1: bot 2 gives up seat 2
tick 1: alice takes seat 2
tick 1: bot 1 gives up seat 1
tick 1: bob takes seat 1
  alice hears: you are in seat 2
  bob hears: you are in seat 1
seats: 0: bot 0 (chips 4), 1: bob (chips 4), 2: alice (chips 4)
tick 4: alice dropped, seat 2 held until tick 9
seats: 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 chips
seats: 0: bot 0 (chips 7), 1: bob (chips 7), 2: alice (chips 5)
tick 7: alice dropped, seat 2 held until tick 12
tick 12: alice's grace ran out, seat 2 opens
tick 12: bot 3 takes seat 2
seats: 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 yet
tick 13: bot 3 gives up seat 2
tick 13: alice takes seat 2
  alice hears: you are in seat 2
seats: 0: bot 0 (chips 14), 1: bob (chips 14), 2: alice (chips 2)
tick 14: the host orders bob out
  bob hears: farewell, Kicked
tick 14: bob left as ordered (Kicked), seat 1 opens
tick 15: bot 4 takes seat 1
seats: 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.

Where to go next#

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.

The grace_run example holds seats across drops in a playable game, with meters for how long the party waited and how many runs a short window cost. Chapter 12 of the plaza guide, Players come and go, covers presence, held seats, bots and what happens when a match ends. The plaza usage guide and the plaza_server_utils usage guide on GitHub cover the tracker and the seating blocks.

The full API references are on docs.rs: plaza and plaza_server_utils.