plaza_wire 0.7.0

The wire format trait shared by Plaza servers and clients. Pure serde, no async runtime.
Documentation

plaza_wire

License: Mozilla Public License 2.0 (MPL-2.0) · Status: Experimental

The wire vocabulary shared by a Plaza server and whatever talks to it: the WireCodec trait (with a JSON implementation), and the common netcode payload types both ends exchange.

Full surface in API_REFERENCE.md.

The frame

A frame is one kind byte, then the encoded body:

[kind: u8][ codec-encoded body ]
0[{"AssignPlayer":{"player_id":"...","side":"Left"}}]

For Kind::Ops, the body is the ops array itself. Nothing else is on the wire. There is no envelope struct, no sender field, and no serde enum wrapping the payload.

The tag is outside the codec on purpose. A serde enum expresses the same thing, and that is what this used to be, but then the codec decides what the tag costs: a quoted string under JSON, an array element under MessagePack, a field number under protobuf. A byte written ahead of the body costs exactly one byte in every format, and the decoder reads it without parsing anything. Measured on the same message: 39 bytes against 42, and 113ns to decode against 180ns, rising to 239ns for the version that keeps the tag inside the document and still dispatches on it, because that needs a second parse.

An unknown kind is skipped, not fatal. Kind::from_byte returns None for a tag this build does not know, and every transport drops such a frame and carries on. That rule exists from the start because it cannot be added later: a client already deployed cannot learn to tolerate a new frame kind. It is also why the tag is read by hand rather than through serde_repr, which errors on an unknown discriminant and would make the rule unexpressible.

There is no from on the wire. Who sent a message is the server's own bookkeeping, attached by the transport from the connection. Every shipped client already ignored it, and an application that needs to say who did something puts that in its own op at the width it actually needs, which is usually a seat index rather than a 64-bit identity.

On a stream transport, a length prefix rides ahead of the frame. A WebSocket hands each message over whole; TCP hands over bytes, so both ends must agree where a frame ends before either can read a kind byte. That contract is framing: a 4-byte big-endian length then the frame, with delimit for writers and the LengthDelimited decoder (fed bytes, yielding frames, enforcing your max_frame_bytes) for clients and transport adapters that own their own I/O. It is the same layout plaza_session's TCP transport speaks, named where both ends can see it.

What lives here

Agent, AgentId, Kind and the framing helpers, the WireCodec trait, the netcode payloads, and the flow-control notice payloads (flow_payloads: what the turn, round and phase managers wrap into your ops; core re-exports them at their old paths). All of it is genuinely serialized or genuinely shared, which is the rule for this crate: it exists so a browser client can name what it sends without depending on core, which pulls tokio and does not target wasm32-unknown-unknown.

MessageTarget, PresenceEvent, TargetedOp and SessionMessage stay in core. They are server-side routing and plumbing, they are not Serialize, and no client ever sees one.

For plain JavaScript clients, the frame layer ships as a single vendorable file: js/plaza_protocol.js, documented in js/README.md.

Text or binary

WireCodec::is_text() says whether a codec's output is UTF-8. It defaults to false, which is right for any compact binary format, and JsonCodec overrides it to true.

Transports that distinguish frame types use it. It matters for browsers: a WebSocket text frame arrives as a string that JSON.parse(event.data) accepts directly, while a binary frame arrives as a Blob or ArrayBuffer the client must decode itself, having first remembered to set binaryType. Sending JSON as binary is legal and makes every browser client harder to write than it needs to be.

Install

[dependencies]
plaza_wire = "0.1"

Trait only, no JSON:

plaza_wire = { version = "0.1", default-features = false }

Why this is its own crate

Both ends of a connection have to agree, but only one of them should pay for a server runtime. Everything here is pure serde with no async, so a browser client, a wasm build, or a native client can depend on this crate alone and never pull in tokio, actix, or a channel library.

Server code does not need to name it: plaza_session re-exports the codec, and plaza core re-exports the payloads under game_common::reconciliation::op_payloads, so existing paths keep working.

Payloads

The [payloads] module holds the netcode vocabulary both ends exchange, generic over your state, input, and id types, and carrying no math dependency:

  • SequencedClientInput (client to server): a numbered input, so the server can tell the client which it has applied (reconciliation).
  • AuthoritativeStateUpdate (server to client): the client's own authoritative state plus the last input seq applied.
  • RemoteEntitySnapshot (server to client): another entity's state, for interpolation and extrapolation. You name its position/rotation types.
  • TimestampedClientAction (client to server): a time-stamped action, so the server can rewind to when the client acted (lag compensation).

The client half (plaza_client_utils) and the server half (plaza_server_utils, plaza core) share this one definition.

Measuring your own round trip

A latency probe is a frame kind rather than an op, because answering one is something a session can finish by itself: echo a value, stamp the reply with a clock. Send a Kind::Ping and the other end's session answers with a Kind::Pong, with no application code on either side of the exchange.

frame::begin(frame::Kind::Ping, &mut buf);
codec.encode_into(&frame::Ping { origin: my_clock_now }, &mut buf)?;
pub struct Ping { pub origin: u64 }
pub struct Pong { pub origin: u64, pub responder: Option<u64> }

Two fields, two contracts. origin is opaque to the responder: it comes back exactly as it went out, and nothing but the sender ever interprets it. That is what a round trip is measured from, and it works whatever you stamped: milliseconds, nanoseconds, a frame counter.

responder is the other end's clock, read as the reply was built, and it is the field that is easy to leave out. Echoing the origin alone gives a round trip, which measures the distance to the responder without ever locating it. A client that renders on the responder's timeline needs its clock too, which is what ClockSyncEstimator::observe_exchange fits an offset from. It is Option because a responder with no clock installed has to be distinguishable from one whose clock reads zero.

