Skip to main content

CrowdyJS SDK

Browser-first SDK for Crowded Kingdoms game clients. CrowdyJS wraps the Management API identity surface and the Game API world / GraphQL UDP-proxy surface behind typed clients. As of v15, client.auth signs in with login / register (email + password), magic link, or social/OIDC — and as of v15.1 it also manages passwords (reset, change, and adding a first one). Sign-in returns an identity session token for the Management API, while gameplay uses a short-lived app-scoped token minted with client.portal — see Authentication: session vs app-scoped tokens. Native UDP to Buddy servers uses the Replication API directly, not CrowdyJS.

CrowdyJS 16 docks the Agentic Crowdy Studio agent beside the Studio editor: the DeepSeek Harness runs in the player's browser, edits the project, runs draft tests and takes screenshots through the page, and spends model tokens through the Game API's metered endpoint with the player's own app token. Access remains allowlisted per app. Verify the published SDK with npm view @crowdedkingdoms/crowdyjs dist-tags.

Install​

npm install @crowdedkingdoms/crowdyjs

CrowdyJS targets browsers by default and uses native fetch, WebSocket, crypto, btoa, and atob. Node tools can use the SDK if they provide browser-compatible globals for realtime connections.

One endpoint, two token types​

The SDK talks to one GraphQL base URL, configured as httpUrl (and wsUrl for realtime). What separates the surfaces is the token, not the host:

Sub-clientTokenUse
client.auth, client.users, client.apps, client.platformidentity sessionIdentity (login/register, requestLoginLink/completeLoginLink, socialLoginStart/socialLoginComplete, availableLoginProviders, myIdentities/linkIdentity/unlinkIdentity, logout, me, updateGamertag), app routing reads (apps.routeFor), and public platform config (platform.config).
client.chunks, client.voxels, client.actors, client.avatars, client.teleport, client.state, client.host, client.serverStatus, client.channels, client.teams, client.udp, client.inputLogapp-scopedWorld data, avatars, channels & teams, the GraphQL UDP proxy subscription, and game-client bootstrap. client.host covers host election (get / amIHost) and heartbeat — see Host discovery. client.inputLog reads the inputs recorded for an app with replay logging on (18.6.0) — see Input logging.

Each client has one AuthState, so you still build two clients: an identity client holding the session token, and a per-game client holding that app's app-scoped token — see Authentication: session vs app-scoped tokens.

The two clients need not share a URL. An app lives in one datacenter, so mintAppToken returns the gameApiUrl / gameApiWsUrl for that app, and the per-game client should use them.

Upgrading from v13 or earlier

managementUrl, managementGraphqlEndpoint and the client.management escape hatch were removed when the separate management server was retired. Point httpUrl at the API and delete the managementUrl line; use client.graphql where you used client.management.

Full sub-client surface​

As of v6 (completed in v6.1), CrowdyJS wraps the public API surface a client uses — every non-deprecated root field outside platform administration has a typed method, with Relay *Connection cursor-pagination variants alongside the legacy offset lists. (v7 then made gameplay require an app-scoped token — see Authentication: session vs app-scoped tokens.) The surfaces are namespaced by audience:

AudienceSub-clientsNotes
Game-client (browser-safe)auth, users, safety, udp, world(...), chunks, voxels, actors, avatars, state, teleport, host, channels, teams, exec, inputLog, serverStatus, crowdyStudio, crowdyStudioGitHubSafe to drive from an untrusted browser with the documented token and server policy. auth/users use the identity session token; world, Studio, and realtime surfaces require an app-scoped token. The Studio agent pane (dsh option) reaches the model through the metered REST endpoint with that token and separately requires use_studio_agent.
Studio-admin (token whose user holds manage_apps)organizations, managedPlayers, moderation (a moderate_safety holder), apps, appAccess, billing, payments, quotas, usage, sharedEnvironment, gameApps (grids) — also grouped under client.admin.*Privileged org/app administration. Requires a user with the manage_apps permission (or an org token). Not end-user-safe — see the note below. Dedicated environments were removed in v13. managedPlayers (18.16.0, manage_players) administers an organization's org-managed players; its settings say whether the organization has accepted the Org-Managed Players Addendum.

