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-client | Token | Use |
|---|---|---|
client.auth, client.users, client.apps, client.platform | identity session | Identity (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.udp | app-scoped | World 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.
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:
| Audience | Sub-clients | Notes |
|---|---|---|
| Game-client (browser-safe) | auth, users, udp, world(...), chunks, voxels, actors, avatars, state, teleport, host, channels, teams, gameModel, serverStatus, playerCompute, crowdyStudio, crowdyStudioAgent | Safe 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.operator | Platform 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.
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.authsign-in (login,register,completeLoginLink, orsocialLoginComplete) 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 with | then |
|---|---|---|
| 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 / social | portal.mintAppToken(appId) |
| outside a browser (Node, CLI, Unreal/Unity, CrowdyCPP, tests) | auth.login / auth.register | portal.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.signInderives Studio's/authorizefrom the API host (ck.<tier>.→studio.<tier>.; passauthorizeUrlto override) and navigates;handleSignInCallbackexchanges the returned code for an app token, stores it, and stripscode/statefrom the address bar (safe to call on every boot). YourredirectUri'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 anAppTokenResponse(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;handleAuthorizeRequestis what Studio's/authorizepage runs. For an untrusted app it throwsPortalConsentRequiredErroruntil 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) withportal.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
loginandregisterdid not exist. They do. ThedevLoginbypass 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.
UNAUTHENTICATED hereEach 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
- On the identity client, sign in with
identity.auth.login()/register()/completeLoginLink()/socialLoginComplete(), oridentity.session.restore(). - 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. - Subscribe to UDP proxy notifications with
game.udp.subscribe(handlers, appId)(orgame.world(appId).subscribe(handlers)). TheappIdis required — the Game API scopes each realtime session to one app. - Join a chunk by sending an initial actor update.
- Send actor, voxel, text, audio, video (
game.udp.sendVideoFrame, needsuse_video_chat; see Webcam video), and client-event updates throughgame.udporgame.world(appId)helpers. - Call
game.udp.disconnect()when leaving the world, thengame.close()when disposing the client. Rotate the app token withgame.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
CrowdyHttpErrorCrowdyGraphQLErrorCrowdyNetworkErrorCrowdyTimeoutErrorCrowdyRealtimeErrorCrowdyProtocolErrorPortalConsentRequiredError— thrown byclient.portal.handleAuthorizeRequestwhen an untrusted app needs the user's consent before a portal code can be minted. It carriesappIdandappName; 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.