The unit is out of band and plaza has no opinion about it. Nothing here converts, defaults, or names a unit; the values are passed and echoed as given. Which clock responder reads is yours to choose and both ends have to mean the same one. A simulation clock is usually right, because it is the timeline the client is drawing on; wall time is right only if that is also what stamps your snapshots.

To answer a probe by hand, in a client with its own read loop, frame::answer_ping builds the reply:

if let Some(reply) = frame::answer_ping(&codec, body, my_clock_now) {
  socket.send(&reply);
}

Usage

JsonCodec is the default: readable from a browser console or websocat, which makes it the right choice while you are still debugging a protocol.

use plaza_wire::{JsonCodec, WireCodec};

let codec = JsonCodec;
let bytes = codec.encode(&my_op)?;
let decoded: MyOp = codec.decode(&bytes)?;

Swap in your own format for production without touching transport code. Implementations must be stateless and cheap to clone: on the server, one codec lives inside a session and is shared across every connection it holds.

MessagePack itself needs no hand-writing: the msgpack feature ships MsgPackCodec (compact, structs as arrays) and MsgPackNamedCodec (structs as maps, for a peer that decodes by name). The implementation below is kept as the shortest illustration of the trait.

use plaza_wire::WireCodec;

#[derive(Clone, Copy)]
struct MsgPackCodec;

impl WireCodec for MsgPackCodec {
  fn name(&self) -> &'static str {
    "msgpack"
  }

  fn encode<T: serde::Serialize>(&self, value: &T) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
    rmp_serde::to_vec(value).map_err(Into::into)
  }

  fn decode<T: serde::de::DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, Box<dyn std::error::Error + Send + Sync>> {
    rmp_serde::from_slice(bytes).map_err(Into::into)
  }
}

Pass it where a transport takes a codec, for example TcpPlazaSession::bind_with_codec or ActixWsPlazaSession::with_codec.

A protocol version nobody has to maintain

A wire format only agrees if both ends were built from the same definition of it, and the ends are separate builds. A browser client especially: it is a build product and does not rebuild when the server does, so a page from before a wire change is the normal state of affairs. Without a version the failure is silent in the worst way, because the page loads, the game appears to run, and only the messages whose shape changed are rejected, which reads as a netcode bug and is a deployment one.

plaza_wire::build derives the version by resolving your wire from its roots. Tag each op enum with a doc line, and everything else is discovered:

[build-dependencies]
plaza_wire = { version = "0.1", default-features = false, features = ["build"] }
/// plaza-wire: root
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum TableOp { ... }

// build.rs
fn main() {
  plaza_wire::build::Wire::detect()
    .dart("../../flutter/my_client/lib/wire_protocol.dart")   // only if you have a Dart client
    .emit();
}

// src/types.rs
pub const PROTOCOL: u32 = WIRE_PROTOCOL;
include!(concat!(env!("OUT_DIR"), "/wire_protocol.rs"));

The resolver parses src/, starts from the tagged roots, and walks field types transitively, generic arguments included, so the version hashes exactly the types on the wire: an off-wire neighbour sharing a file moves nothing, and a payload two files away counts. Plaza's own vocabulary (the notice payloads, the netcode payloads, Agent) is covered by a constant baked into this crate, so it is never yours to list. A referenced type the resolver cannot place fails the build naming the reference; a serde type unreachable from every root gets a warning naming it and both tags (plaza-wire: root to include it, plaza-wire: off-wire to silence it), because a forgotten tag is the one miss no resolver can catch. Wire::ops(&["TableOp"]) names roots explicitly for a crate that would rather not tag, .also_scan(dir) covers a workspace keeping wire types in a sibling crate, and .leaf("Name") acknowledges a type whose shape is pinned elsewhere. Plaza types that live in core rather than here (Vec2, the collaborative payloads) are included on demand: .vocab(plaza_wire::build::vocab::MATH) covers them from vendored copies embedded in this crate, and referencing one without its bundle tells you that exact line. The older file-list emit(&[paths]) remains underneath; note the two derive different numbers for the same wire (per-definition against per-file hashing), so switching bumps your version once.

A client then announces PROTOCOL on connect and a server speaking a different one can reply "reload" rather than flooding its log with per-message decode warnings. A version that has to be bumped by hand is skipped precisely during the change that needed it, which is why this is derived rather than declared. It errs toward asking for a reload that was not strictly needed: the cost is a page load, and the opposite mistake is a silent half-working session. It cannot rescue a client older than the handshake itself, which is the bootstrapping floor every protocol version has.

How the version reaches each client family differs by what serves it, and each channel is the right one for its medium, not a legacy of the others:

Client Channel Client work
Browser page Host stamps window.PLAZA_PROTOCOL into the HTML at serve time none
Dart / Flutter app .dart(path) writes a committed const int wireProtocol the app imports; .dart_types(path) generates its wire types too, making compact MessagePack safe to speak one import, one constructor argument
Native Rust client shares the server's crate and its PROTOCOL const none

The Dart file is committed because a Dart build cannot run a cargo build script; the server's build keeps it current, and assert_dart_protocol(path, PROTOCOL) is a one-line test that fails CI when a wire change was committed without a build. Either way a stale client also self-announces at runtime through the Hello handshake, so the test moves discovery earlier rather than being the only net.

The other half of that failure is caching, and it lives in plaza_session::host::Host: a browser serving the page from cache cannot quote a new version however well you derived it.

Features

Feature Default Effect
json yes Provides JsonCodec and pulls in serde_json. Disable to take the trait alone.
build no The build-script half above, including the Wire resolver (pulls syn, build-time only). Belongs in [build-dependencies], not [dependencies].