Show each player only their own hand

For Rust developers building a game or shared app on plaza who need each client to see a different slice of the same state

You will build a small card table that runs in one process. Three players are dealt hidden hands. Each client sees its own cards and only a count for everyone else. A fourth player joins in the middle of the hand and catches up from a single snapshot. Then a leak check walks every message each client received and proves that no hidden card reached anyone else. Last, a uniform snapshot sends one public view to the whole table for the cost of building it once.

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 cardtable
cd cardtable

Add plaza, Tokio and async-trait. plaza's traits are async and written with async_trait, so you implement them with the same macro. Your [dependencies] section should look like this:

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

The table uses InProcessSession, plaza's loopback transport, so there are no sockets. Each client gets its own inbox and plaza resolves who receives what on the server side, the same way a real transport does. The programs run on Tokio's current_thread runtime and shuffle with a fixed seed, so every run prints the same output.

Deal a hand to each player#

A plaza app has three pieces. The state is one value that only the controller owns. The rules are a StateLogic, the one place the state changes. The snapshot provider is a SnapshotProvider, which turns the state into what one client is sent.

A snapshot is an op like any other. TableOp::View carries a boxed PlayerView and the client decodes it on the same path as every other op. The provider receives target, the agent the snapshot is for. The controller calls it once per recipient. That is where hidden information is handled: the view puts in the recipient's own hand and reduces everyone else to a card count.

Replace src/main.rs with this:

src/main.rs
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;

use async_trait::async_trait;
use plaza::controller::{ControllerCommand, StateControllerBuilder};
use plaza::session::in_process::ClientInbox;
use plaza::snapshot::{SnapshotContext, SnapshotError, SnapshotProvider};
use plaza::state_logic::{LogicInput, LogicOutput, SnapshotRequest, StateLogic, StateLogicError};
use plaza::{Agent, InProcessSession};

type PlayerId = u64;

const HAND_SIZE: usize = 4;
const SEED: u64 = 0x5eed_ca4d;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Card(u8);

impl fmt::Display for Card {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let rank = b"A23456789TJQK"[(self.0 % 13) as usize] as char;
        let suit = b"SHDC"[(self.0 / 13) as usize] as char;
        write!(f, "{rank}{suit}")
    }
}

#[derive(Clone, Debug)]
struct PlayerView {
    hand: Vec<Card>,
    others: Vec<(PlayerId, usize)>,
    pile: Vec<(PlayerId, Card)>,
}

#[derive(Clone, Debug)]
enum TableOp {
    Deal,
    View(Box<PlayerView>),
}

#[derive(Debug, Default)]
struct Table {
    players: Vec<PlayerId>,
    hands: BTreeMap<PlayerId, Vec<Card>>,
    pile: Vec<(PlayerId, Card)>,
}

fn shuffled_deck(mut seed: u64) -> Vec<Card> {
    let mut deck: Vec<Card> = (0..52).map(Card).collect();
    for i in (1..deck.len()).rev() {
        seed ^= seed << 13;
        seed ^= seed >> 7;
        seed ^= seed << 17;
        deck.swap(i, (seed % (i as u64 + 1)) as usize);
    }
    deck
}

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> {
        match input {
            LogicInput::AgentJoined { agent } => {
                if let Some(id) = agent.id_cloned() {
                    table.players.push(id);
                }
                Ok(LogicOutput::none())
            }
            LogicInput::AgentOps { source, ops } if source.is_system() => {
                if !ops.iter().any(|op| matches!(op, TableOp::Deal)) {
                    return Ok(LogicOutput::none());
                }
                let mut deck = shuffled_deck(SEED);
                for id in &table.players {
                    table
                        .hands
                        .insert(*id, deck.split_off(deck.len() - HAND_SIZE));
                }
                let everyone = table
                    .players
                    .iter()
                    .map(|id| Agent::new_human(*id))
                    .collect();
                Ok(LogicOutput::none().and_snapshot(SnapshotRequest::to(everyone)))
            }
            _ => Ok(LogicOutput::none()),
        }
    }
}

struct TableView;

#[async_trait]
impl SnapshotProvider<PlayerId, Table, TableOp> for TableView {
    async fn create_snapshot(
        &self,
        table: &Table,
        target: Option<&Agent<PlayerId>>,
        _context: Option<SnapshotContext>,
    ) -> Result<Option<TableOp>, SnapshotError<PlayerId>> {
        let me = target.and_then(|agent| agent.id_cloned());
        let hand = me
            .and_then(|id| table.hands.get(&id))
            .cloned()
            .unwrap_or_default();
        let others = table
            .players
            .iter()
            .filter(|id| Some(**id) != me)
            .map(|id| (*id, table.hands.get(id).map_or(0, Vec::len)))
            .collect();
        Ok(Some(TableOp::View(Box::new(PlayerView {
            hand,
            others,
            pile: table.pile.clone(),
        }))))
    }
}

