Skip to main content

GraphQL UDP Proxy API -- Client SDK Guide

Choosing an integration path

This is one of two client integration paths. Use this proxy only if your client cannot open raw UDP sockets (browsers, generic JS/TS apps). UDP-capable clients (Unreal Engine, native desktop/mobile games) should authenticate on the Management API, mint an app-scoped token (mintAppToken), call serverWithLeastClients on the Game API, then speak raw UDP to Buddy. See the Replication API for the native UDP path.

Both paths share the same Management API identity and the same app-scoped token minted from it (see Portals & app-scoped tokens); only the transport differs (GraphQL mutations through this proxy vs. raw UDP to a Buddy server).

The Game API acts as a UDP proxy between GraphQL clients and Buddy. Clients on this path interact entirely through GraphQL (HTTP mutations + WebSocket subscriptions); the API handles UDP connectivity, session registration with Buddy, HMAC authentication, and binary serialization on the client's behalf.

Authentication​

Every Game API request uses an app-scoped token — a token confined to the one app you are playing. The identity session token that login / register return is a management-plane credential and is rejected on the Game API and realtime surface. Mint an app token from the session token (first-party or non-browser code), or obtain one through hosted sign-in (a browser game on its own domain: client.portal.signIn → Studio → client.portal.handleSignInCallback; direct login / register are refused from a non-first-party browser origin since ck-api v1.88.0 -- see Sign in). Then pass it as Authorization: Bearer <token> on both HTTP requests and WebSocket connections to the Game API:

# On the Management API, with the identity session token as the Bearer:
mutation Enter($appId: BigInt!) {
mintAppToken(input: { appId: $appId }) {
token # 64-char app-scoped token -- use this for all Game API requests
gameTokenId # numeric ID (informational)
appId expiresAt gameApiUrl gameApiWsUrl discoveryUrl
}
}

App tokens expire (default ~30 min) — call refreshAppToken (with the app token as the Bearer) before expiresAt. Browser games that run at a different origin from the page that holds the session use the OAuth2 Authorization-Code + PKCE flow (createPortalAuthorizationCode → exchangePortalCode) instead of a direct mint. CrowdyJS wraps both in client.portal. See Portals & app-scoped tokens and Authentication.

Connection lifecycle​

1. Subscribe to notifications (WebSocket)​

Open a WebSocket with the graphql-transport-ws subprotocol (the legacy subscriptions-transport-ws protocol, subprotocol graphql-ws, is not accepted: the handshake selects no subprotocol for it, so the client fails the connection, and a socket that opens without graphql-transport-ws is closed with code 4406) and send connection_init with your app-scoped token and the appId you are playing. The realtime session is app-scoped: the Game API only delivers that app's spatial notifications, rejects a subscription that arrives without an appId, and rejects an identity session token or a token scoped to a different app (see Realtime connection events).

{ "type": "connection_init", "payload": { "Authorization": "Bearer <token>", "appId": "1" } }

After connection_ack, subscribe:

subscription {
udpNotifications {
__typename
... on ActorUpdateNotification { appId chunkX chunkY chunkZ distance decayRate uuid state sequenceNumber epochMillis }
... on VoxelUpdateNotification { appId chunkX chunkY chunkZ distance decayRate uuid voxelX voxelY voxelZ voxelType voxelState sequenceNumber epochMillis }
... on GenericErrorResponse { sequenceNumber errorCode }
... on ClientAudioNotification { appId chunkX chunkY chunkZ distance decayRate uuid audioData sequenceNumber epochMillis }
... on ClientVideoNotification { appId chunkX chunkY chunkZ distance decayRate uuid videoData sequenceNumber epochMillis }
... on ActorLeftNotification { appId chunkX chunkY chunkZ distance uuid leftReason sequenceNumber epochMillis }
... on ClientTextNotification { appId chunkX chunkY chunkZ distance decayRate uuid text sequenceNumber epochMillis }
... on ClientEventNotification { appId chunkX chunkY chunkZ distance decayRate uuid eventType state sequenceNumber epochMillis }
... on ServerEventNotification { appId chunkX chunkY chunkZ distance decayRate uuid eventType state sequenceNumber epochMillis }
... on SingleActorMessageNotification { appId chunkX chunkY chunkZ uuid payload sequenceNumber epochMillis }
... on ChannelMessageNotification { channelId uuid payload sequenceNumber epochMillis }
... on ChannelAudioNotification { channelId uuid audioData sequenceNumber epochMillis }
... on RealtimeConnectionEvent { status code message retryable }
}
}