The SDK never relaxes server-side authorization — exposing an operation just gives you a typed wrapper; the caller still needs the right token and permission.

The SDK is for players, developers and org-admins, and is designed for production. Since CrowdyJS 18.0.1 (the first 18.x on the production channel) it wraps no field that only a platform super-admin or operator can call (user administration, platform-wide payment audits, org freezes, app visibility overrides, hosted-game listing and take-downs): those fields are in the API reference, and platform tooling calls them directly.

Studio-admin is about who you authenticate as, not where the code runs

The studio-admin surfaces have exactly one extra gate: the caller must be a logged-in user who holds manage_apps on the org. The management-surface operations use the identity session token sign-in returns — there is no separate "admin token" type. (The one Game API studio-admin surface, gameApps (grids), runs on the Game API and so needs an app-scoped token for the target app — a studio admin can mintAppToken for their own app even without player entitlement.) So you can drive them from a browser — for example your studio's own admin tool, where an admin signs in — which is exactly how the Crowded Kingdoms management UI works. "Not in an untrusted browser" is a credential-safety rule: don't embed a privileged admin (or org) token in the public game client you ship to untrusted end users. A trusted admin context — a studio backend or an admin-only / authenticated web app — is fine.

Production example​

Build and test against production; see Connecting game clients. Minimal CrowdyJS config:

createCrowdyClient({
httpUrl: 'https://ck.prod.crowdedkingdoms.com/graphql',
wsUrl: 'wss://ck.prod.crowdedkingdoms.com/graphql',
tokenStore: new BrowserLocalStorageTokenStore(),
});

This single client covers identity and routing reads. For gameplay you still mint an app-scoped token and drive the world surfaces from a per-game client — see Authentication: session vs app-scoped tokens and Connecting game clients.

Register at https://studio.crowdedkingdoms.com/register — no shared admin account required. Crowded Kingdoms' dev and test environments are internal and refuse outside accounts (TIER_ACCESS_REQUIRED).

Tutorial: Build a collaborative canvas game — step-by-step guide with live demo chapters using the config above.

Authentication: session vs app-scoped tokens​

CrowdyJS uses two kinds of token and gameplay does not accept the session token (see Portals & app-scoped tokens):

  • Identity session token — returned by any client.auth sign-in (login, register, completeLoginLink, or socialLoginComplete) and stored on the client automatically. A management-plane credential (account, studio admin) and the only thing that can mint app tokens. It is rejected for gameplay (the Game API and the realtime/UDP surface reject it).
  • App-scoped gameplay token — short-lived (~30 min), confined to one app, and the Bearer for that app's Game API + realtime surface. Refresh or re-portal before it expires.

Which sign-in you use depends on where your code runs (ck-api v1.88.0, CrowdyJS 15.6.0, 2026-09-08):

your code runs...sign in withthen
in a browser, on your own domain (every third-party game)portal.signIn({ appId, redirectUri }) → Studio → portal.handleSignInCallback()the client already holds the app token
in a browser, on the first-party host (Studio)auth.login / magic link / socialportal.mintAppToken(appId)
outside a browser (Node, CLI, Unreal/Unity, CrowdyCPP, tests)auth.login / auth.registerportal.mintAppToken(appId)

The first row is the only one a game on its own domain can take: from any non-first-party browser origin the direct sign-in mutations are refused with HOSTED_SIGN_IN_REQUIRED (isHostedSignInRequiredError), because a page on a customer's domain that collects a Crowded Kingdoms password is indistinguishable from a phishing page. The player signs in on Studio and your game receives a token confined to itself. See Sign-in with client.auth for the other two rows.