fn name(id: PlayerId) -> &'static str {
    ["", "alice", "bob", "carol", "dave"][id as usize]
}

fn show(id: PlayerId, op: &TableOp) {
    match op {
        TableOp::View(view) => {
            let hand: Vec<String> = view.hand.iter().map(Card::to_string).collect();
            let others: Vec<String> = view
                .others
                .iter()
                .map(|(other, count)| format!("{} {count}", name(*other)))
                .collect();
            let pile: Vec<String> = view
                .pile
                .iter()
                .map(|(by, card)| format!("{card} by {}", name(*by)))
                .collect();
            println!(
                "{:>5} view: hand [{}] others [{}] pile [{}]",
                name(id),
                hand.join(" "),
                others.join(", "),
                pile.join(", ")
            );
        }
        other => println!("{:>5} got {other:?}", name(id)),
    }
}

async fn next(id: PlayerId, inbox: &ClientInbox<TableOp, PlayerId>) {
    let msg = inbox.recv().await.expect("session closed");
    for op in &msg.ops {
        show(id, op);
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let session = InProcessSession::<TableOp, PlayerId>::new();
    let (tx, controller) = StateControllerBuilder::new(
        Arc::new(TableRules),
        session.clone(),
        Arc::new(TableView),
        Table::default(),
    )
    .build();
    let running = tokio::spawn(controller.run());

    let mut inboxes = Vec::new();
    for id in 1..=3 {
        let (_conn, inbox) = session.connect(Agent::new_human(id)).await?;
        next(id, &inbox).await;
        inboxes.push((id, inbox));
    }

    tx.send(ControllerCommand::SubmitSystemOps {
        source_description: "dealer".into(),
        ops: vec![TableOp::Deal],
    })
    .await?;
    for (id, inbox) in &inboxes {
        next(*id, inbox).await;
    }

    tx.send(ControllerCommand::Shutdown).await?;
    let table = running.await??;
    println!("dealt {} hands", table.hands.len());
    Ok(())
}

The rules register each player on AgentJoined. Dealing is a system op, TableOp::Deal, which main submits through the controller's CommandSender the way a server-side timer or an admin tool would. The deal does not send any cards itself. It returns LogicOutput::none().and_snapshot(SnapshotRequest::to(everyone)), which asks the controller to build a fresh view for each player once the rules return. Every connecting client also gets a join snapshot without asking. main reads that one before it connects the next player.

Run it:

$ cargo run
alice view: hand [] others [] pile []
  bob view: hand [] others [alice 0] pile []
carol view: hand [] others [alice 0, bob 0] pile []
alice view: hand [9C 5C AH JC] others [bob 4, carol 4] pile []
  bob view: hand [8H TC 3C 2D] others [alice 4, carol 4] pile []
carol view: hand [JS AD JH 5S] others [alice 4, bob 4] pile []
dealt 3 hands

The first three lines are the join snapshots, taken before the deal, so the hands are empty and each player only sees the people who arrived before them. After the deal each player sees four cards of their own and a count of four for the other two. The same Table produced three different payloads. None of them carries a card that belongs to someone else.

Bring in a player who joins late#

A player who arrives in the middle of a hand needs the whole table at once. In plaza that is the join snapshot again: the controller calls your rules with AgentJoined and then sends the joiner a snapshot built for them. A late joiner is just a recipient that has not had a snapshot yet, so there is no separate catch-up message to design.

The rules also have to decide who else hears about the join. Here the late joiner is dealt four cards from what is left of the deck, so everyone already seated holds an out-of-date count. The AgentJoined arm asks for a fresh per-recipient snapshot for them, leaving out the joiner, who gets the join snapshot anyway.

This step also lets players play a card. Play comes from a client and Played goes to everyone, because a played card is face up and public.

Replace src/main.rs with the full program:

src/main.rs
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;

use async_trait::async_trait;
use plaza::controller::{ControllerCommand, StateControllerBuilder, query_with};
use plaza::session::in_process::ClientInbox;
use plaza::snapshot::{SnapshotContext, SnapshotError, SnapshotProvider};
use plaza::state_logic::{LogicInput, LogicOutput, SnapshotRequest, StateLogic, StateLogicError};
use plaza::{Agent, InProcessSession, TargetedOp};

type PlayerId = u64;

const HAND_SIZE: usize = 4;
const SEED: u64 = 0x5eed_ca4d;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Card(u8);

impl fmt::Display for Card {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let rank = b"A23456789TJQK"[(self.0 % 13) as usize] as char;
        let suit = b"SHDC"[(self.0 / 13) as usize] as char;
        write!(f, "{rank}{suit}")
    }
}

