plaza_wire 0.6.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.

What lives here

Agent, AgentId, Kind and the framing helpers, the WireCodec trait, and the netcode payloads. 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.

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).
  • Ping / Pong (either direction): a latency probe echoed back unchanged, so the sender measures its round trip.

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

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.

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 hashing the sources that define your messages, from a build.rs:

[build-dependencies]
plaza_wire = { version = "0.1", default-features = false, features = ["build"] }
// build.rs
fn main() {
  plaza_wire::build::emit(&["src/sim/protocol.rs", "src/sim/types.rs"]);
}

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

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, since it changes when those files change at all, comments included: 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.

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. Belongs in [build-dependencies], not [dependencies].