Three sockets, and every frame on them is a contract. @pithy-sh/multiplayer serves a session socket for live play, @pithy-sh/matchmaking serves a presence socket that tells a player a match was found, and core serves a presence room socket, for any capability that declares room kinds, that says who else has a record open. A client on any of them may be an app store build that lags its Worker by months, so a frame changes by one rule. This page states it. Every other page links here.
The sockets
| Socket | Path | Subprotocol | The Worker sends | The client sends |
|---|---|---|---|---|
| Session | GET /multiplayer/sessions/:id/socket | pithy.multiplayer.v1 | state, error | action, state |
| Presence | GET /matchmaking/presence | pithy.presence.v1 | init, match_found, invite, friend_request, invite_canceled, queue_dropped, error, and pong as plain text | ping as plain text |
| Presence room | GET {basePath}/presence/rooms/:kind/:recordId | pithy.presence.v1 | init, joined, left, activity, error, and pong as plain text | set_activity, and ping as plain text |
Each path sits under its capability’s basePath, so a project that moves the routes moves the socket with them. The session and matchmaking upgrades take a bearer token or a session cookie, and only a member of the session may open its socket.
The presence room socket takes a ticket instead, because a browser’s WebSocket sets no headers. You mint one at POST {basePath}/presence/tickets for a signed-in user, or at POST /control-plane/presence/tickets for a management client, and offer it as pithy.ticket.<ticket> in Sec-WebSocket-Protocol beside pithy.presence.v1. A ticket is single use, lasts 60 seconds, and names one room and one participant. A capability that declares room kinds serves this socket under its own name, supportPresenceRooms for support, so one Worker can serve several. Presence rooms has the rest.
Frames
Every frame is JSON text, discriminated on type. Read the envelope, switch on type, then read the body as the type it names.
Every frame is a Zod schema, and the Worker parses every frame it receives with it. It is the schema the contract publishes, never a copy, so the document and the socket cannot disagree about what is accepted. The session socket’s live in @pithy-sh/multiplayer/src/session/frames, the presence socket’s in @pithy-sh/matchmaking/src/presence/frames, the presence room’s in @pithy-sh/core/src/presence/frames. All three modules are pure, so a TypeScript client can import them.
An error frame carries the same public error body an HTTP error does, and the socket stays open after it. Show it and keep the socket.
ping and pong are the one exception to JSON. The presence keepalive, and the presence room’s, is plain text each way.
Versions
The version is negotiated at the upgrade. The client offers the subprotocol it speaks in Sec-WebSocket-Protocol, and the Worker answers with the one it chose.
The Worker serves two: the current version and the previous one. It picks the current one when the client offers it, and the previous one otherwise. A client that offers only versions the Worker does not serve is refused with a validation/invalid_input 400 before the upgrade, naming the versions it does serve. A socket that opened anyway would speak frames the client cannot read.
The first version’s previous one is no version at all, on a socket that existed before versions did. A client that offers no subprotocol is every client written before versions existed, and it gets exactly what it always got:
- On the session socket, the state arrives as the bare session view with no
type, which the contract callssnapshot. A frame naming an unknowntype, or none, is ignored. A frame that is not JSON is still answered with anerrorframe. - On the presence socket, a frame it cannot accept is ignored, because that client was never promised an
errorframe.
On pithy.multiplayer.v1 and pithy.presence.v1, the state arrives as a state frame, and any frame the socket cannot accept is answered with a validation/invalid_input error frame. A new client offers the current version.
The presence room has no unversioned version, so it serves one version, not two. It was versioned from its first release, and its ticket can only arrive in the subprotocol offer, so a client that offers none has no ticket and is refused. Its previous version is none until its first breaking change. Then pithy.presence.v1 becomes the previous one, by the rule below.
How a frame changes
- A field added to a frame is optional. A client built before the field existed never sees it, and a client built after it must read its absence.
- A client ignores a
typeit does not know, and a key it does not know. That is what lets the Worker add a frame or a field without a new version. - A change no client could ignore is a new subprotocol: a field removed, retyped or made required, or a frame whose meaning moved. The new version becomes the current one, the old current one becomes the previous one, and a client released before the change keeps playing.
This is the socket’s form of the rule a response schema follows, for the same reason: both are read across a version boundary.
A game’s own shapes
A game model may declare an action schema and a view schema, and the three example games declare both. Writing your own game model shows where they go.
actionis one player’s move: the body ofPOST /multiplayer/sessions/:id/action, and thepayloadof an{ "type": "action", "payload": … }frame. When it is declared, every action is checked against it before the model’sapplyruns, and a malformed one is refused withvalidation/invalid_input.viewis what the model’sredactreturns: one player’s sight of the game, in a session view’sstate. It is never parsed on the way out.redactis the authority on what a player may see, and the schema only tells a client how to read it.
Both are optional, so a model written before them still loads, and a client reads that game’s payload and state as unknown.
The document
pithy contract writes a Worker’s asyncapi.json beside its openapi.json when the Worker serves a socket. It is AsyncAPI 3.0: each socket’s path, how its upgrade is verified, the subprotocols it serves, and a schema for every frame each side sends, with every field described. Each configured game’s schemas are in it as <gameKey>.action and <gameKey>.view, so a client in any engine types the part that is the game.
OpenAPI stops at the upgrade. A client generates its HTTP calls from openapi.json and writes its socket layer against asyncapi.json. Unity walks through both.
The kit publishes the same document for its own sockets, every capability composed: docs/asyncapi.generated.json in the repository.