#[derive(Clone, Debug)]
struct PlayerView {
    hand: Vec<Card>,
    others: Vec<(PlayerId, usize)>,
    pile: Vec<(PlayerId, Card)>,
}

#[derive(Clone, Debug)]
enum TableOp {
    Deal,
    Play(Card),
    Played { by: PlayerId, card: Card },
    View(Box<PlayerView>),
}

#[derive(Debug, Default)]
struct Table {
    players: Vec<PlayerId>,
    deck: Vec<Card>,
    hands: BTreeMap<PlayerId, Vec<Card>>,
    pile: Vec<(PlayerId, Card)>,
}

fn shuffled_deck(mut seed: u64) -> Vec<Card> {
    let mut deck: Vec<Card> = (0..52).map(Card).collect();
    for i in (1..deck.len()).rev() {
        seed ^= seed << 13;
        seed ^= seed >> 7;
        seed ^= seed << 17;
        deck.swap(i, (seed % (i as u64 + 1)) as usize);
    }
    deck
}

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> {
        match input {
            LogicInput::AgentJoined { agent } => {
                let Some(id) = agent.id_cloned() else {
                    return Ok(LogicOutput::none());
                };
                table.players.push(id);
                if table.deck.is_empty() {
                    return Ok(LogicOutput::none());
                }
                let at = table.deck.len() - HAND_SIZE;
                table.hands.insert(id, table.deck.split_off(at));
                let others = table
                    .players
                    .iter()
                    .filter(|other| **other != id)
                    .map(|other| Agent::new_human(*other))
                    .collect();
                Ok(LogicOutput::none().and_snapshot(SnapshotRequest::to(others)))
            }
            LogicInput::AgentOps { source, ops } if source.is_system() => {
                if !ops.iter().any(|op| matches!(op, TableOp::Deal)) {
                    return Ok(LogicOutput::none());
                }
                table.deck = shuffled_deck(SEED);
                for id in &table.players {
                    let at = table.deck.len() - HAND_SIZE;
                    table.hands.insert(*id, table.deck.split_off(at));
                }
                let everyone = table
                    .players
                    .iter()
                    .map(|id| Agent::new_human(*id))
                    .collect();
                Ok(LogicOutput::none().and_snapshot(SnapshotRequest::to(everyone)))
            }
            LogicInput::AgentOps { source, ops } => {
                let Some(by) = source.id_cloned() else {
                    return Ok(LogicOutput::none());
                };
                let mut out = Vec::new();
                for op in ops {
                    let TableOp::Play(card) = op else { continue };
                    let Some(hand) = table.hands.get_mut(&by) else {
                        continue;
                    };
                    let Some(at) = hand.iter().position(|held| *held == card) else {
                        continue;
                    };
                    hand.remove(at);
                    table.pile.push((by, card));
                    out.push(TargetedOp::new_system_all(vec![TableOp::Played {
                        by,
                        card,
                    }]));
                }
                Ok(out.into())
            }
            _ => Ok(LogicOutput::none()),
        }
    }
}

struct TableView;

#[async_trait]
impl SnapshotProvider<PlayerId, Table, TableOp> for TableView {
    async fn create_snapshot(
        &self,
        table: &Table,
        target: Option<&Agent<PlayerId>>,
        _context: Option<SnapshotContext>,
    ) -> Result<Option<TableOp>, SnapshotError<PlayerId>> {
        let me = target.and_then(|agent| agent.id_cloned());
        let hand = me
            .and_then(|id| table.hands.get(&id))
            .cloned()
            .unwrap_or_default();
        let others = table
            .players
            .iter()
            .filter(|id| Some(**id) != me)
            .map(|id| (*id, table.hands.get(id).map_or(0, Vec::len)))
            .collect();
        Ok(Some(TableOp::View(Box::new(PlayerView {
            hand,
            others,
            pile: table.pile.clone(),
        }))))
    }
}

fn name(id: PlayerId) -> &'static str {
    ["", "alice", "bob", "carol", "dave"][id as usize]
}

fn show(id: PlayerId, op: &TableOp) {
    match op {
        TableOp::View(view) => {
            let hand: Vec<String> = view.hand.iter().map(Card::to_string).collect();
            let others: Vec<String> = view
                .others
                .iter()
                .map(|(other, count)| format!("{} {count}", name(*other)))
                .collect();
            let pile: Vec<String> = view
                .pile
                .iter()
                .map(|(by, card)| format!("{card} by {}", name(*by)))
                .collect();
            println!(
                "{:>5} view: hand [{}] others [{}] pile [{}]",
                name(id),
                hand.join(" "),
                others.join(", "),
                pile.join(", ")
            );
        }
        TableOp::Played { by, card } => {
            println!("{:>5} heard: {} played {card}", name(id), name(*by))
        }
        other => println!("{:>5} got {other:?}", name(id)),
    }
}

