Send only what each player can see
For Rust developers whose plaza world is bigger than one player's screen and who want to know what each piece of interest management saves
You will build a yard of 2,000 crates with three players walking through it. First a spatial grid cuts each player's frame down to the crates within view. Then the view is streamed as deltas against what each client has acknowledged, with a mirror on the client that checks it still agrees with the server. Then packets are dropped to show why the acknowledged baseline matters. Last, an aggregate tree gives each player a coarse picture of the rest of the world. Every step prints the bytes it costs.
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 the two utility crates. plaza_server_utils holds the server half: the spatial grid, the delta baseline and the aggregate tree. plaza_client_utils holds the client half, the delta mirror, plus the slot keys and digests both halves share. Your [dependencies] section should look like this:
[]
= "0"
= "0"Neither crate needs an async runtime. Everything in this tutorial is plain data structures driven from a loop, so the server and the three clients run side by side in one function. In a plaza app the server half runs in your StateLogic's TimeStep arm and each packet travels as one of your ops. The crates are scattered with a fixed seed and the players walk fixed paths, so every run prints the same numbers.
Send each player only what is near it#
Relevance means telling each client about the entities near it and nothing else. SpatialGrid buckets entity ids into square cells. query_radius returns every id in the cells that overlap a square around the viewer. That is a superset of the circle, so an exact distance test afterwards drops the extras. A cell about a third of the view radius wide is what plaza's examples use.
The crates never move, so the grid is filled once. A world whose entities move clears and refills it every tick, which costs less than tracking which cell each entity left.
Replace src/main.rs with this:
use TAU;
use ;
const WORLD: f32 = 1000.0;
const CRATES: usize = 2000;
const PLAYERS: usize = 3;
const VIEW: f32 = 100.0;
const CELL: f32 = 32.0;
const TICKS: u64 = 200;
const ENTRY_BYTES: usize = 16;
const SEED: u64 = 0xc0ffee;
Each entry costs 16 bytes in this tutorial's accounting: an 8-byte key and two 4-byte coordinates. The players walk a circle of radius 300 around the middle of the yard, a third of a turn apart, for 200 ticks.
Run it:
The grid hands back about 160 candidates a tick and the distance test keeps about 62 of them. Sending only those costs around 1,000 bytes a tick instead of 32,000 for the whole yard.
Stream the view as deltas#
Most of a player's view is the same from one tick to the next. Sending only what entered and what left is far cheaper, but it is only correct when the client really holds what the server thinks it holds. If the server diffs against what it last sent, one lost packet leaves the client short for good, because nothing ever mentions those crates again.
DeltaBaseline diffs against what the client has acknowledged instead. Keep one per client. Each tick you give plan the set of keys the client should hold now and a sequence number. It returns a DeltaPlan: the keys that entered, the keys that left and full_baseline when the client must clear its copy first. The client applies the packet to a DeltaMirror, which tracks which sequence numbers arrived. It sends back an acknowledgement and the digest of what it holds. observe_ack takes those and moves the server's baseline forward.
The keys are SlotKeys from a SlotAllocator, encoded as a u64 of index and generation. The server and the client fold the same encoded keys into a SetDigest, so a digest from one end means the same set at the other. Each packet carries the digest of the set the client should hold after applying it. settle compares the mirror against it and counts a mismatch.
Replace src/main.rs with the full program:
use BTreeSet;
use TAU;
use ;
use DeltaBaseline;
use ;
const WORLD: f32 = 1000.0;
const CRATES: usize = 2000;
const PLAYERS: usize = 3;
const VIEW: f32 = 100.0;
const CELL: f32 = 32.0;
const TICKS: u64 = 200;
const HISTORY: usize = 32;
const ENTRY_BYTES: usize = 16;
const LEFT_BYTES: usize = 8;
const HEADER_BYTES: usize = 25;
const SEED: u64 = 0xc0ffee;
World::visible is the query from the last step, returning encoded keys in a BTreeSet, which is what plan takes. build turns the plan into a packet by looking up each entering crate. apply is the whole client side: begin opens the packet, the entries go in, the departures come out and settle checks the result. The acknowledgement goes back the same tick here. On a real link it arrives a round trip later and the server keeps planning against the last one it has. A delta packet has a 25-byte header in this accounting (sequence, baseline sequence, digest and a flag) and a departure costs 8 bytes.
Run it:
Deltas bring each player down to under 60 bytes a tick. The one full baseline per player is the first packet: before the first acknowledgement the server cannot know what the client holds, so it sends the whole view. mirror off by compares each mirror with the server's own set at the end using divergence_from, which names the keys that are missing or extra. Both checks read zero.
Lose packets and recover#
A real link loses packets. This step drops one packet in ten for each player and runs the stream twice. RecoveryPolicy::Naive diffs against what was last sent and RecoveryPolicy::AckRecovery, the default, diffs against what was acknowledged. The acknowledgements still get through, so the server learns which packets arrived.
Add RecoveryPolicy to the plaza_server_utils import, then add the loss rule:
Replace the body of main with this:
let world = new;
let mut candidates = Vecnew;
for policy in
should_hold is the view as of the last packet that arrived, which is what the mirror ought to match.
Run it:
The naive stream stays at about 58 bytes a tick and is wrong. Every lost packet takes its entries and departures with it, so each mirror ends up missing crates it should show and holding crates that walked out of view long ago. The digest disagrees on almost every packet after the first loss and nothing in the stream can repair it.
The acknowledged stream ends with every mirror correct and no digest mismatches. Its baseline is the newest contiguous acknowledgement. After a loss the server diffs against the last state before the gap until that state ages out of the HISTORY window of 32 sent states. The next plan is then a full baseline, which is where the extra full baselines in the output come from. At this loss rate the stream averages about 440 bytes a tick.
full_rebuilds and frames_lost are worth putting on a debug panel. Here about 20 lost packets per player led to 5 or 6 full baselines, because one rebuild clears every gap inside the window it replaces.
Summarise the rest of the world#
Relevance is all or nothing: a crate outside the view does not exist for that client. That is fine for things a client only draws. It is wrong for things a client computes with, such as a minimap of where the crowds are or a force that adds up over everything. AggregateTree keeps those distant crates at lower resolution. It replaces a distant group with one stand-in at its weighted centroid and keeps near crates exact.
Add AggregateTree and WeightedPoint to the plaza_server_utils import and const SUMMARY_BYTES: usize = 12; beside the other sizes. Then add this function:
Call it at the end of main:
println!;
whole_world_summary;The weight of each crate is 1.0, so a stand-in's weight is the number of crates it covers. build_in fixes the root cell to the yard rather than to the crates' current extent, which keeps the clusters stable when the tree is rebuilt every tick. summarize walks the tree from the viewer and accepts a whole cell when its width divided by its distance is below theta. A summary entry costs 12 bytes here: two coordinates and a weight.
Run it:
theta 0 accepts no cell, so it is aggregation switched off through the same code path. The four stand-ins there are crates that share a leaf at the tree's maximum depth of 10. At 0.5 the whole yard fits in 122 entries and at 1.0 in 38. How coarse you can go depends on what the client does with the entries. A drawing tolerates more than a simulation. Past about 1.0 the walk starts accepting cells the viewer is standing close to.
Where to go next#
The crates here never move, so the delta stream is the whole story. When entities move, the set membership still travels as deltas and the positions of the entities in view travel beside it. PriorityAccumulator fits those updates into a byte budget without starving anyone, RestDetector skips what has stopped moving and TierBoundary keeps an entity on the edge of view from flickering in and out. Told sends only changed values on a reliable stream where acknowledgements are not needed. For a world with known bounds, CellSpace and DenseGrid replace the hashed grid with a flat table. PlayoutBuffer on the client and DeltaBaseline::with_flow on the server handle a tab that stops reading and comes back.
Chapter 11 of the plaza guide, Keeping the pipe small, covers relevance, deltas, aggregation and priority with numbers from the examples. The plaza_server_utils usage guide and the plaza_client_utils usage guide on GitHub cover both halves of the stream.
The full API references are on docs.rs: plaza_server_utils and plaza_client_utils.
