WebSocket Protocol
This page is the raw transport reference. Most apps should use MatchSession instead of writing framing code.
URL
ws://127.0.0.1:4000/ws/match_123?seat_token=seat_token_123The SDK derives this from the HTTP baseUrl automatically when a match session connects — http becomes ws, https becomes wss. Replace match_123 and seat_token_123 with the access values returned by matches.create() or matches.join().
Outbound messages
Every client frame is a JSON envelope tagged with type. Payload shape depends on the tag. The eleven tags below are the entire client vocabulary; the client-message schema accepts nothing else.
Ready
{ "type": "ready" }Sync request
{ "type": "syncRequest" }Asks the server to resend the recovery pair — a full Snapshot followed by a History snapshot.
Ping
{ "type": "ping", "payload": { "nonce": "host-latency-1" } }payload is required; the nonce inside it is not. A ping without a nonce is "payload": {} or "payload": { "nonce": null } — dropping payload itself fails validation. The answering Pong echoes whatever was sent.
Undo
{ "type": "undo" }Rolls back the latest accepted player action — not necessarily the sender's own, which is part of why approval exists. It applies straight away unless the rollback is sensitive, which means the checkpoints it would consume either revealed hidden information — a card draw, a reveal, a deck or hand discard, a taken mulligan, or a resulting choice that is about to expose hidden cards — or contain an action by a seat other than the requester. Those are the two reasons the server reports, revealedHiddenInfo and otherPlayerActed.
A sensitive undo does not apply on its own. The server records it on GameState.pendingUndo, emits an undoRequested game event, and waits for the other human seats. Only human seats are ever asked, so a two-player match against AI has no approvers and always takes the immediate path — where one human undo additionally consumes the AI responses back to the requester's own action, pauses the AI, and resumes it after the next successful human mutation.
Undo response
{ "type": "undoResponse", "payload": { "approve": true } }Answers the request currently in GameState.pendingUndo — what matchSession.respondUndo() writes. Only the seats in pendingUndo.pendingSeats may answer: every joined, non-eliminated human seat other than the requester. Membership is re-checked against live state when the response arrives, so a seat eliminated since the request cannot cast the deciding vote; anyone else is rejected with undo_response_not_allowed, and answering when nothing is pending gives no_pending_undo_request.
There are three ways the request ends:
- The last outstanding approval arrives. The undo applies exactly as the immediate path would have. Should the match have moved on anyway, the server cancels with
undoCancelledand reasoninvalidatedinstead of rewinding the wrong state. - Any approver declines. The request is cancelled (
undoDeclined) and the requester may not ask again until that turn ends; a secondundobefore then fails withundo_declined_this_turn. - The requester answers their own request. It is withdrawn regardless of
approve(undoCancelled, reasonrequester), and no decline lock is recorded.
A pending request also dies on its own: any newer mutation that commits first — a command, a ready, a format event — supersedes it, and the server discards it with undoCancelled and reason superseded. pendingUndo is public state that every viewer including spectators sees; only the prompt to answer it is per seat.
Set AI paused
{ "type": "setAiPaused", "payload": { "paused": true } }Command
Scalar payload:
{ "type": "command", "payload": "pass" }Tagged payload:
{
"type": "command",
"payload": {
"chooseOpeningResources": {
"cardInstanceIds": [1000004, 1000011]
}
}
}Here 1000004 and 1000011 are cardInstanceId values read from that seat's private hand in the snapshot — match-local ids for two physical copies, not my-swu.com card ids. Physical copies are numbered from 1000000 upward so that a deck holding duplicates still addresses one exact card. See Card IDs.
The tagged payload is the GameCommand union, and its play and ability variants carry selection fields that are required on every send even when nothing is being chosen or paid: modeIndices on playCard, useLeaderAction, useActionAbility, resolveAbility, and chooseAbilityModes, and creditTokensToSpend on the same commands except resolveAbility, which accepts its absence as 0 for replay compatibility. See Credit payments.
Format event
{
"type": "formatEvent",
"payload": { "type": "readyNextGame", "ready": true }
}The out-of-game flow, applied to the sending seat: sideboarding, best-of-series readiness, Trilogy bans and deck selection, Draft picks, and the post-game rematch and series-extension offers. The payload is a FormatEventCommand, itself tagged with its own type — sideboard, readyNextGame, banTrilogyDeck, selectTrilogyDeck, draftPick, extendSeries, respondSeriesExtension, requestRematch, or respondRematch. ready above is optional and defaults to true, so older clients that omit it keep working. The same commands are reachable over HTTP through matches.applyFormatEvent(); see Out-of-game commands.
{
"type": "formatEvent",
"payload": { "type": "banTrilogyDeck", "deckIndex": 1 }
}deckIndex is zero-based and always reads from the sending seat's point of view: on banTrilogyDeck it indexes the opponent's three registered decks, on selectTrilogyDeck the sender's own. See Trilogy for the ban and pick flow itself.
AI format event
{
"type": "aiFormatEvent",
"payload": { "type": "banTrilogyDeck", "deckIndex": 1 }
}The same command, applied to the attached AI seat rather than to the sender's. It exists for trilogyFormatMode: 'manual', the mode in which the AI opponent has no ban/pick policy of its own and the human opponent makes those two choices on its behalf — see Trilogy format mode.
The server validates the sender before applying and rejects the frame with invalid_command unless all of the following hold: the match is two-player Trilogy, seat two has an attached AI opponent, that opponent was configured trilogyFormatMode: 'manual', the sender is not the AI seat, and the payload is banTrilogyDeck or selectTrilogyDeck. No other format command is accepted on this tag, and unlike formatEvent there is no HTTP equivalent — this frame is socket-only.
Because the command runs as the AI seat, its deckIndex is read from that seat's point of view too. The banTrilogyDeck above therefore bans one of the sender's own decks, which is the choice the AI would otherwise have made. The resulting rollback point belongs to the human who sent it, so a later undo takes it back, and the frame counts as a human mutation for resuming an undo-created AI pause.
Chat
{ "type": "chat", "payload": { "message": "good luck!" } }One table message from the sending seat — what matchSession.sendChat() writes. There is no inbound chat frame: an accepted message comes back as a History entry whose kind is chat, so it replays after a reconnect like every other feed entry. The server owns the display name and avatar on that entry; the frame carries only message.
The body is trimmed and truncated to 500 characters, and an empty or whitespace-only body is rejected with invalid_selection. Spectator connections cannot send it at all (read_only_spectator).
Free text additionally requires every joined human seat to be authenticated — an anonymous seat has no account behind a moderation report, so the server answers invalid_selection while one is at the table. Canned quick phrases stay available there, because a fixed server-owned set carries nothing to moderate. A quick phrase travels as its token in the same message field, so both players read it in their own language:
{ "type": "chat", "payload": { "message": "!qp:goodGame" } }The ids behind !qp: are greetings, goodLuck, wellPlayed, thinking, oops, and goodGame. An unrecognised id is not a phrase — it stays the free text it is and faces the anonymity gate like any other body.
Chat is rate limited to 6 messages per 10 seconds per seat, and unlike a dropped Interaction cue an over-budget message does answer with an error frame carrying rate_limited.
Interaction cue
{
"type": "interactionCue",
"payload": {
"kind": "handCard",
"index": 3,
"state": "drag",
"dragOffset": { "x": -0.12, "towardTable": 0.34 }
}
}A presentation-only signal for this seat's live hover or drag — what matchSession.sendInteractionCue() writes. It changes no game state and is never recorded in history.
Note the payload has no seat: the server stamps the authoring seat itself, so a client cannot forge a cue for someone else, then rebroadcasts the completed cue to everyone on the match as an Interaction cue broadcast, which is where the field meanings and the silent-drop rules are described.
Inbound messages
Welcome
{
"type": "welcome",
"payload": {
"matchId": "match_123",
"role": "player",
"seat": "one",
"aiStatus": { "seats": [] }
}
}role is player or spectator, and seat is present for players only. aiStatus repeats the payload of the aiStatus frame so a client knows the AI state without waiting for one.
Snapshot
{
"type": "snapshot",
"payload": {
"status": "inProgress",
"prompt": { "chooseInitiative": { "seat": "one" } }
}
}Full seat-projected state, and the only state frame that carries no revision. The server sends it at recovery boundaries only — the opening handshake, an explicit syncRequest, and a socket that fell behind its broadcast receiver — and always follows it immediately with historySnapshot.
Snapshot update
{
"type": "snapshotUpdate",
"payload": {
"revision": 42,
"snapshot": {
"status": "inProgress",
"prompt": { "chooseInitiative": { "seat": "one" } }
}
}
}This is the incremental path: between recovery boundaries, new authoritative state reaches the socket here and nowhere else. revision is the monotonic number assigned when that match transition committed, and snapshot is the same seat-projected GameState a full snapshot carries, computed for exactly that revision.
Apply it as the new authoritative state, and ignore any frame whose revision is not greater than the newest revision already applied. Delivery around a recovery boundary can hand you an older revision after a newer one.
Which level of the API has to do that depends on how you connect. MatchConnection already implements it — it gates on the revision, replaces its stored snapshot, and re-emits the result as an ordinary snapshot event, so MatchSession and its snapshot listeners never deal with snapshotUpdate directly (it still reaches the raw message event). A consumer that writes its own framing must implement the same rule; one that simply ignores snapshotUpdate keeps rendering the handshake snapshot forever and desyncs without any error frame.
History snapshot
{
"type": "historySnapshot",
"payload": {
"snapshotRevision": 42,
"entries": [
{
"id": "0d3d6f7a-9e93-4a6e-bd0b-1c2a5f0f1b44",
"seq": 12,
"createdAtMs": 1750000000000,
"kind": "playerPresence",
"payload": { "seat": "two", "status": "reconnected" }
}
]
}
}The replayable match feed — chat, presence transitions, and recorded game events — capped at the newest 200 entries. It is sent only as the second half of a recovery pair, immediately after the full snapshot.
snapshotRevision is the revision of that paired snapshot, and it exists as a fence rather than as a label. Recovery re-sends state with no revision on it, so a snapshotUpdate published before the recovery point can still be in flight and would otherwise overwrite freshly recovered state with older state. Raise the revision floor to max(current, snapshotRevision) when this frame arrives and every such straggler fails the "must be greater" test above. That is also why the two frames must be read as a pair: taking the snapshot and skipping the historySnapshot leaves the fence down.
History entry
{
"type": "historyEntry",
"payload": {
"id": "3f6b4c11-0a7d-4a4b-8f2e-7c9d1b6a8e05",
"seq": 13,
"createdAtMs": 1750000000001,
"historyGroupId": "b1c2d3e4-5f60-4718-9a2b-3c4d5e6f7a8b",
"kind": "gameEvent",
"payload": { "playerReady": { "seat": "one" } }
}
}One entry appended to that feed, in the same shape the historySnapshot entries use: id, seq, and createdAtMs alongside the kind tag and its payload. kind is chat, gameEvent, or playerPresence.
Order by seq and de-duplicate on id — a recovery historySnapshot re-delivers entries this socket already applied. historyGroupId is optional and ties together the several gameEvent entries produced by one player action, so a feed can render them as a single item; chat and presence entries never carry one.
Event
{
"type": "event",
"payload": { "playerReady": { "seat": "one" } }
}One committed gameplay transition reaches the socket as its event frames, then the matching historyEntry frames, then the snapshotUpdate for that revision. Use event for animations and timelines and the snapshot for state; the history entry is the copy that survives a reconnect.
Interaction cue broadcast
{
"type": "interactionCue",
"payload": {
"seat": "two",
"kind": "handCard",
"index": 3,
"state": "drag",
"dragOffset": { "x": -0.12, "towardTable": 0.34 }
}
}One seat's Interaction cue after the server stamped seat on it, rebroadcast to everyone on the match — spectators and the original sender included.
kind is handCard, index is a position in that seat's hand, and state is hover, drag, or clear. Cues are deliberately best effort: the server drops one silently when index is past the end of that seat's current hand — the card was played while the cue was in flight — and when the sender exceeds its budget of 300 cues per 10 seconds. Neither drop produces an error frame, because a rate-limit toast during ordinary pointer movement would be worse than a missing hover.
dragOffset is optional and the SDK does not interpret it. The first-party client sends viewport-normalized fractions, x for horizontal travel and a positive towardTable for movement away from that player's hand toward the table, which leaves each viewer to resolve what direction that is on its own screen.
AI status
{
"type": "aiStatus",
"payload": {
"seats": [
{ "seat": "two", "difficulty": "medium", "paused": true, "trilogyFormatMode": "auto" }
]
}
}One entry per AI-controlled seat; an empty seats list means the match has no AI controller. The payload rejects unknown fields, so the retired single-AI attached/paused/seat form is no longer accepted.
Error
{
"type": "error",
"payload": {
"code": "invalid_command",
"message": "command is not valid in the current prompt"
}
}The payload also carries an optional retryAtMs (epoch milliseconds when a rate-limited request may be retried), but only because the HTTP API shares this shape. Match-socket errors never set it, rate_limited included.
Pong
{
"type": "pong",
"payload": { "nonce": "host-latency-1" }
}The nonce echoes the matching ping nonce (or null if none was sent). Use it to measure round-trip latency.
Validation
The SDK checks inbound envelopes against the server-message schema and outbound envelopes against the client-message schema only when the client was created with a validator. Frame validation is off by default; see Runtime validation for the trade-off and for validate: createSchemaValidator().
Two things hold either way: a frame that is not valid JSON still emits sdk_invalid_server_message, and a message type this SDK build does not know cannot be mis-dispatched, because the routers have no case for it. With validation off it does reach the raw message event instead of being dropped during decode.
Clients that implement their own transport can validate with the JSON schemas shipped in @my-swu/simulator-client/schema/.