async fn next(id: PlayerId, inbox: &ClientInbox<TableOp, PlayerId>) {
    let msg = inbox.recv().await.expect("session closed");
    for op in &msg.ops {
        show(id, op);
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let session = InProcessSession::<TableOp, PlayerId>::new();
    let (tx, controller) = StateControllerBuilder::new(
        Arc::new(TableRules),
        session.clone(),
        Arc::new(TableView),
        Table::default(),
    )
    .build();
    let running = tokio::spawn(controller.run());

    let mut inboxes = Vec::new();
    for id in 1..=3 {
        let (_conn, inbox) = session.connect(Agent::new_human(id)).await?;
        inbox.recv().await?;
        inboxes.push((id, inbox));
    }

    tx.send(ControllerCommand::SubmitSystemOps {
        source_description: "dealer".into(),
        ops: vec![TableOp::Deal],
    })
    .await?;
    for (id, inbox) in &inboxes {
        next(*id, inbox).await;
    }

    let alice_card = query_with(&tx, |table: &Table| table.hands[&1][0]).await?;
    session
        .client_send(Agent::new_human(1), vec![TableOp::Play(alice_card)])
        .await;
    for (id, inbox) in &inboxes {
        next(*id, inbox).await;
    }

    println!("-- dave joins late --");
    let (_conn, dave) = session.connect(Agent::new_human(4)).await?;
    for (id, inbox) in &inboxes {
        next(*id, inbox).await;
    }
    next(4, &dave).await;

    tx.send(ControllerCommand::Shutdown).await?;
    let table = running.await??;
    println!("{} cards left in the deck", table.deck.len());
    Ok(())
}

query_with runs a closure on the controller's task and sends back what it returns, so main can pick a card from Alice's hand without copying the whole table. session.client_send submits ops as if Alice's client had sent them. When the rules get an op they cannot honour, such as a card that is not in the sender's hand, they drop it instead of returning an error. On a network an op can cross a departure or a change in the rules. That race is normal.

Run it:

$ cargo run
alice view: hand [9C 5C AH JC] others [bob 4, carol 4] pile []
  bob view: hand [8H TC 3C 2D] others [alice 4, carol 4] pile []
carol view: hand [JS AD JH 5S] others [alice 4, bob 4] pile []
alice heard: alice played 9C
  bob heard: alice played 9C
carol heard: alice played 9C
-- dave joins late --
alice view: hand [5C AH JC] others [bob 4, carol 4, dave 4] pile [9C by alice]
  bob view: hand [8H TC 3C 2D] others [alice 3, carol 4, dave 4] pile [9C by alice]
carol view: hand [JS AD JH 5S] others [alice 3, bob 4, dave 4] pile [9C by alice]
 dave view: hand [2H 3S QS 2S] others [alice 3, bob 4, carol 4] pile [9C by alice]
36 cards left in the deck

Dave's one snapshot gives him his own hand, the other three players' counts and the pile with Alice's 9C. The players already seated get refreshed views that now list Dave with four cards. Nobody's hand appears anywhere except in its owner's view.

Prove that no hidden card leaks#

Filtering the snapshot is not enough on its own. Every op in the outbound stream reaches a client and an event can leak just as easily as a view. Here each player draws a card after they play. The first version of the draw announces the card to the table.

The clients now record everything they hear instead of printing it. Client::until reads messages until one matches, because plaza merges neighbouring ops for the same target into one envelope, so the number of messages a client receives is not something to count on. main waits for each play to reach every client before the next one, which keeps the output in the same order on every run. After shutdown, drain collects whatever is still queued. Then report lists every card a client heard about that is still sitting in somebody else's hand.

Replace src/main.rs with the full program:

src/main.rs
use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
use std::sync::Arc;

use async_trait::async_trait;
use plaza::controller::{ControllerCommand, StateControllerBuilder, query_with};
use plaza::session::in_process::ClientInbox;
use plaza::snapshot::{SnapshotContext, SnapshotError, SnapshotProvider};
use plaza::state_logic::{LogicInput, LogicOutput, SnapshotRequest, StateLogic, StateLogicError};
use plaza::{Agent, InProcessSession, TargetedOp};

type PlayerId = u64;

const HAND_SIZE: usize = 4;
const SEED: u64 = 0x5eed_ca4d;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Card(u8);

impl fmt::Display for Card {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let rank = b"A23456789TJQK"[(self.0 % 13) as usize] as char;
        let suit = b"SHDC"[(self.0 / 13) as usize] as char;
        write!(f, "{rank}{suit}")
    }
}

#[derive(Clone, Debug)]
struct PlayerView {
    hand: Vec<Card>,
    others: Vec<(PlayerId, usize)>,
    pile: Vec<(PlayerId, Card)>,
}