Every row needs the player's terms, and an age and a country, before a gameplay token (ck-api v2.35.0 and v2.60.0, CrowdyJS 18.4.0 and 18.24.0). Until the documents are stored, portal.mintAppToken, portal.createAuthorizationCode and portal.refresh answer LEGAL_ACCEPTANCE_REQUIRED (isLegalAcceptanceRequiredError; legalAcceptanceRequiredOf(err).missingDocuments names what is missing). On the first row Studio's /authorize asks, so your game only sends the player back through portal.signIn when a refresh is refused that way. On the other two, show your own checkboxes, each document linked (client.auth.playerLegalDocuments() and the app's own client.auth.appRequiredDocuments(appId)), then call client.auth.recordPlayerConsents({ acceptLegal: true, appId }) before minting; client.auth.playerLegalAcceptance({ appId }) says whether that is still needed. Ask the date of birth and the country on a neutral screen and send them with auth.register({ …, birthDate, country, parentalConfirmation }), or auth.recordPlayerAge({ birthDate, country, parentalConfirmation }) for an account with none. The gate's other refusals read with territoryNotSupportedOf, ageRequiredOf, ageRequirementNotMetOf (it surfaces no age), safetyLogRequiredOf and, from 18.25.0, safetyModeratorRequiredOf. See Terms, age and country.

client.portal wraps hosted sign-in, minting, and consent:

  • portal.signIn({ appId, redirectUri, authorizeUrl? }) → portal.handleSignInCallback() — hosted sign-in for a game on its own domain. signIn derives Studio's /authorize from the API host (ck.<tier>. → studio.<tier>.; pass authorizeUrl to override) and navigates; handleSignInCallback exchanges the returned code for an app token, stores it, and strips code/state from the address bar (safe to call on every boot). Your redirectUri's origin must be one of the app's registered redirect URIs, which is also what admits it to CORS.
  • portal.mintAppToken(appId) — first-party / non-browser: mint directly from a session token. Returns an AppTokenResponse (token, gameApiUrl, gameApiWsUrl, expiresAt, …); it is not stored on the calling client.
  • portal.beginEntry(...) → portal.handleAuthorizeRequest() → portal.completeEntry() — the same PKCE steps without the defaults; handleAuthorizeRequest is what Studio's /authorize page runs. For an untrusted app it throws PortalConsentRequiredError until the user approves (pass { grantConsent: true } once they do); trusted apps skip consent.
  • portal.getConsent(appId), portal.authorizeApp(appId), portal.myAuthorizedApps(), portal.revokeAppAuthorization(appId) — the consent screen + "connected apps" management. App owners register client settings (redirect-URI allow-list, client type, launch URL) with portal.setAppClientSettings({ appId, redirectUris, clientType, launchUrl }).
  • portal.refresh() — silent same-app token rotation before expiry.
  • portal.adoptAppToken(response) and portal.exchangeManagedPlayerAssertion({ appId, assertion }) (18.16.0) — the device's side of org-managed players: store the app token the player's organization minted on its server (an org token, cko_…, is refused), or trade an assertion the organization signed. Until the organization has accepted the Org-Managed Players Addendum, both and their refreshes answer ORG_LEGAL_ACCEPTANCE_REQUIRED.

Hosted sign-in (a game on its own domain) is two calls on one client:

const game = createCrowdyClient({ httpUrl, wsUrl,
tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:app:' + appId) });

// Boot: finish a sign-in we are returning from (no-op without ?code=).
const entered = await game.portal.handleSignInCallback();

// "Sign in with Crowded Kingdoms" button: go to Studio and come back.
await game.portal.signIn({ appId, redirectUri: `${location.origin}/auth/callback` });

First-party or non-browser code uses the two-client pattern: an identity client holding the session token, and a separate per-game client holding the app token. They never share a token store:

// Identity client (first-party origin or Node) — holds the session token.
const identity = createCrowdyClient({
httpUrl: 'https://api.example.com/graphql',
tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:session'),
});
// Sign in (stores the session token). Email + password here; magic link and
// social are the other two paths and behave the same from this point on.
await identity.auth.login({ email: 'player@example.com', password });

// Native / same-origin: mint an app token, then build the per-game client.
const appToken = await identity.portal.mintAppToken(appId);
const game = createCrowdyClient({
httpUrl: appToken.gameApiUrl!,
wsUrl: appToken.gameApiWsUrl!,
tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:app:' + appId),
});
game.setToken(appToken.token);
// Drive gameplay through `game`; rotate with `game.portal.refresh()` before expiry.

See Portals & app-scoped tokens for the full mint / PKCE / refresh narrative and Game API → Authentication.

Sign-in with client.auth​

client.auth.login(email, password) and client.auth.register(...) are the primary path for first-party pages and non-browser code (15.0.0). Magic link and social/OIDC remain. Each returns an AuthResponse and stores the session token on the identity client. From a browser game on its own domain every method in this namespace is refused with HOSTED_SIGN_IN_REQUIRED (ck-api v1.88.0); use client.portal.signIn there instead.

Until 15.0.0 this SDK was passwordless and these pages said login and register did not exist. They do. The devLogin bypass they also advertised has been removed from the SDK and from every tier.

// Magic link: request, then complete with the token from the emailed URL.
await identity.auth.requestLoginLink({
email: 'player@example.com',
redirectUri: 'https://app.example.com/auth/callback',
});
// The token arrives ONLY by email -- there is no way to read it out of the
// response. An automated caller should register an account instead.
await identity.auth.completeLoginLink(tokenFromLink);

// Social / OIDC: drive your buttons from the enabled providers.
const providers = await identity.auth.availableLoginProviders(); // e.g. ['google']
const { authorizeUrl, state } = await identity.auth.socialLoginStart(
'google',
'https://app.example.com/auth/google/callback',
);
window.location.assign(authorizeUrl); // …provider redirects back with ?code & ?state
await identity.auth.socialLoginComplete({ provider: 'google', code, state });

// Email + password: the path that needs neither an inbox nor a browser.
await identity.auth.register({ email: 'player@example.com', password }); // new account
await identity.auth.login({ email: 'player@example.com', password }); // existing one

An account can link multiple sign-in methods: identity.auth.myIdentities(), identity.auth.linkIdentity({ provider, code, state }), and identity.auth.unlinkIdentity(identityId) (which refuses to remove your last sign-in method). See Sign in for the full model.

Managing passwords​

New in 15.1.0. The API has served these four throughout; the SDK wrapped none of them until now, so a game shipping CrowdyJS could sign a player in and then had no first-class way to let them set or change the password behind it.

Which one applies is decided by what the caller has already proven, not by what they want to do:

// Not signed in, or signed in and cannot remember it: email a reset link.
await identity.auth.requestPasswordReset('player@example.com'); // always true
await identity.auth.resetPassword({ token: tokenFromEmail, newPassword });

// Signed in, and the account has a password.
await identity.auth.changePassword({ currentPassword, newPassword });

// Signed in, and it has none -- a magic-link or social-only account.
await identity.auth.setInitialPassword(newPassword);

identity.auth.checkAuthMethod(email) returns hasPassword for an address before sign-in, which is how you decide which form to show.

setInitialPassword refuses an account that already has a password, and that is deliberate rather than an inconvenience to route around: without the refusal it would be changePassword with the current-password check deleted, and that check is what stops a stolen session from replacing a credential the owner still knows. It also emails a security notification to the account address on success — tell your user that, because they are about to receive it.

Do not branch on UNAUTHENTICATED here

Each refusal has its own extensions.code from ck-api v1.60.0: PASSWORD_ALREADY_SET, PASSWORD_NOT_SET, INVALID_CURRENT_PASSWORD. Before that release the first two shared UNAUTHENTICATED with a genuinely expired session and the third arrived as INTERNAL_SERVER_ERROR, so a client reading the code signed a user out for mistyping their current password.

Use the predicates rather than either. Each one accepts the new code and the older wording, so the same build works against a tier that has not deployed v1.60.0 — which matters here, because games pin this SDK exactly:

import {
isPasswordAlreadySetError,
isNoPasswordSetError,
isInvalidCurrentPasswordError,
} from '@crowdedkingdoms/crowdyjs';

try {
await identity.auth.setInitialPassword(newPassword);
} catch (e) {
if (isPasswordAlreadySetError(e)) {
// They already have one. Ask for it rather than replacing it blind.
await identity.auth.changePassword({ currentPassword, newPassword });
} else throw e;
}

None of the three means the session is gone. Sign the user out only on UNAUTHENTICATED, which now says only that.

Since ck-api v1.88.0 resetPassword revokes every session of the account and changePassword every session but the calling one (with the app tokens minted from them), so a second tab or device is signed out by either. Full semantics, including the exact refusal wording, are in Managing passwords.

Quick start​

This is the first-party / non-browser shape (direct sign-in, then mint). A browser game on its own domain replaces the identity client with game.portal.signIn / game.portal.handleSignInCallback as shown above and never holds a session.

import {
BrowserLocalStorageTokenStore,
createCrowdyClient,
} from '@crowdedkingdoms/crowdyjs';

const apiUrl = 'https://api.example.com/graphql';
const appId = '1';

// Identity client: restore a session, or sign in, for the identity session token.
const identity = createCrowdyClient({
httpUrl: apiUrl,
tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:session'),
});

await identity.session.restore();
if (!identity.session.getToken()) {
// Sign in. Email + password shown; magic link and social are the other paths.
// See #sign-in-with-clientauth.
await identity.auth.login({ email: 'player@example.com', password });
}

// Mint an app-scoped token, then build a per-game client that holds it.
const appToken = await identity.portal.mintAppToken(appId);

const game = createCrowdyClient({
httpUrl: appToken.gameApiUrl!,
wsUrl: appToken.gameApiWsUrl!,
tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:app:' + appId),
realtime: {
retryAttempts: 8,
waitTimeoutMs: 5000,
// Optional: the binary relay signs datagrams in the browser and, since 17.1,
// packs the sends made within `bundleWindowMs` (default 1 ms) into one
// MESSAGE_BUNDLE. `client.udp.flushSends()` sends a frame's worth at once;
// `bundleSends: false` restores one datagram per message.
// binaryTransport: true,
},
});
game.setToken(appToken.token);

// gameClientBootstrap needs the app-scoped token, so it runs on the per-game client.
const bootstrap = await game.serverStatus.gameClientBootstrap(appId);
console.log(bootstrap.versionInfo.minimumClientVersion);

Note that the per-game client uses the URLs the mint returned, not apiUrl: those name the datacenter holding the app. The identity client can stay on the shared origin.

Game loop lifecycle​

  1. On the identity client, sign in with identity.auth.login() / register() / completeLoginLink() / socialLoginComplete(), or identity.session.restore().
  2. Mint an app-scoped token for the target app — identity.portal.mintAppToken(appId) (same-origin/native) or the PKCE portal flow for a different origin — and build a per-game client that holds it (game.setToken(appToken.token)). See Authentication: session vs app-scoped tokens.
  3. Subscribe to UDP proxy notifications with game.udp.subscribe(handlers, appId) (or game.world(appId).subscribe(handlers)). The appId is required — the Game API scopes each realtime session to one app.
  4. Join a chunk by sending an initial actor update.
  5. Send actor, voxel, text, audio (game.udp.sendAudioPacket, Voice chat), video (game.udp.sendVideoFrame, needs use_video_chat; see Webcam video), and client-event updates through game.udp or game.world(appId) helpers.
  6. Call game.udp.disconnect() when leaving the world, then game.close() when disposing the client. Rotate the app token with game.portal.refresh() before it expires.

Realtime notifications​

subscribe takes the handlers and a required appId, and the session must be opened with that app's app-scoped token: an identity session token is rejected with APP_TOKEN_REQUIRED, and a token minted for a different app with APP_SCOPE_MISMATCH. The Game API also fences udpNotifications by app and rejects an app-agnostic subscription with a RealtimeConnectionEvent (code: 'APP_ID_REQUIRED'); the SDK sends the app in the WebSocket connection_init payload. Run one per-game client per app (each holding that app's token) when a player is in multiple apps at once.

const appId = '1';

const unsubscribe = client.udp.subscribe(
{
actorUpdate: (event) => {
console.log(event.uuid, event.state);
},
singleActorMessage: (event) => {
// A direct actor-to-actor message addressed to you (payload is base64).
console.log(event.uuid, event.payload);
},
genericError: (event) => {
console.warn(event.sequenceNumber, event.errorCode);
},
connectionEvent: (event) => {
// e.g. APP_ID_REQUIRED (no appId), APP_TOKEN_REQUIRED (session token used),
// or APP_SCOPE_MISMATCH (token minted for a different app).
console.warn(event.code, event.message);
},
error: (error) => {
console.error(error.code, error.message);
},
},
appId,
);

client.realtime.onStatus((status) => {
console.log('realtime:', status);
});

genericSpatial (CrowdyJS 18.7.0) receives GENERIC_SPATIAL_1 (opcode 140), the long spatial message your game defines the payload of. It arrives on the binary relay only (realtime.binaryTransport: true): the GraphQL udpNotifications union has no member for it. client.udp.sendGenericSpatial({ appId, chunk, uuid, payload }) (CrowdyJS 18.15.0) sends one, also on the binary relay only, since there is no GraphQL mutation for it: off the relay it throws CrowdyRealtimeError with code: 'BINARY_RELAY_UNAVAILABLE' and retryable: false, and sends nothing. The payload is base64 and at most 1,123 bytes decoded; distance defaults to 8 and decayRate to 0. CrowdyCPP's Connection::sendGenericSpatial and CrowdyPy's client.udp.send_generic_spatial send the same message. A genericError with errorCode 33 (APP_PAUSED) means the app is paused (its organization has no funds, reached a spend cap, or its subscription lapsed) and nothing will be delivered until the owner fixes it; gameClientBootstrap(appId).runtimeGate says why.

client.world(appId).subscribe(handlers) is a convenience wrapper that passes its appId for you.

The SDK uses the graphql-transport-ws protocol, reconnects with backoff, re-reads the current token before reconnecting, and resubscribes to the UDP notifications subscription. It does not rotate an expiring app token for you — call game.portal.refresh() before the token's expiresAt to keep the same-app session alive (see Portals & app-scoped tokens).

Raw UDP sends​

const response = await client.udp.sendActorUpdateAndWait({
appId: '1',
chunk: { x: '0', y: '0', z: '0' },
uuid: '0123456789abcdef0123456789abcdef',
state: 'AA==',
distance: 8,
decayRate: 1,
});

console.log(response.__typename, response.sequenceNumber);

The AndWait variants allocate a sequenceNumber when missing and wait for a matching notification or GenericErrorResponse, timing the wait from when the send was accepted. The first awaited send of each session (the first connect, a reconnect, or a move to another server) is retried once with a fresh sequenceNumber if it is refused UNAUTHORIZED or, for actor and voxel updates, times out: how a replication server older than v0.42.0 answers a session's first message. A send whose sequenceNumber you chose is not retried, and neither is the timeout of an audio, text or event send, which is how those succeed (CrowdyJS 18.20.0). An actor or voxel update still waiting when the client reconnects or moves to another server is resent as soon as the new session is up, rather than at its timeout, because its echo went to the session it left; that is its one retry (CrowdyJS 18.21.0).

To message a single actor directly (delivered only to that actor, not broadcast), use sendSingleActorMessage. It is fire-and-forget -- the sender gets no echo, so there is no AndWait variant:

await client.udp.sendSingleActorMessage({
appId: '1',
chunk: { x: '7', y: '1', z: '2' }, // the TARGET actor's current chunk
targetUuid: '0123456789abcdef0123456789abcdef',
payload: 'aGVsbG8=', // base64 payload
});

Permissions​

The realtime server always enforces permissions. A player can only act in your world if they have app access (an entitlement / access tier) and the target chunk is inside a grid where they hold the right key (access, update_voxel_data, use_voice_chat). Building is decided by the most specific grid covering the chunk: a plot or safe zone decides its own chunks, the world grid decides only the wilderness, and the app can close the wilderness to building (which grid decides a voxel write). New apps are open by default — a default tier and a world-spanning grant are created automatically, and giving a player app access grants them every key on the world grid — so basic play and building work with no extra setup. Owners add restrictions (safe zones, plot ownership) via the Game API.

When a player lacks permission, the server replies with a GenericErrorResponse (an UNAUTHORIZED error code) rather than delivering the action; your genericError handler (and any *AndWait promise rejection) surfaces it:

client.udp.subscribe(
{
genericError: (e) => {
if (e.errorCode === 7 /* UNAUTHORIZED */) {
// player isn't entitled / lacks grid permission for that chunk
}
},
},
appId,
);

See Game API → Permissions overview for tiers, grids, and how to grant or restrict access.

Player safety​

CrowdyJS 18.24.0 wraps the teen safety program (ck-api v2.60.0), and 18.25.0 its additions (ck-api v2.61.0): safetyModeratorRequiredOf, the reason on parentalConsentUnavailableOf(err), hasSafetyModerator on app reads, regionCode on platform.territoryPolicies() rows, and region in an organization's assertion claims. With the player's app token, client.safety holds their own controls in the app: voice and video settings (commSettings, setCommSettings), a parent's consent (requestParentalConsent), contacts (contacts, contactRequests, requestContact, acceptContact, removeContact), blocks (blocks, block, unblock) and reports (fileReport). client.apps.recording(appId) says what the safety log records, for the notice and the indicator your game shows. Moderators use client.moderation (reports, resolveReport, escalateReport) with their identity session. The realtime servers enforce the settings, contacts and blocks (replication server v0.43.0); your game shows the controls. See Voice, video, contacts and blocks, Safety reports and moderation and Players under 18 and player safety.

World helpers​

const world = client.world('1');
const actor = world.actor();

await actor.join({ x: '0', y: '0', z: '0' });
await actor.sendState('AA==');
await actor.sendText('hello nearby players');

// Direct message to one other actor (supply its UUID + current chunk):
await actor.sendToActor('0123456789abcdef0123456789abcdef', 'aGVsbG8=', {
x: '7',
y: '1',
z: '2',
});

Errors​

  • CrowdyHttpError
  • CrowdyGraphQLError
  • CrowdyNetworkError
  • CrowdyTimeoutError
  • CrowdyRealtimeError
  • CrowdyProtocolError
  • PortalConsentRequiredError — thrown by client.portal.handleAuthorizeRequest when an untrusted app needs the user's consent before a portal code can be minted. It carries appId and appName; show a consent screen, then retry with { grantConsent: true }.

CrowdyGraphQLError preserves GraphQL errors, including path and extensions.code.

Helpers read the refusals a game shows the player: accessRefusalOf(err) (ACCESS_NOT_GRANTED, ACCESS_REVOKED, ACCESS_SUSPENDED with suspendedUntil), accountBlockedOf(err) (ACCOUNT_BLOCKED, with blockedUntil for a timed block; CrowdyJS 18.10.0), groupMemberBannedOf(err) (GROUP_MEMBER_BANNED), appPausedOf(err) and actorExistsOf(err). For org-managed players: assertionRejectedOf(err) (ASSERTION_REJECTED, with the failed check in reason) and managedPlayerNotAllowedOf(err) (MANAGED_PLAYER_NOT_ALLOWED: no wallet, or mods the organization has not switched on) from 18.16.0, and orgLegalAcceptanceRequiredOf(err) (ORG_LEGAL_ACCEPTANCE_REQUIRED: the player's organization has not accepted the Org-Managed Players Addendum) from 18.17.0. Neither the player nor a retry fixes the last one: an admin of the organization accepts the addendum in Studio. isLegalAcceptanceRequiredError is the player's own clickwrap, and since 18.17.0 it no longer matches the organization's code. An ExecConnection whose player's access or session ends is closed for good with EXEC_CLOSE_ACCESS_ENDED (4403); onAccessEnded(listener) hears the reason.

Driving the Game API or realtime surface with an identity session token instead of an app-scoped token fails with the realtime RealtimeConnectionEvent codes APP_TOKEN_REQUIRED / APP_SCOPE_MISMATCH; an app token whose TTL elapsed mid-session yields the UdpErrorCode TOKEN_EXPIRED. Minting a portal code for an untrusted app without consent surfaces as PortalConsentRequiredError (or a FORBIDDEN/CONSENT_REQUIRED GraphQL error when calling the API directly). See Error codes.

Low-level GraphQL access​

import { VersionInfoDocument } from '@crowdedkingdoms/crowdyjs/generated';

const data = await client.graphql.request(VersionInfoDocument);

Prefer the typed sub-clients for everyday use — including the studio-admin surfaces (client.organizations, client.billing, client.quotas, …, grouped under client.admin.*). The escape hatch above is only for brand-new server fields not yet wrapped. See the Management API schema reference for the underlying operations.

Schema reference​

Generated operation types ship with the npm package. Platform GraphQL shapes are documented under CrowdyJS GraphQL reference and the Management / Game API schema tabs.