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 v8, client.auth is passwordless
(magic link, social/OIDC, or the dev bypass — no login/register) and
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.
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.
Two-endpoint architecture
The SDK talks to two GraphQL base URLs:
| Sub-client | Endpoint config | Use |
|---|---|---|
client.auth, client.users, client.apps, client.platform | managementUrl | Passwordless identity (requestLoginLink/completeLoginLink, socialLoginStart/socialLoginComplete, devLogin, 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 | httpUrl / wsUrl | 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. Drive identity (auth, users, apps, platform, portal) from a client holding the identity session token, and the world/realtime surfaces from a per-game client holding that app's app-scoped token — see Authentication: session vs app-scoped tokens.
Full sub-client surface
As of v6 (completed in v6.1), CrowdyJS wraps the full public Management + Game
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 | Safe to drive from an untrusted browser. auth/users use the identity session token; the world & realtime surfaces require an app-scoped token. |
Studio-admin (token whose user holds manage_apps) | organizations, apps, appAccess, billing, payments, quotas, environments, 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. |
Operator (is_operator) | client.operator | Platform control plane (environments, change orders, secrets, releases, audit). Internal tooling only. |
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-plane surfaces
use the identity session token login 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({
managementUrl: 'https://api.dev.crowdedkingdoms.com',
httpUrl: 'https://game.dev1.dev.cks-env.com/graphql',
wsUrl: 'wss://game.dev1.dev.cks-env.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 Game API 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 a passwordless
client.authsign-in (completeLoginLink,socialLoginComplete, ordevLogin) 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.
Sign-in itself is passwordless — client.auth has no login/register.
See Sign-in with client.auth below.
client.portal wraps minting, the browser handoff, and consent:
portal.mintAppToken(appId)— native / same-origin: mint directly from the session token. Returns anAppTokenResponse(token,gameApiUrl,gameApiWsUrl,expiresAt, …); it is not stored on the calling client.portal.beginEntry(...)→portal.handleAuthorizeRequest()→portal.completeEntry()— the OAuth2 Authorization-Code + PKCE flow for a game on a different origin (the session token never leaves the Overworld). For an untrusted app,handleAuthorizeRequestthrowsPortalConsentRequiredErroruntil the user approves (pass{ grantConsent: true }once they do); trusted apps such as the Overworld (app 1) 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.
Use the two-client pattern: an identity client on the Overworld/hub origin
holding the session token, and a separate per-game client holding the app token.
They share only managementUrl, never a token store:
// Identity client (Overworld origin) — holds the session token.
const identity = createCrowdyClient({
managementUrl: 'https://management-api.example.com',
tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:session'),
});
// Passwordless sign-in (stores the session token). Magic link / social in
// production; devLogin shown here for dev/test (DEV_AUTH_BYPASS).
await identity.auth.devLogin('player@example.com');
// 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!,
managementUrl: 'https://management-api.example.com',
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 (passwordless)
There is no client.auth.login/register. Pick one passwordless path; each
returns an AuthResponse and stores the session token on the identity client:
// Magic link: request, then complete with the token from the emailed URL.
const { devToken } = await identity.auth.requestLoginLink({
email: 'player@example.com',
redirectUri: 'https://app.example.com/auth/callback',
});
// In dev (DEV_AUTH_BYPASS) devToken is returned directly; in prod it arrives by email.
await identity.auth.completeLoginLink(tokenFromLinkOrDevToken);
// 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 });
// Dev / test only (DEV_AUTH_BYPASS): one-call session.
await identity.auth.devLogin('player@example.com');
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 (passwordless) for the full
model.
Quick start
import {
BrowserLocalStorageTokenStore,
createCrowdyClient,
} from '@crowdedkingdoms/crowdyjs';
const managementUrl = 'https://management-api.example.com';
const appId = '1';
// Identity client: restore or sign in (passwordless) for the identity session token.
const identity = createCrowdyClient({
managementUrl,
tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:session'),
});
await identity.session.restore();
if (!identity.session.getToken()) {
// Passwordless sign-in — magic link / social in production. devLogin shown here
// for dev/test (DEV_AUTH_BYPASS). See #sign-in-with-clientauth-passwordless.
await identity.auth.devLogin('player@example.com');
}
// 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!,
managementUrl,
tokenStore: new BrowserLocalStorageTokenStore('crowdyjs:app:' + appId),
realtime: {
retryAttempts: 8,
waitTimeoutMs: 5000,
},
});
game.setToken(appToken.token);
// gameClientBootstrap is a Game API call, so it runs on the per-game client.
const bootstrap = await game.serverStatus.gameClientBootstrap(appId);
console.log(bootstrap.versionInfo.minimumClientVersion);
Always set managementUrl explicitly for new integrations. If omitted, the SDK falls back to httpUrl for legacy single-host deployments.
Game loop lifecycle
- On the identity client, sign in (passwordless) with
identity.auth.completeLoginLink()/socialLoginComplete()/devLogin(), 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, and client-event updates through
game.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.