All spatial types include the full header fields (appId, chunkX/Y/Z, distance, decayRate, uuid), plus sequenceNumber and epochMillis (server-generated UTC timestamp in milliseconds since epoch). Only GenericErrorResponse has a minimal 3-field format (no spatial header).

ActorUpdateResponse / VoxelUpdateResponse are legacy — never emitted. The game server retired their dedicated response opcodes: an applied update arrives as your own *Notification self-echo (the sender is included in the chunk fan-out), and failures arrive as GenericErrorResponse (correlated by sequenceNumber). The two types remain in the union for backward compatibility only — do not select them in new code; they will be removed in a future major version.

ChannelMessageNotification and ChannelAudioNotification are delivered on this same subscription but are not spatial (they have no chunk header) — see Channels.

Subscribing automatically opens a UDP proxy session to the game server if one does not already exist. The server picks the game server with the fewest clients.

Realtime connection events​

RealtimeConnectionEvent reports session-level problems (it carries no spatial header). Because the session is app-scoped, the most common one is a missing appId in connection_init:

coderetryableMeaning
APP_ID_REQUIREDfalseNo appId was sent in connection_init. App-agnostic subscriptions are not permitted — reconnect with the appId you are playing.
APP_TOKEN_REQUIREDfalseThe connection used an identity session token (or a non-app token). Mint an app-scoped token (mintAppToken / portal) and reconnect.
APP_SCOPE_MISMATCHfalseThe token is app-scoped but to a different app than the appId you subscribed with. Use this app's token.
AUTH_REQUIREDfalseMissing or invalid token on the connection.
TOKEN_EXPIREDfalseThe app-scoped token passed its expiresAt. refreshAppToken (or re-portal) and reconnect.
UDP_PROXY_CONNECTION_FAILEDtrueThe proxy could not open a UDP session (for example the app is runtime-denied). Safe to retry.

2. Register your actor in a chunk​

Before another client can receive your updates, you must send at least one actor update so the game server knows your chunk position. This is the "registration" step:

mutation {
sendActorUpdate(input: {
appId: 0
chunk: { x: 0, y: 0, z: 0 }
distance: 8
uuid: "<your-32-byte-uuid>"
state: "" # empty state for registration
sequenceNumber: 1
})
}

Every client in the same chunk must do this. After registration, the game server fans out subsequent updates to all other registered clients in range.

3. Send actor updates​

mutation {
sendActorUpdate(input: {
appId: 0
chunk: { x: 0, y: 0, z: 0 }
distance: 8
decayRate: 0
uuid: "<your-32-byte-uuid>"
state: "<base64-encoded-binary-state>"
sequenceNumber: 2
})
}

The mutation returns true when the UDP packet is sent. Other clients in the same chunk will receive an ActorUpdateNotification on their udpNotifications subscription.

4. Receive notifications​

Notifications arrive as next messages on the WebSocket subscription:

{
"id": "s1",
"type": "next",
"payload": {
"data": {
"udpNotifications": {
"__typename": "ActorUpdateNotification",
"appId": "0",
"chunkX": "0",
"chunkY": "0",
"chunkZ": "0",
"distance": 8,
"decayRate": 0,
"uuid": "abc123...",
"state": "aGVsbG8=",
"sequenceNumber": 2,
"epochMillis": "1712345678000"
}
}
}
}

5. Disconnect​

mutation { disconnectUdpProxy }

Unsubscribing from udpNotifications stops delivery but does not release the UDP session. Call disconnectUdpProxy explicitly, or the server will release it after 30 seconds of inactivity.

Complete flow (two clients)​

Client A Web API Game Server
──────── ─────── ───────────
login + mintAppToken ──────────▶ app token A (this app)
subscribe udpNotifications ─────▶ (opens UDP proxy session, registers session)
◀── wait ~1.5s for session ready ──▶

sendActorUpdate (seq=1, empty) ──▶ UDP ACTOR_UPDATE_REQUEST ──▶ registers A in chunk

Client B Web API Game Server
──────── ─────── ───────────
login + mintAppToken ──────────▶ app token B (this app)
subscribe udpNotifications ─────▶ (opens UDP proxy session)
sendActorUpdate (seq=1, empty) ──▶ UDP ACTOR_UPDATE_REQUEST ──▶ registers B in chunk

sendActorUpdate (seq=2, state) ──▶ UDP ACTOR_UPDATE_REQUEST ──▶
◀── fan-out
◀── UDP ACTOR_UPDATE_NOTIFICATION
─── WebSocket next ──▶ Client A receives B's update

