Skip to content

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 a wasm worker.
  • 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.

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:

kindDirectionPayload
stateengine host → remote client{ "kind": "state", "forPlayer": "player-N"?, "state": <StateUpdate> }
displayengine host → all{ "kind": "display", "event": <DisplayEvent> }
promptengine host → remote client{ "kind": "prompt", "forPlayer": "player-N", "prompt": <AgentPrompt> }
errorengine host → remote client{ "kind": "error", "forPlayer": "player-N", "error": <ProtocolError> }
responseremote client → engine host{ "kind": "response", "fromPlayer": "player-N", "promptId": <n>, "action": <PromptOutput> }
directiveremote client → engine host{ "kind": "directive", "fromPlayer": "player-N", "directive": <DirectiveInput> }
logengine host → all{ "kind": "log", "fromPlayer": "player-N", "entry": <GameLogEntry> }
snapshotengine host → joining observer{ "kind": "snapshot", "fromPlayer": "player-N", "entry": <GameSnapshot> }
fatalengine host → all{ "kind": "fatal", "message": <string> } — the engine died; the session is over
roomRelayany → anyroom-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.

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.