Set up a game and give players access
Bringing a game online involves two roles:
- The admin (an org owner / studio) sets the game up: an organization, an app, and one or more access tiers.
- Players then get access to that app — either automatically (open‑by‑default) or by an explicit grant.
Entitlements — who may play, at which tier, and which runtime permission keys that tier carries — are defined here on the Management API. The Game API enforces them at runtime: on a player's first connect it mirrors the entitlement and provisions the matching world‑grid permissions, and Buddy (the UDP server) checks both app‑ and grid‑level permissions on every spatial message.
All operations below are available in the Management UI and via GraphQL with a user session token or an org API token. Exact field names per version are in the schema reference.
1. Admin: set up your game
a. Create an organization
You become the org owner (full manage_apps / manage_environments permissions).
mutation { createOrganization(input: { name: "Acme Games", slug: "acme" }) { orgId } }
b. Create an app
Creating an app automatically provisions a free, default access tier holding the standard runtime permission keys (access, teleport, update_voxel_data, use_voice_chat). That single step makes the app immediately playable and is what enables open‑by‑default access (next section).
mutation {
createApp(input: { orgId: "ACME_ORG_ID", name: "My World", slug: "my-world", datacenter: "or", status: LIVE, visibility: PUBLIC }) {
appId
}
}
createApp requires the manage_apps permission on the org (org owners have it by default). datacenter is required and permanent, because all of an app's data lives in one datacenter: pass a code that placeableDatacenters lists with placeable: true. status is one of DRAFT, LIVE, ARCHIVED; visibility is PUBLIC, UNLISTED, or PRIVATE (only PUBLIC + LIVE apps surface in the marketplace).
Manage the app over its lifecycle (also manage_apps):
# Update metadata: name, description, visibility, status, metadata.
mutation { updateApp(appId: "APP_ID", input: { description: "Now with voxels", visibility: UNLISTED }) { appId } }
# Take an app offline without deleting it (sets status to ARCHIVED).
mutation { archiveApp(appId: "APP_ID") { appId status } }
Read an app's current state any time with the app(appId) query — including the routing fields (gameApiUrl, splitMode, deploymentTarget) covered in step d.
c. (Optional) Define additional access tiers
Add tiers for premium/paid access, or to grant a narrower/broader set of permissions. List assignable keys with the runtimePermissions query.
mutation {
createAccessTier(input: {
appId: "APP_ID",
name: "Premium",
isFree: false,
isDefault: false,
permissionKeys: ["access", "teleport", "update_voxel_data", "use_voice_chat"]
}) { tierId }
}
Tier operations require the manage_access_tiers permission. Edit or retire tiers with updateAccessTier(tierId, input) and archiveAccessTier(tierId), and list an app's tiers (no auth) with appAccessTiers(appId).
Opting out of open‑by‑default: if you do not want anonymous/auto access, remove (or never create) a tier that is both
isFreeandisDefault. Players then only get access through an explicit grant (below).
The four code keys (write_server_code, run_server_code,
write_client_code, run_client_code) are not in the generated default
tier. Add them only to tiers intended for authors or mod users; see
mods.
c.1 (Optional) Censor player code with strict admission
New apps default to IMPLICIT_ALLOW (censorship off). To require studio
approval for every running player artifact:
mutation {
setAppCodeAdmissionMode(appId: "APP_ID", mode: ALLOW_LIST)
}
ALLOW_LIST is strict: self-authored code in its author's own grid still waits
for the code, author, or authoring org to be admitted. Deploy and compile remain
available; only execution is gated.
mutation {
admitAppCode(input: {
appId: "APP_ID"
subjectKind: AUTHOR
subjectRef: "PLAYER_USER_ID"
}) {
admissionId
admittedAt
}
}
Use appCodeAdmissions to inspect active/history rows and
revokeAppCodeAdmission to drain admitted code. Writes require
manage_compute; reads require view_compute_diagnostics. Admission never
grants source access — closed source remains author-only with no moderation
override.
Mods. A player's mod is admitted by its own id or its
marketplace listing (subjectKind: CODE) or by its owner
(subjectKind: AUTHOR); until then, switching it on is refused. For a hostile
listing, switch off every install of it at once with execModSetSwitch and
the listing's id (Game API, manage_compute).
c.1a (Optional) Choose how claims confer grid ownership (P4a)
Games differ on how a player comes to own a grid. Configure the app's
claim policy (requires manage_apps):
mutation {
setAppGridClaimPolicy(appId: "APP_ID", policy: SELF_CLAIM)
}
SELF_CLAIM (default) lets claimGridOwnership assign ownership directly;
APPROVAL turns claims into requests your designated approvers accept;
INVITE requires a standing invite. setAppGridClaimPolicy refuses
MARKETPLACE_ONLY while paid grid sales are off the public API. Changing
policy never revokes existing ownership rows.
c.2 (Optional) Take a markup on player usage
Player usage bills the player's own wallet, never the org — see Player wallets & billing. One knob belongs to the studio:
- Rate-card markup (
setPlayerRateMarkup,manage_billing): basis points added on the platform's base player rates — the studio's usage revenue, always itemized separately in the player's spend history.
appPlayerUsage (view_compute_diagnostics) shows per-player consumption;
appPlayerMarkupAccrued (view_billing) totals accrued markup income.
c.3 Decide who the game is for
Crowded Kingdoms admits players aged 13 to 17 (since 2026-11-04). Every app whose minimum age is
under 18 admits them, and must keep its safety log on, give players the communication
controls and appoint a moderator (a member holding a role with moderate_safety; an admin alone
does not count). A game for adults sets its minimum age to 18:
mutation { updateApp(appId: "42", input: { minPlayerAge: 18 }) { effectiveMinPlayerAge } }
See Players under 18 and player safety for what changes and what a
game needs, and Age, country and who may play for the
checks every sign-in passes. Documents of your app's own, which its players accept on top of the
platform's, are set with setAppRequiredDocuments (Legal documents).
d. Choose where the app runs
Your app needs a Game API to serve runtime traffic, and there is one way to get it: the shared platform. Publish with publishAppToShared and the app is immediately served by that tier's shared Game API fleet, scoped by your appId, in the datacenter you chose when you created it. You do not provision VMs or wait for a stack.
Availability: shared platform hosting is the only hosting model. Customer-provisioned environments — the developer sandbox (
environmentClass: "dev_single") and multi‑VM dedicated stacks — were retired without replacement, and their mutations are no longer in the published SDL. If you are following an older guide that starts withcreateEnvironment, stop: see Shared environment & billing instead.
Whichever hosting you use, query the app's routing fields (gameApiUrl, splitMode, deploymentTarget) before a player joins so the client connects to the right Game API. See Shared environment & billing for the shared model and routing fields, and Loading an app's Game API for the client walkthrough.
2. How players get access
There are two paths — most public games use the first.
Open‑by‑default (zero‑friction)
If the app has a free + default tier (the one createApp made for you), then any player is automatically granted that tier the first time they connect — no per‑player admin action. The Game API resolves the entitlement on connect, mirrors it into the per‑tenant game database, and grants the player the app's default world‑grid permissions. From that point Buddy authorizes their spatial traffic.
Explicit grant (admin‑controlled)
For premium tiers, or for apps that opted out of open‑by‑default, the admin grants a specific player a specific tier:
mutation { grantAppAccess(input: { appId: "APP_ID", userId: "PLAYER_USER_ID", tierId: "TIER_ID" }) { appUserAccessId status } }
When you change what a tier grants, resyncTierGridPermissions(appId) re-applies each player's tier keys on the app's default world grid. It only adds keys: a key the tier no longer carries stays until you revoke it.
The grant propagates to the Game API automatically (the matching grid permissions are provisioned server‑side via replica‑sync), so the player is authorized end to end — again with no direct database access.
Revoking, suspending and banning
Revoke a player's access with revokeAppAccess(appId, userId). For a ban that lifts by itself, suspend it instead:
mutation { suspendAppAccess(appId: "APP_ID", userId: "PLAYER_USER_ID", until: "2026-11-01T00:00:00Z") { suspendedUntil } }
until is at most 365 days away; unsuspendAppAccess(appId, userId) lifts it early. Granting, revoking and suspending require manage_access_tiers, and your app's ck-exec code can do the same with the access.write scope.
Revoking or suspending cuts everything the player does in your app, at once:
- Their gameplay tokens for the app stop working, and their realtime connection is dropped. Channel messages stop reaching them and theirs stop reaching the channel.
- Their ck-exec connections to your app close with code
4403, and their mods in your app stop until the suspension ends or you restore them. - Signing in to the app is refused with
ACCESS_REVOKED, orACCESS_SUSPENDEDwithsuspendedUntil. So are their other calls into the app: purchases, model calls, joining a channel or a team, and the like. Your server code'splayers.featuresanswers no features for them andplayers.check_permissionanswers false.
What is the player's own stays readable: their wallet, receipts and their own input logs. A member of your organization keeps their developer access. A grant that expires is cut the same way within a minute, but it is not a sanction: the player can buy or be granted access again. A suspension lapses by itself; lifting either restores nothing live, and the player connects again. None of these codes tells the player to buy anything, and a player with no access to an app with no free tier gets ACCESS_NOT_GRANTED.
To keep a player out of one channel or team without touching their access to the app, ban them from it: banChannelMember(groupId, userId, reason) or banTeamMember(...), with the group's manage_members permission. They are removed, the channel stops reaching them, and joining, requesting to join and being added are refused with GROUP_MEMBER_BANNED until unbanChannelMember / unbanTeamMember. Leaving or being removed does not lift a ban.
Crowded Kingdoms can also block an account across the whole platform, on every app. Its requests and sign-in are then refused with ACCOUNT_BLOCKED (with blockedUntil for a timed block). Your game shows the refusal; there is nothing for it to do.
3. The player's flow
- Register or log in through the Management API (
register/login) — returns the identity session token. (Browser games can use the SDK's guest/anonymous auth; see CrowdyJS and the Build a game tutorial.) - Mint an app token for this app —
mintAppToken(native/same-origin) or the browser portal flow (client.portal). This is the app-scoped token used for gameplay; the session token is rejected by the Game API. The first mint auto-grants access on free tiers. See Portals & app-scoped tokens. - Connect with CrowdyJS, the Unreal SDK, or a raw UDP client, pointed at the app's
gameApiUrl(returned by the mint), sending the app-scoped token as the Bearer. - Play — send actor, voxel, text, audio, and client‑event updates. The Game API and Buddy authorize each message against the player's tier and grid permissions. Refresh the app token (
refreshAppToken) before it expires.
What happens under the hood
Management API Game API (per app `gameApiUrl`) Buddy (UDP)
────────────── ────────────────────────────── ───────────
app + access tier (free, default)
│ open-by-default on 1st connect (or explicit grantAppAccess)
└────────────────────────────► mirror app_user_access
+ grant default world-grid permissions ──► enforces app + grid
permissions per message
A player with an active tier on an app is authorized everywhere that matters — no manual grid setup, no database writes by the integrator.
The permission keys
runtimePermissions returns the assignable set. There are nine, and the last five are the ones people are surprised by.
| Key | What it allows | App scope | Grid scope |
|---|---|---|---|
access | Access an app runtime. Without it nothing else matters. | ✓ | ✓ |
teleport | Teleport within an app runtime | ✓ | ✓ |
update_voxel_data | Update voxel data | ✓ | ✓ |
use_voice_chat | Use voice chat | ✓ | ✓ |
write_server_code | Author and deploy player server code | ✓ | ✓ |
run_server_code | Run admitted player server code | ✓ | ✓ |
write_client_code | Author and deploy player client code | ✓ | ✓ |
run_client_code | Run admitted player client code | ✓ | ✓ |
use_studio_agent | Use the app-scoped Agentic Crowdy Studio orchestrator | ✓ | — |
The generated default tier carries the first four. Everything below the line is opt-in, per tier.
run_server_code is what mods need. Switching a player's mod on requires effective run_server_code: on the player's access tier and on the grid. A tier that carries write_server_code without it lets players build and deploy mods that are then refused when they switch them on.
Two scopes, and the one that does not follow a tier edit
Every key except use_studio_agent is checked at two scopes, and code execution requires both:
- App scope — the access tier the player is on.
- Grid scope — a per-user, per-grid row, mirrored from the tier on the player's first connect.
That mirroring is one-way and one-time. It is what makes the happy path effortless, and it is the trap:
Editing a tier does not re-mirror to players who have already connected. They keep the grid-scope permissions they were given the first time they entered, however many times you update the tier afterwards. New players get the new set; existing ones do not.
So a permission added to a live game reaches its existing players only if you also grant it at grid scope:
mutation {
grantGridPermissions(input: {
appId: "APP_ID", gridId: "GRID_ID", userId: "USER_ID",
permissionKeys: ["run_server_code", "run_client_code"]
}) { __typename }
}
Two things about that call, both of which return a clear error rather than failing silently:
- It is app-resident — send it to the app's own
gameApiUrl(frommintAppToken), not the shared entry name. The shared name answers from whichever datacenter you land in and will tell you to reconnect. - It needs an app-scoped token, not an identity session token.
What a new app developer should check first
- The default tier's keys.
appAccessTiers(appId)— isisDefaultpresent, and does it carry what your game actually needs? Four keys is the generated default, not a recommendation. run_server_codeat both scopes, if players run mods at all.- The Studio agent policy, if you use the agent: it is fail-closed on a rebuilt fleet and reports
AGENT_APP_KILLEDuntil an operator enables it for your app. - The org runtime wallet —
app(appId){ runtimeStatus runtimeDenialReason }. A denied wallet stops server code independently of every permission above, with its own message. - Who the game is for —
app(appId){ minPlayerAge effectiveMinPlayerAge replayLoggingEnabled }. An app that admits players under 18 keeps its safety log on (c.3).
Quick reference
| Goal | Admin does | Players get access via |
|---|---|---|
| Open/public game | createApp (keeps the free default tier) | Open‑by‑default on first connect |
| Invite‑only / paid | createApp, then remove the free default tier and define paid tiers | Explicit grantAppAccess per player (or after purchase) |
| Premium tier on an open game | createApp + createAccessTier (premium) | Free tier auto; premium via grantAppAccess |
See the GraphQL schema reference for exact inputs, and Connecting game clients for the endpoints.