Important notes​

  • state must be base64-encoded binary, not JSON.

  • uuid must be exactly 32 bytes when UTF-8 encoded.

  • sequenceNumber is a uint8 (0-255) that wraps. Present on all spatial types. In responses it echoes the request's sequence for correlation; in notifications it is the original sender's sequence.

  • epochMillis is a server-generated UTC timestamp (milliseconds since epoch). Present on all spatial types (notifications and responses). Use it for ordering and synchronization.

  • distance (0-8) controls how many chunks away the update replicates. Use 8 for maximum range. The server clamps values to the 0-8 range using Chebyshev distance.

  • decayRate controls how message delivery drops off with distance:

    ValueNameBehavior
    0NoneAll clients within distance receive every message.
    1ExponentialEach distance ring receives half the messages of the previous ring.
    2Linear 50%Linear decay; the furthest ring receives 50% of messages.
    3Linear 25%Linear decay; the furthest ring receives 25% of messages.
    4Linear 10%Linear decay; the furthest ring receives 10% of messages.
    5Linear 5%Linear decay; the furthest ring receives 5% of messages.
  • appId is the app (world) identifier in the spatial wire header — an int64 at byte offset 1, distinct from chunk X/Y/Z coordinates.

  • The proxy auto-reconnects to the game server if no traffic is seen for 30s. The subscription stays open but notifications pause until reconnection.

  • Server load shedding is handled for you. If the assigned game server runs hot it can ask connected clients to move to a different server. The proxy intercepts that signal and transparently re-selects a server and re-authorizes your session — your WebSocket subscription stays open and there is nothing to handle in the browser. (Native UDP clients receive a COMMAND_RECONNECT and must move themselves — see the Replication API.)

  • Error responses (e.g., invalid token, unknown app) arrive as GenericErrorResponse on the subscription.

  • Send back the cks_ga cookie. Your proxy session lives on one API server behind the load balancer, so your subscription's WebSocket and your HTTP send* mutations must reach the same server, or the error responses and echoes for those sends never arrive on your subscription. The load balancer sets a cks_ga cookie on its first response (HttpOnly; Secure; SameSite=None) and routes every request that carries it to the same server: return it on every HTTP request and on the WebSocket upgrade. CrowdyJS and CrowdyPy do this for you, in the browser and in Node or Python. A browser sends it on requests made with credentials included, where it accepts the cookie. A client that sends no cookie is kept on one server by its IP address.

  • Move when your server drains. Before an API server is updated or retired, it sends a RealtimeConnectionEvent with status: 'draining' and code: 'SERVER_DRAINING' on your subscription and answers your requests with the cks_ga cookie expired (Max-Age=0). The server still works at that moment: drop the cookie, then reconnect your subscription and send your next request, so both land together on a server that is staying. A client that keeps its own cookie jar must honour the expiry rather than send the old value again. CrowdyJS, CrowdyCPP and CrowdyPy do all of this for you. Your first send after the move starts a new session on the new server, which holds it until the session is ready (replication server v0.42.0), and the SDKs retry the first awaited send of a session once against an older server, so nothing is lost across the move.

  • A paused app is refused, and says so. If an app's organization has no funds, reached a spend cap, or its subscription lapsed (see Shared environment & billing), the proxy refuses to open a session: HTTP mutations error with APP_PAUSED (extensions.reason names the cause) and the subscription delivers a RealtimeConnectionEvent with code: 'UDP_PROXY_CONNECTION_FAILED' (retryable: true). A session already open gets GenericErrorResponse with errorCode 33 (APP_PAUSED) in answer to a send. gameClientBootstrap still answers and reports runtimeGate, so check it at startup and tell the player the world is paused rather than showing an empty one.

Actor-to-actor messages​

sendSingleActorMessage delivers a message to exactly one other actor (by its UUID) instead of broadcasting to everyone nearby. The sender must know the destination actor's current chunk. Only the target receives it -- as a SingleActorMessageNotification on its own udpNotifications subscription -- and the sender gets no echo.

mutation {
sendSingleActorMessage(input: {
appId: 0
chunk: { x: 7, y: 1, z: 2 } # the TARGET actor's chunk
targetUuid: "<target-actor-32-byte-uuid>"
payload: "aGVsbG8=" # base64; put your own identity here if needed
sequenceNumber: 1
})
}

The payload carries only the target's UUID, not the sender's; if the recipient needs to know who sent it, include that in payload.

