# 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 as `plaza = "0"`.

## Set up the project

Start a new binary crate.

```sh
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:

```toml title="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:

```rust title="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:

```shell-session
$ 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:

```rust title="src/main.rs" {6,10,38-39,46,73-78,80-88,94,96-97,105-126,188-190,217,230-236,238-243,247}
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:

```shell-session
$ 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:

```rust title="src/main.rs" {2,41,128-135,202-208,213-217,219-232,234-238,240-249,251-255,257-272,276-304,307,318,320,328-329,332-341,344-348,350,354-362}
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:

```shell-session
$ 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:

```rust title="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:

```shell-session
$ 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:

```rust title="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};`:

```rust title="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`:

```rust title="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:

```rust title="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:

```shell-session
$ 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](https://github.com/excsn/plaza/tree/main/examples/card_table) 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](https://github.com/excsn/plaza/blob/main/docs/guide/10-what-each-player-sees.md), covers hidden information, the recipient's own state and uniform snapshots. The [plaza usage guide](https://github.com/excsn/plaza/blob/main/core/README.USAGE.md) on GitHub covers the controller, snapshots, time and guards.

The full API reference is on [docs.rs](https://docs.rs/plaza).