#[derive(Clone, Debug)]
enum TableOp {
    Deal,
    Play(Card),
    Played { by: PlayerId, card: Card },
    Drew { by: PlayerId, card: Option<Card> },
    View(Box<PlayerView>),
}

#[derive(Debug, Default)]
struct Table {
    players: Vec<PlayerId>,
    deck: Vec<Card>,
    hands: BTreeMap<PlayerId, Vec<Card>>,
    pile: Vec<(PlayerId, Card)>,
}

fn shuffled_deck(mut seed: u64) -> Vec<Card> {
    let mut deck: Vec<Card> = (0..52).map(Card).collect();
    for i in (1..deck.len()).rev() {
        seed ^= seed << 13;
        seed ^= seed >> 7;
        seed ^= seed << 17;
        deck.swap(i, (seed % (i as u64 + 1)) as usize);
    }
    deck
}

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> {
        match input {
            LogicInput::AgentJoined { agent } => {
                let Some(id) = agent.id_cloned() else {
                    return Ok(LogicOutput::none());
                };
                table.players.push(id);
                if table.deck.is_empty() {
                    return Ok(LogicOutput::none());
                }
                let at = table.deck.len() - HAND_SIZE;
                table.hands.insert(id, table.deck.split_off(at));
                let others = table
                    .players
                    .iter()
                    .filter(|other| **other != id)
                    .map(|other| Agent::new_human(*other))
                    .collect();
                Ok(LogicOutput::none().and_snapshot(SnapshotRequest::to(others)))
            }
            LogicInput::AgentOps { source, ops } if source.is_system() => {
                if !ops.iter().any(|op| matches!(op, TableOp::Deal)) {
                    return Ok(LogicOutput::none());
                }
                table.deck = shuffled_deck(SEED);
                for id in &table.players {
                    let at = table.deck.len() - HAND_SIZE;
                    table.hands.insert(*id, table.deck.split_off(at));
                }
                let everyone = table
                    .players
                    .iter()
                    .map(|id| Agent::new_human(*id))
                    .collect();
                Ok(LogicOutput::none().and_snapshot(SnapshotRequest::to(everyone)))
            }
            LogicInput::AgentOps { source, ops } => {
                let Some(by) = source.id_cloned() else {
                    return Ok(LogicOutput::none());
                };
                let mut out = Vec::new();
                for op in ops {
                    let TableOp::Play(card) = op else { continue };
                    let Some(hand) = table.hands.get_mut(&by) else {
                        continue;
                    };
                    let Some(at) = hand.iter().position(|held| *held == card) else {
                        continue;
                    };
                    hand.remove(at);
                    table.pile.push((by, card));
                    out.push(TargetedOp::new_system_all(vec![TableOp::Played {
                        by,
                        card,
                    }]));

                    let Some(drawn) = table.deck.pop() else {
                        continue;
                    };
                    hand.push(drawn);
                    out.push(TargetedOp::new_system_all(vec![TableOp::Drew {
                        by,
                        card: Some(drawn),
                    }]));
                }
                Ok(out.into())
            }
            _ => Ok(LogicOutput::none()),
        }
    }
}

struct TableView;

#[async_trait]
impl SnapshotProvider<PlayerId, Table, TableOp> for TableView {
    async fn create_snapshot(
        &self,
        table: &Table,
        target: Option<&Agent<PlayerId>>,
        _context: Option<SnapshotContext>,
    ) -> Result<Option<TableOp>, SnapshotError<PlayerId>> {
        let me = target.and_then(|agent| agent.id_cloned());
        let hand = me
            .and_then(|id| table.hands.get(&id))
            .cloned()
            .unwrap_or_default();
        let others = table
            .players
            .iter()
            .filter(|id| Some(**id) != me)
            .map(|id| (*id, table.hands.get(id).map_or(0, Vec::len)))
            .collect();
        Ok(Some(TableOp::View(Box::new(PlayerView {
            hand,
            others,
            pile: table.pile.clone(),
        }))))
    }
}

fn name(id: PlayerId) -> &'static str {
    ["", "alice", "bob", "carol", "dave"][id as usize]
}

fn show(id: PlayerId, op: &TableOp) {
    match op {
        TableOp::View(view) => {
            let hand: Vec<String> = view.hand.iter().map(Card::to_string).collect();
            let others: Vec<String> = view
                .others
                .iter()
                .map(|(other, count)| format!("{} {count}", name(*other)))
                .collect();
            let pile: Vec<String> = view
                .pile
                .iter()
                .map(|(by, card)| format!("{card} by {}", name(*by)))
                .collect();
            println!(
                "{:>5} view: hand [{}] others [{}] pile [{}]",
                name(id),
                hand.join(" "),
                others.join(", "),
                pile.join(", ")
            );
        }
        TableOp::Played { by, card } => {
            println!("{:>5} heard: {} played {card}", name(id), name(*by))
        }
        TableOp::Drew {
            by,
            card: Some(card),
        } => println!("{:>5} heard: {} drew {card}", name(id), name(*by)),
        TableOp::Drew { by, card: None } => {
            println!("{:>5} heard: {} drew a card", name(id), name(*by))
        }
        other => println!("{:>5} got {other:?}", name(id)),
    }
}