Webcam video​

sendVideoPacket is the video sibling of sendAudioPacket: it carries one fragment of an encoded webcam frame (native opcode 143) to every actor within distance, who receive it as ClientVideoNotification (opcode 144). It needs the use_video_chat permission on the sender's tier and on the grid under the target chunk; a refusal is UNAUTHORIZED. The key is opt-in — a new app's default tier does not carry it — because every receiver pays the egress for every copy (see the cost note below).

mutation {
sendVideoPacket(input: {
appId: 1
chunk: { x: 0, y: 0, z: 0 }
uuid: "<your-32-byte-actor-uuid>"
videoData: "AQAAAQAC…" # base64: 6-byte fragment header + a slice of the JPEG/WebP
distance: 1 # default 1 — keep it small
decayRate: 0
sequenceNumber: 7
})
}

A datagram is at most 1232 bytes, so a frame never fits in one call: the client cuts it into up to 16 fragments, each videoData being a 6-byte header (version 1, codec 0 = JPEG / 1 = WebP, big-endian frameId, fragIndex, fragCount) followed by that fragment's slice, and the receiver reassembles per (uuid, frameId), delivering each frame once and abandoning an incomplete one when a newer frameId arrives or after 500 ms. The header is a client-to-client convention the proxy never inspects; it is specified byte for byte in Replication API → Wire formats → Video payload. The official SDKs do all of this for you: CrowdyJS udp.sendVideoFrame(...) + VideoFrameAssembler, CrowdyCPP Connection::sendVideoFrame + crowdy::media::VideoFrameAssembler.

Recommended: distance 0–1, ≤ 10 fps, frames ≤ 8 KB (JPEG quality ≈ 0.5 at 128×96 is 2–5 KB). One sender at 10 fps × 4 KB is ~40 KB/s to each receiver and every copy is metered on the app's egress; ten cameras in one chunk is ~400 KB/s into every player standing there. Give players a toggle, and stop sending when nobody is in range.

Actor left​

ActorLeftNotification (native opcode 145) is server → client only: the server stopped considering an actor present — about five seconds after its last actor update (leftReason: 0, stale), or at once when its session ended by deauthorisation or token expiry (leftReason: 1). It is fanned out to the actor's last known chunk with the same ring rules its actor updates used, so exactly the clients that could have seen the actor are told, once. A session that merely moves to another game server (load shedding, reconnect) is not a leave and emits nothing.

Use it to remove the remote avatar, and to release anything keyed by that uuid (audio playback chains, video textures), immediately instead of waiting for your own staleness timeout — but keep the timeout: there is no retransmit, so the reaper is the fallback for a lost datagram. Tolerate leave → join for the same uuid (a real reconnect after a stale looks exactly like that); never latch "gone forever". The SDK stores do this for you: CrowdyJS RemoteActorStore removes the actor and fires onLeave on 145, CrowdyCPP RemoteActorLane likewise; treat leftReason values other than 0 and 1 as stale. The server-side twin for game logic is the root hub's on_presence on ck-exec, which lists the players who left — see presence.

Other mutations​

MutationDescription
sendVoxelUpdateModify a voxel in a chunk
sendAudioPacketSend voice audio data
sendVideoPacketSend one webcam video fragment (needs use_video_chat; see Webcam video)
sendTextPacketSend chat text
sendClientEventSend a custom event
sendSingleActorMessageSend a direct message to one actor by UUID (not broadcast)
sendChannelMessagePublish to a channel; delivered to members as ChannelMessageNotification (see Channels)
sendChannelAudioSend an audio packet to a channel; delivered to every other member as ChannelAudioNotification. Needs the channel's send_voice and use_voice_chat (see Voice on a channel)
sendRangedChannelMessagePublish to a channel, delivered only to members with a live actor within maxDistance chunks (straight-line) of an origin chunk; members receive the ordinary ChannelMessageNotification (see Limit delivery by distance)
connectUdpProxyExplicitly open a UDP session (optional -- mutations and subscription auto-open)
disconnectUdpProxyRelease the UDP session

Queries​

QueryDescription
udpProxyConnectionStatusCheck if a UDP session is active
gameClientBootstrap(appId)One-shot startup payload: current user, version requirements, UDP proxy status, realtime protocol + subscription name, spatial send limits (maxReplicationDistance, maxDecayRate, sequenceNumberModulo), the app's runtimeGate (status, reason: anything but ACTIVE means paused) and wildernessWritesOpen (whether players may build where only the world grid covers)