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 your hub/Overworld use the OAuth2 Authorization-Code + PKCE flow (createPortalAuthorizationCodeexchangePortalCode) 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 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 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 is delivered on this same subscription but is not spatial (it has 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.

  • Runtime-denied apps are refused. If an app is suspended or over its budget (see Shared environment & billing), the proxy refuses to open a session: HTTP mutations error and the subscription delivers a RealtimeConnectionEvent with code: 'UDP_PROXY_CONNECTION_FAILED' (retryable: true). Surface it so the studio can fund the wallet or lift the cap, then retry.

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 player_left automation / compute event — see Autonomous processes → Players leaving.

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)
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, and spatial send limits (maxReplicationDistance, maxDecayRate, sequenceNumberModulo)