struct Client {
    id: PlayerId,
    inbox: ClientInbox<TableOp, PlayerId>,
    heard: Vec<TableOp>,
}

impl Client {
    async fn join(
        session: &InProcessSession<TableOp, PlayerId>,
        id: PlayerId,
    ) -> Result<Self, Box<dyn Error>> {
        let (_conn, inbox) = session.connect(Agent::new_human(id)).await?;
        let mut client = Client {
            id,
            inbox,
            heard: Vec::new(),
        };
        client.next().await?;
        Ok(client)
    }

    async fn next(&mut self) -> Result<(), Box<dyn Error>> {
        let msg = self.inbox.recv().await?;
        self.heard.extend(msg.ops);
        Ok(())
    }

    async fn until(&mut self, done: impl Fn(&TableOp) -> bool) -> Result<(), Box<dyn Error>> {
        let mut seen = self.heard.len();
        loop {
            if self.heard[seen..].iter().any(&done) {
                return Ok(());
            }
            seen = self.heard.len();
            self.next().await?;
        }
    }

    fn drain(&mut self) {
        while let Ok(msg) = self.inbox.try_recv() {
            self.heard.extend(msg.ops);
        }
    }

    fn cards_named(&self) -> Vec<Card> {
        let mut cards = Vec::new();
        for op in &self.heard {
            match op {
                TableOp::View(view) => {
                    cards.extend(&view.hand);
                    cards.extend(view.pile.iter().map(|(_, card)| *card));
                }
                TableOp::Played { card, .. } => cards.push(*card),
                TableOp::Drew {
                    card: Some(card), ..
                } => cards.push(*card),
                _ => {}
            }
        }
        cards
    }
}

fn report(client: &Client, table: &Table) {
    let named = client.cards_named();
    let mut leaks = Vec::new();
    for (owner, hand) in &table.hands {
        if *owner == client.id {
            continue;
        }
        for card in hand.iter().filter(|card| named.contains(card)) {
            leaks.push(format!("{card} ({})", name(*owner)));
        }
    }
    let hidden: usize = table
        .hands
        .iter()
        .filter(|(owner, _)| **owner != client.id)
        .map(|(_, hand)| hand.len())
        .sum();
    println!(
        "{:>5}: {} ops heard, {} hidden cards elsewhere, leaked: {}",
        name(client.id),
        client.heard.len(),
        hidden,
        if leaks.is_empty() {
            "none".to_string()
        } else {
            leaks.join(" ")
        }
    );
}

#[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(TableView),
        Table::default(),
    )
    .build();
    let running = tokio::spawn(controller.run());

    let mut clients = Vec::new();
    for id in 1..=3 {
        clients.push(Client::join(&session, id).await?);
    }

    tx.send(ControllerCommand::SubmitSystemOps {
        source_description: "dealer".into(),
        ops: vec![TableOp::Deal],
    })
    .await?;
    for client in &mut clients {
        client.next().await?;
    }

    for id in 1..=3 {
        let card = query_with(&tx, move |table: &Table| table.hands[&id][0]).await?;
        session
            .client_send(Agent::new_human(id), vec![TableOp::Play(card)])
            .await;
        for client in &mut clients {
            client
                .until(|op| matches!(op, TableOp::Played { card: played, .. } if *played == card))
                .await?;
        }
    }

    let dave = Client::join(&session, 4).await?;
    for client in &mut clients {
        client
            .until(|op| matches!(op, TableOp::View(view) if view.others.len() == 3))
            .await?;
    }
    clients.push(dave);

    tx.send(ControllerCommand::Shutdown).await?;
    let table = running.await??;
    for client in &mut clients {
        client.drain();
    }
    for op in &clients[1].heard {
        show(2, op);
    }
    for client in &clients {
        report(client, &table);
    }
    Ok(())
}

Run it:

$ cargo run
  bob view: hand [] others [alice 0] pile []
  bob view: hand [8H TC 3C 2D] others [alice 4, carol 4] pile []
  bob heard: alice played 9C
  bob heard: alice drew 2S
  bob heard: bob played 8H
  bob heard: bob drew QS
  bob heard: carol played JS
  bob heard: carol drew 3S
  bob view: hand [TC 3C 2D QS] others [alice 4, carol 4, dave 4] pile [9C by alice, 8H by bob, JS by carol]
