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:
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:
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.
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.
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 runalice 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.
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.
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 runalice 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 9Ccarol 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.
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.
$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:
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: nonecarol: 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.
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:
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();letmut 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:
$cargo run3 joins: provider calls 3, snapshots sent 3deal: provider calls 3, snapshots sent 3dave joins: provider calls 4, snapshots sent 4alice 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 4alice: 10 ops heard, 12 hidden cards elsewhere, leaked: none bob: 10 ops heard, 12 hidden cards elsewhere, leaked: nonecarol: 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.
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.