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 15.x includes the Agentic Crowdy Studio allowlisted development contract. The app-token client.crowdyStudioAgent transport and integrated Ask/Build/Play dock provide durable events, exact approvals, checkpoints, scoped Play leases, and reconnect fencing. Access remains allowlisted; this is not production availability. 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.gameModel, client.udpapp-scopedWorld data, avatars, channels & teams, game models (incl. automations and model-driven notifications), the GraphQL UDP proxy subscription, and game-client bootstrap. client.host covers host election (get / amIHost) and heartbeat — see Host discovery.

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 full public API surface — every non-deprecated root field 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, udp, world(...), chunks, voxels, actors, avatars, state, teleport, host, channels, teams, gameModel, serverStatus, playerCompute, crowdyStudio, crowdyStudioAgentSafe to drive from an untrusted browser with the documented token and server policy. auth/users use the identity session token; world, Studio, agent, and realtime surfaces require an app-scoped token. Agent model access remains server-side and separately requires use_studio_agent.
Studio-admin (token whose user holds manage_apps)organizations, 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.
Operator (is_operator)client.operatorPlatform compute ceilings only. Infrastructure (change orders, secrets, releases) lives in the separate infra-control-plane service, not this SDK.

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.

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.

Dev tier example

For integration testing on the shared dev tier (current game env dev1), see Dev tier (client integration). Minimal CrowdyJS config:

createCrowdyClient({
httpUrl: 'https://ck.dev.crowdedkingdoms.com/graphql',
wsUrl: 'wss://ck.dev.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 Dev tier (client integration).

Register at https://app.dev.crowdedkingdoms.com/register — no shared admin account 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 a first-party host (Studio, crowdy.games)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.

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.

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,
},
});
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, 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);
});

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.

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). 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 everything everywhere — 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.

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.

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, client.environments, …, grouped under client.admin.*) and client.operator. 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.