alice: 9 ops heard, 12 hidden cards elsewhere, leaked: QS (bob) 3S (carol)
  bob: 9 ops heard, 12 hidden cards elsewhere, leaked: 2S (alice) 3S (carol)
carol: 9 ops heard, 12 hidden cards elsewhere, leaked: 2S (alice) QS (bob)
 dave: 1 ops heard, 12 hidden cards elsewhere, leaked: none

Bob's log shows the problem: he heard that Alice drew 2S and that Carol drew 3S. Each of the three seated players has two of the others' cards in their transcript. Dave joined after the draws and only has his join snapshot, so he saw nothing he shouldn't have.

The fix is to send the card only to the player who drew it and tell everyone else that a card was drawn. TargetedOp::new_system_to addresses one agent and MessageTarget::AllExcept addresses everyone but one. Replace the draw broadcast with this:

src/main.rs
out.push(TargetedOp::new_system_to(
    by,
    vec![TableOp::Drew {
        by,
        card: Some(drawn),
    }],
));
out.push(TargetedOp::new(
    Agent::system(),
    MessageTarget::AllExcept(by),
    vec![TableOp::Drew { by, card: None }],
));

Add MessageTarget to the use plaza::{...} line and run it again:

$ cargo run
  bob view: hand [] others [alice 0] pile []
  bob view: hand [8H TC 3C 2D] others [alice 4, carol 4] pile []
  bob heard: alice played 9C
  bob heard: alice drew a card
  bob heard: bob played 8H
  bob heard: bob drew QS
  bob heard: carol played JS
  bob heard: carol drew a card
  bob view: hand [TC 3C 2D QS] others [alice 4, carol 4, dave 4] pile [9C by alice, 8H by bob, JS by carol]
alice: 9 ops heard, 12 hidden cards elsewhere, leaked: none
  bob: 9 ops heard, 12 hidden cards elsewhere, leaked: none
carol: 9 ops heard, 12 hidden cards elsewhere, leaked: none
 dave: 1 ops heard, 12 hidden cards elsewhere, leaked: none

Bob still hears that a draw happened, so his client can update the count, but the card stays with its owner. Bob's own draw still shows him QS. The report reads none for every player. Keep a check like this running: a new op that names a card will show up in it the first time it is sent to the wrong audience.

Send one view to everyone#

A per-recipient pass costs one provider call and one encode per recipient. When a view is the same for everyone, SnapshotRequest::uniform runs the provider once with no target and sends that one payload to every recipient. That is only safe when the view holds nothing any recipient may not see.

This table's provider already has a safe answer for no target: me is None, so the hand comes back empty and every player appears as a count. A Tally op uses it to send the whole table a public summary. Add Tally to the ops:

src/main.rs
#[derive(Clone, Debug)]
enum TableOp {
    Deal,
    Play(Card),
    Played { by: PlayerId, card: Card },
    Drew { by: PlayerId, card: Option<Card> },
    Tally,
    View(Box<PlayerView>),
}

Give the provider a counter so you can see how often it is called. It needs use std::sync::atomic::{AtomicUsize, Ordering};:

src/main.rs
#[derive(Default)]
struct TableView {
    calls: AtomicUsize,
}

#[async_trait]
impl SnapshotProvider<PlayerId, Table, TableOp> for TableView {
    async fn create_snapshot(
        &self,
        table: &Table,
        target: Option<&Agent<PlayerId>>,
        _context: Option<SnapshotContext>,
    ) -> Result<Option<TableOp>, SnapshotError<PlayerId>> {
        self.calls.fetch_add(1, Ordering::Relaxed);
        let me = target.and_then(|agent| agent.id_cloned());
        let hand = me
            .and_then(|id| table.hands.get(&id))
            .cloned()
            .unwrap_or_default();
        let others = table
            .players
            .iter()
            .filter(|id| Some(**id) != me)
            .map(|id| (*id, table.hands.get(id).map_or(0, Vec::len)))
            .collect();
        Ok(Some(TableOp::View(Box::new(PlayerView {
            hand,
            others,
            pile: table.pile.clone(),
        }))))
    }
}

Replace the system-op arm of the rules so it handles both Deal and Tally:

src/main.rs
LogicInput::AgentOps { source, ops } if source.is_system() => {
    let everyone: Vec<_> = table
        .players
        .iter()
        .map(|id| Agent::new_human(*id))
        .collect();
    let mut out = LogicOutput::none();
    for op in ops {
        match op {
            TableOp::Deal => {
                table.deck = shuffled_deck(SEED);
                for id in &table.players {
                    let at = table.deck.len() - HAND_SIZE;
                    table.hands.insert(*id, table.deck.split_off(at));
                }
                out = out.and_snapshot(SnapshotRequest::to(everyone.clone()));
            }
            TableOp::Tally => {
                out = out.and_snapshot(SnapshotRequest::uniform(everyone.clone()))
            }
            _ => {}
        }
    }
    Ok(out)
}

