Share a counter between two clients
For Rust developers who know Tokio and want to see the smallest complete plaza app before building a game or a collaborative tool on it
You will build a counter that two clients share. Each client joins, receives a snapshot of the current value and sends increments. Every change is broadcast back to both. Along the way you will see the types plaza is generic over, why only the controller ever changes the state, how to feed it time, how to ask it a question while it runs and how to shut it down so a closing message still goes out.
Before you start
You need Rust with Cargo. plaza comes from crates.io:
plaza = "0"Set up the project#
Start a new binary crate.
Add plaza, Tokio and async-trait. The traits you implement are async, so you need async-trait to write them. Your [dependencies] section should look like this:
[]
= "0"
= { = "1", = ["macros", "rt"] }
= "0.1"Everything here runs in one process over InProcessSession, plaza's in-memory transport, so there are no sockets to set up. The programs use Tokio's single-threaded current_thread runtime and every client reads its inbox in a fixed order, so the output is the same on every run. The ops never cross a network, so they need no serde derives either. A real transport would need them.
Write the rules#
A plaza app is three types of your own and the rules that connect them. The state is the data being shared, here a struct holding one number. The op enum is everything a client can send and everything the server sends back. The id is whatever names a client; a plain u32 works.
Those three are the type parameters nearly everything in plaza is generic over:
| Parameter | Here | What it is |
|---|---|---|
StateType | Counter | The shared state. It needs Debug + Send + Sync + 'static. It needs Clone only if you call query_state. |
Op | CounterOp | The operations, in both directions. It needs Clone + Debug + Send + Sync + 'static, plus serde to cross a network. |
ID | UserId | Who acted. Any Clone + Debug + Eq + Hash + Send + Sync + 'static type is an AgentId. |
The traits name them in different orders: StateLogic<Op, ID, StateType> and SnapshotProvider<ID, StateType, Op>. The compiler will tell you when you swap two.
The rules are a StateLogic, which has one method. process_input gets the state as &mut and one LogicInput at a time. It returns a LogicOutput saying which ops to send to whom. This is the only place the state is ever changed. The StateController owns the state and calls your rules from its own task, one input after another, so there is nothing to lock.
The snapshot provider decides what a client is sent when it joins. The controller calls create_snapshot once per recipient and passes the agent it is for, so each client could get its own view. Here everyone may see the whole counter, so it ignores the target and returns the value. A snapshot is just another op variant.
Replace src/main.rs with this:
use Arc;
use async_trait;
use
;
type UserId = u32;
;
;
async
StateControllerBuilder::new takes the rules, the session, the snapshot provider and the initial state. build returns the controller and a CommandSender. The sender is the handle everything outside the controller uses to talk to it. Nothing in plaza spawns a task for you, so you spawn controller.run().
session.connect is what a socket connecting would be on a real transport. It returns an inbox for that client and announces the join. The controller answers by sending the joiner a snapshot. client_send is the client sending ops. A client can send as soon as connect returns, because the controller handles a client's join before any op it sends, so the snapshot always comes first. The rules turn each Increment into a Changed broadcast with TargetedOp::new_system_all. recv on an inbox waits for the next SessionMessage, which holds the sender and a list of ops. show prints the ops a client can receive.
The rules ignore an Increment from an agent with no id. Agent is Human(id), Bot(id) or System. Only the first two carry an id.
Run it:
Alice joined while the value was 0, then her own increment came back to her as a broadcast. The client never changes its local copy on its own. It waits for the server to say what happened.
When a provider is a plain function of the state and the recipient, as this one is, you can skip the trait. Write the function and wrap it in SnapshotFn:
Then pass Arc::new(SnapshotFn(view)) to the builder in place of Arc::new(CounterView) and import plaza::snapshot::SnapshotFn. The output is the same. Implement the trait directly when building a view can fail or has to await.
Add a second client#
Bob joins after Alice has already changed the value. His snapshot should show the value as it is now. His increment should reach both of them.
Replace main with this:
async
Run it:
Bob's snapshot says 5 because the controller built it from the state when he joined. His increment was broadcast to everyone connected, so Alice and Bob both see 8.
The program reads Alice's two messages before Bob connects and reads the inboxes in a fixed order. That only keeps the printed output the same on every run.
Ask the controller a question#
Code outside the controller cannot read the state directly, since the controller owns it. You ask instead. query_with sends a closure to the controller, which runs it on its own task with the state borrowed and sends back the result.
Add query_with to the plaza::controller import, then rename _tx to tx and add this to the end of main, before Ok(()):
let value = query_with.await?;
println!;Run it:
Only the i64 is copied. The closure runs inside the controller loop, so it must not block or await. query_state(&tx) returns a copy of the whole state instead. It is the only call that needs Counter to be Clone. On a large state, ask for the field you need.
Feed it time with a TickDriver#
The controller does not keep time. Time reaches the rules the same way ops do, as an input: LogicInput::TimeStep { delta_time }. A TickDriver is a loop that sends those steps at a rate you pick.
The counter will keep track of how long it has been running. Add use std::time::Duration; and tick_driver::TickDriver to the imports, then give the state an uptime field:
The rules now match on the input, so they can handle TimeStep as well as ops:
;
Add this to main after the query:
from_hz
.run_fixed_for
.await;
let uptime = query_with.await?;
println!;Run it:
run_fixed_for wakes at 100 Hz and hands the controller exactly 50 steps of exactly 10 ms each, so the uptime is always 500 ms however busy the machine is. The bounded form returns once it has sent them, which suits a demo. A server spawns the unbounded forms and leaves them running:
tokio::spawn(TickDriver::from_hz(60).run(tx.clone()))passes the measured time since the last tick. Use it for logic that only integrates, such as a decay or a cooldown.tokio::spawn(TickDriver::from_hz(60).run_fixed(tx.clone(), Duration::from_millis(16)))always passes the same step. Use it whenever anything predicts, replays or rolls back the logic, since a client can only reproduce a simulation stepped in fixed amounts.
Shut it down#
run returns when the controller receives ControllerCommand::Shutdown. It hands back the final state for you to save. Commands already queued when Shutdown arrives are processed first, so a closing message submitted just before it is guaranteed to go out.
The closing message comes from the server itself, which submits it as a system op. Add a variant to the op enum:
Then update the rules so an Increment still needs an id while Closing is only accepted from System:
;
The show function needs an arm for the new variant:
In main, keep the controller's join handle with let running = tokio::spawn(controller.run());, add ControllerCommand to the plaza::controller import and finish with this:
tx.send
.await?;
tx.send.await?;
let last = running.await??;
show;
show;
println!;Run it:
SubmitSystemOps reaches the rules as AgentOps from Agent::System, which is how the rules tell it apart from a client trying to send Closing. The broadcast goes out, then Shutdown stops the loop and running.await?? hands back the final Counter. The first ? is Tokio's join error and the second is the controller's own PlazaError.
Here is the whole program:
use Arc;
use Duration;
use async_trait;
use
;
type UserId = u32;
;
;
async
Where to go next#
The rules here ignore AgentJoined and AgentLeft. A real app usually seats a player on join and frees the seat on leave. The session reports both the same way it reported the join here. InProcessSession::disconnect is the in-process equivalent of a socket closing. When a change alters what clients should see, the rules can push fresh views without waiting for a join by returning LogicOutput::ops(ops).and_snapshot(SnapshotRequest::to(recipients)).
One loop, one truth in the plaza guide explains why the controller is shaped this way. What each player sees covers snapshots and per-player views. The shared_counter example is a fuller version of this program.
The plaza usage guide on GitHub covers guards, uniform snapshots and the rest of the controller. The full API reference is on docs.rs.
