Transport
The protocol is transport-agnostic — it defines the JSON messages, not how the bytes move. A conforming pair can exchange them over any of:
- A platform-native invocation channel (e.g. Tauri
invoke()with matching#[tauri::command]handlers). The reference desktop client uses this for solo play. - A WebSocket connection (
ws:///wss://), one JSON document per text frame. Used for the web client and multiplayer relay. - Web Worker
postMessage, used by the reference web client when the engine runs in awasmworker. - An in-process channel (e.g. Rust
mpsc) carrying serialized JSON. Used by the test harness and embedded engine deployments.
A single session MUST use one transport for its whole duration; cross-transport sessions are out of scope.
Multiplayer relay
Section titled “Multiplayer relay”When more than one client takes part in a session, a relay server sits between
the engine host and the remote clients (the reference relay is
manabrew-rs/crates/manabrew-server). The game messages are carried as an
opaque state value inside the relay’s own envelope, discriminated by a kind
field:
kind | Direction | Payload |
|---|---|---|
state | engine host → remote client | { "kind": "state", "forPlayer": "player-N"?, "state": <StateUpdate> } |
display | engine host → all | { "kind": "display", "event": <DisplayEvent> } |
prompt | engine host → remote client | { "kind": "prompt", "forPlayer": "player-N", "prompt": <AgentPrompt> } |
error | engine host → remote client | { "kind": "error", "forPlayer": "player-N", "error": <ProtocolError> } |
response | remote client → engine host | { "kind": "response", "fromPlayer": "player-N", "promptId": <n>, "action": <PromptOutput> } |
directive | remote client → engine host | { "kind": "directive", "fromPlayer": "player-N", "directive": <DirectiveInput> } |
log | engine host → all | { "kind": "log", "fromPlayer": "player-N", "entry": <GameLogEntry> } |
snapshot | engine host → joining observer | { "kind": "snapshot", "fromPlayer": "player-N", "entry": <GameSnapshot> } |
fatal | engine host → all | { "kind": "fatal", "message": <string> } — the engine died; the session is over |
roomRelay | any → any | room-control messages (e.g. bot lifecycle) — implementation defined |
prompt and error envelopes are private. Engine hosts send them with the
relay’s BroadcastState.target_player set to the deciding player’s username;
forPlayer identifies the engine seat for dispatch and replay, but is not a
transport privacy boundary.
Audience-aware state
Section titled “Audience-aware state”An engine that computes per-recipient views (hidden hands, face-down cards)
addresses each state envelope with forPlayer; a state without forPlayer
is a public view (what a spectator may see). A client MUST apply a state
addressed to its own seat, MUST ignore states addressed to other seats, and —
once it has received any state addressed to it — MUST ignore public views for
the rest of the game. Prompts and errors are likewise acted on only when
forPlayer matches the client’s seat.
The reference relay can deliver an envelope to a single room member instead of
broadcasting, and caches the last state per seat so a reconnecting client
resyncs into its own view. Relays never read inside state.
The lobby/room-control layer that wraps this (authentication, room creation,
ready state, …) is specific to the reference relay and beyond the scope of the
wire protocol itself. Implementations MAY define additional kind values;
consumers MUST ignore unknown kind values rather than treating them as errors.