In main, build the controller in two steps so you can keep its ControllerStats from builder.stats(). Then measure each pass: the three joins, the deal, Dave's late join and the tally. Replace everything in main before the final Ok(()) with this:

src/main.rs
let session = InProcessSession::<TableOp, PlayerId>::new();
let view = Arc::new(TableView::default());
let builder = StateControllerBuilder::new(
    Arc::new(TableRules),
    session.clone(),
    view.clone(),
    Table::default(),
);
let stats = builder.stats();
let (tx, controller) = builder.build();
let running = tokio::spawn(controller.run());
let count = || (view.calls.load(Ordering::Relaxed), stats.snapshots());
let report_pass = |pass: &str, (calls, sent): (usize, u64)| {
    let (now_calls, now_sent) = count();
    println!(
        "{pass}: provider calls {}, snapshots sent {}",
        now_calls - calls,
        now_sent - sent
    );
};

let before = count();
let mut clients = Vec::new();
for id in 1..=3 {
    clients.push(Client::join(&session, id).await?);
}
report_pass("3 joins", before);

let before = count();
tx.send(ControllerCommand::SubmitSystemOps {
    source_description: "dealer".into(),
    ops: vec![TableOp::Deal],
})
.await?;
for client in &mut clients {
    client.until(|op| matches!(op, TableOp::View(_))).await?;
}
report_pass("deal", before);

for id in 1..=3 {
    let card = query_with(&tx, move |table: &Table| table.hands[&id][0]).await?;
    session
        .client_send(Agent::new_human(id), vec![TableOp::Play(card)])
        .await;
    for client in &mut clients {
        client
            .until(|op| matches!(op, TableOp::Played { card: played, .. } if *played == card))
            .await?;
    }
}

let before = count();
let dave = Client::join(&session, 4).await?;
for client in &mut clients {
    client
        .until(|op| matches!(op, TableOp::View(view) if view.others.len() == 3))
        .await?;
}
clients.push(dave);
report_pass("dave joins", before);

let before = count();
tx.send(ControllerCommand::SubmitSystemOps {
    source_description: "tally".into(),
    ops: vec![TableOp::Tally],
})
.await?;
for client in &mut clients {
    client.until(|op| matches!(op, TableOp::View(_))).await?;
    show(client.id, client.heard.last().unwrap());
}
report_pass("tally", before);

tx.send(ControllerCommand::Shutdown).await?;
let table = running.await??;
for client in &mut clients {
    client.drain();
}
for client in &clients {
    report(client, &table);
}

Run it:

$ cargo run
3 joins: provider calls 3, snapshots sent 3
deal: provider calls 3, snapshots sent 3
dave joins: provider calls 4, snapshots sent 4
alice view: hand [] others [alice 4, bob 4, carol 4, dave 4] pile [9C by alice, 8H by bob, JS by carol]
  bob view: hand [] others [alice 4, bob 4, carol 4, dave 4] pile [9C by alice, 8H by bob, JS by carol]
carol view: hand [] others [alice 4, bob 4, carol 4, dave 4] pile [9C by alice, 8H by bob, JS by carol]
 dave view: hand [] others [alice 4, bob 4, carol 4, dave 4] pile [9C by alice, 8H by bob, JS by carol]
tally: provider calls 1, snapshots sent 4
alice: 10 ops heard, 12 hidden cards elsewhere, leaked: none
  bob: 10 ops heard, 12 hidden cards elsewhere, leaked: none
carol: 10 ops heard, 12 hidden cards elsewhere, leaked: none
 dave: 2 ops heard, 12 hidden cards elsewhere, leaked: none

The joins, the deal and Dave's arrival are all per recipient, so the provider runs once per snapshot sent. The tally sends four snapshots from one provider call. Every client got the same view, with no hand in it. The leak report still reads none.

Use per-recipient snapshots by default. A needless per-recipient pass costs a few microseconds, while a uniform view that includes something secret sends it to everyone in the request.

Where to go next#

The provider has two shortcuts. SnapshotFn wraps a plain function fn(&Table, Option<&Agent<PlayerId>>) -> Option<TableOp> when the view needs no async and no error. NoSnapshots, through StateControllerBuilder::without_snapshots, is for an app with no catch-up at all. Returning Ok(None) from a provider sends one recipient nothing. When a view depends on more than the recipient, SnapshotRequest::with_context passes a SnapshotContext from your rules to your provider.

The card_table example is a full version of this table with turns, rounds, scoring and bots that play from the same filtered view a browser gets. Chapter 10 of the plaza guide, What each player sees, covers hidden information, the recipient's own state and uniform snapshots. The plaza usage guide on GitHub covers the controller, snapshots, time and guards.

The full API reference is on docs.rs.