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", status: LIVE, visibility: PUBLIC }) {
appId
}
}
createApp requires the manage_apps permission on the org (org owners have it by default). 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 player-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
Player code and owned grids.
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.
Admission at scale (P4a). Once the app has a marketplace catalog, the
moderation surface is appCodeAdmissionQueue(appId): every listing joined
with its allow-list standing (ADMITTED / PENDING / REVOKED) and which
subject matched (the listing, its author, or its owning org). The wholesale
pattern is admitting an org once (subjectKind: ORG) so every listing
that org owns — current and future — is admitted; per-listing admission
remains for precise control. De-admission drains running installs exactly
like a run-key revocation. For a hostile listing, pair the catalog kill
(setPlayerCodeListingStatus(..., status: KILLED)) with the game-side
fleet-wide runtime kill
(playerComputeSetSwitch(scope: "listing", listingRef: ...)).
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) Bound player compute cost and take a markup
Player compute bills the player's own wallet, never the org — see Player wallets & billing. Two knobs belong to the studio:
- Player policy (
setPlayerWasmPolicy,manage_compute): per-player or cohort clamps atapp_default/tier/grid/userscope, includingunitsPerHour/unitsPerDaycompute quotas,maxCompilesPerHour, and runtime budgets. Quotas protect world health independent of anyone's ability to pay. - 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.
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. You do not provision VMs, choose a datacenter, 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 } }
Revoke a player's access with revokeAppAccess(appId, userId). Granting and revoking both require manage_access_tiers.
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.
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 automations need. Enabling a player automation requires effective run_server_code, and so does invoking a server module export. A game whose damage, spawning or scoring runs as an automation will start, connect, replicate actors — and then quietly do nothing — if the tier its players land on omits that key. The symptom is not a permission error in the client; it is automations that never fire, game-model containers that never bind, and actor updates refused for actors nothing registered.
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 you use automations or player code 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.
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 the Dev tier page for integration‑testing endpoints.