Channels
Channels are app-wide message groups for your game. Unlike spatial messages (which reach nearby players), a channel message is delivered to every member of the channel, wherever they are — ideal for guild chat, party voice coordination, trade broadcasts, or app-wide announcements.
A channel is a kind of player group (group_type channel), so it
shares the same membership and role model as teams: a channel has members,
roles (a leader role is created automatically for the creator), and
per-role permissions. Two permissions are channel-specific: send_messages,
which controls who may publish messages to the channel, and send_voice, which
controls who may send audio to it. Receiving is available to
every active member.
All channel management runs over the Game API GraphQL endpoint; publishing and
receiving messages run over the realtime UDP path (the same udpNotifications
subscription used for spatial traffic). Every operation requires an app-scoped
token for this app (send Authorization: Bearer <token>; mint one with
mintAppToken — see Authentication).
Deciding who can create and join channels
Each app has a channel policy with two settings:
- creation policy — who may create channels:
admin(only app admins),member(any player with app access), oranyone. - default membership policy — how new channels accept members by default:
open(join instantly),request(join request awaits approval),invite(managers add members), oradmin(app admins only).
App admins set the policy with setChannelPolicy (requires manage_apps), and
read it with channelPolicy(appId):
mutation {
setChannelPolicy(input: {
appId: "1",
creationPolicy: "member",
defaultMembershipPolicy: "open",
maxMembers: 500, # optional cap on members per channel
maxGroupsPerUser: 20 # optional cap on channels a user may create/join
}) { creationPolicy defaultMembershipPolicy maxMembers maxGroupsPerUser }
}
maxMembers and maxGroupsPerUser are optional limits — omit them (or pass
null) for no cap.
Creating a channel
mutation {
createChannel(input: {
appId: "1",
name: "Global Trade",
membershipPolicy: "open", # optional; defaults to the app policy
membersCanSend: true, # optional; defaults to true
membersCanSpeak: false # optional; defaults to false
}) { groupId name membershipPolicy }
}
The creator becomes the owner with the system leader role (which holds
every channel permission, including send_messages).
membersCanSend controls the default for joiners:
true(open chat): a defaultmemberrole grantingsend_messagesis created and auto-assigned to everyone who joins, so all members can post.false(announce / read-only): joiners receive messages but cannot post. Grant a posting role explicitly to chosen members (see Roles below).
membersCanSpeak: true adds send_voice to that default role, so every member may
also send audio (a party or guild voice channel). It is off by default: only roles
you grant send_voice may speak.
Grid channels
A grid channel belongs to one grid. Its owner creates it, and the
CLIENT halves of that grid's mods may post into it with
emit_channel; code on any other grid may not.
mutation {
createGridChannel(input: { appId: "…", gridId: "…", name: "plot-42-chat" }) {
groupId
gridId
}
}
query {
gridChannels(appId: "…", gridId: "…") { groupId name }
}
- Only the grid's current owner may create one, whatever the app's channel creation policy says; a grid holds at most 8 active channels.
membershipPolicydefaults toopen, so visitors canjoinChannel, and members getsend_messagesunlessmembersCanSend: false.- A CLIENT half posts as an actor uuid the visitor's page derives for the grid and
the name the CLIENT half gives, never as the visitor: in CrowdyJS,
clientHalfActorUuid(gridId, name), the first 16 bytes of SHA-256 overcrowdy/client-half-actor/v1, the grid id and the name (each separated by a zero byte), as 32 hex characters. The server does not check that uuid, so treat it as a label for display, not as proof of which mod sent a message. - A grid-scoped token may join, leave and post only to its own grid's channels.
Joining and leaving
mutation { joinChannel(groupId: "12") { status } } # open -> active, request -> pending
mutation { requestToJoinChannel(groupId: "12") { status } }
mutation { leaveChannel(groupId: "12") }
myChannels(appId) lists the channels you belong to, with your roles and your
effective channel permissions. channels(appId) lists the active channels in an
app, and channel(groupId) fetches one.
Managing members and roles
Members whose role grants manage_members (plus app admins) can add and remove
members; adding also approves a pending request. Players can always remove
themselves.
mutation { addChannelMember(groupId: "12", userId: "42") { userId status } }
mutation { removeChannelMember(groupId: "12", userId: "42") }
A channel's roles carry these permission keys:
| Key | Allows |
|---|---|
send_messages | Publish messages to the channel |
send_voice | Send audio to the channel |
manage_group | Rename/delete the channel and edit its settings |
manage_members | Add and remove members |
manage_roles | Create/edit/delete roles and assign them |
invite_members | Invite players to the channel |
For an announce channel, create a posting role and assign it to your broadcasters:
mutation {
createChannelRole(input: {
groupId: "12", roleName: "broadcaster", permissions: ["send_messages"]
}) { groupRoleId roleName permissions }
}
mutation {
setChannelMemberRoles(input: {
groupId: "12", userId: "42", roleIds: ["88"]
}) { userId roles { roleName } }
}
Use channelRoles(groupId) / channelMembers(groupId) to list a channel's
roles and members, and updateChannelRole / deleteChannelRole to change them
(the system leader role can't be deleted). Holders of manage_group (and app
admins) rename or retire the channel itself with updateChannel(input) and
deleteChannel(groupId).
Sending and receiving messages
Publishing and delivery happen over the realtime UDP path. Browser clients use
the sendChannelMessage mutation; native clients send the
CHANNEL_MESSAGE_REQUEST UDP message directly (see the
Replication API wire formats). Like other
realtime sends, sendChannelMessage opens a UDP proxy session automatically for
your app-scoped token (the token you minted for this app — not the identity
session token login returns).
mutation {
sendChannelMessage(input: {
channelId: "12",
uuid: "0123456789abcdef0123456789abcdef", # your actor UUID (32 bytes)
payload: "aGVsbG8=", # base64, app-defined, <= 1024 bytes
sequenceNumber: 1
})
}
- The payload is opaque to the server — encode whatever your app needs (chat text, a compact binary event, etc.).
- The sender must be an active member holding
send_messages, and its access tier for the channel's app must grantuse_text_chat(every tier that grantsaccessdoes unless the app took it away; see Permissions); otherwise the message is rejected (aGenericErrorResponsewithUNAUTHORIZEDis returned onudpNotifications). - The sender does not receive an echo of its own message.
Every other active member receives a ChannelMessageNotification on the shared
udpNotifications subscription. As with every realtime subscription, open the
WebSocket with an appId in its connection_init payload — the session is
app-scoped (see the UDP Proxy API guide):
subscription {
udpNotifications {
__typename
... on ChannelMessageNotification {
channelId
uuid # the sender's actor UUID
payload # base64, exactly as sent
sequenceNumber
epochMillis
}
... on GenericErrorResponse { sequenceNumber errorCode }
}
}
Messages are ephemeral — the server routes them and does not store history. Keep your own log client-side if you need scrollback.
Limit delivery by distance
sendRangedChannelMessage publishes to a channel but delivers only to the members
near an origin chunk, such as a shout heard by party members in earshot or a squad
order for the members on the same front. A member receives it when one of its live
actors is in the origin's app and within maxDistance chunks of chunk, measured as
the straight-line distance between chunk coordinates with the boundary included.
mutation {
sendRangedChannelMessage(input: {
channelId: "12",
uuid: "0123456789abcdef0123456789abcdef", # your actor UUID (32 bytes)
payload: "aGVsbG8=", # base64, app-defined, <= 1024 bytes
appId: "42", # the app this token is for
chunk: { x: "10", y: "0", z: "-4" }, # the origin, usually the sender's chunk
maxDistance: 5, # chunks, inclusive
sequenceNumber: 2
})
}
- Distance is Euclidean, not the spatial rings. A member 3 chunks east and 4 north
of the origin is exactly 5 away and receives it with
maxDistance: 5; one 4 east and 4 north is about 5.66 away and does not, although the Chebyshev rings of spatial messages put both in ring 4.maxDistance: 0reaches only the origin chunk. Any distance up to 2147483647 is accepted; there is no cap of 8. - Members receive the ordinary
ChannelMessageNotification, so nothing changes on the receiving side. A member whose actors are all out of range, and a member with no live actor at all, receives nothing. The sender gets no echo. - The same right as
sendChannelMessage: an active member holdingsend_messages. The originappIdmust be the token's app. Refusals arrive onudpNotificationsas aGenericErrorResponsewith yoursequenceNumber:UNAUTHORIZEDwithout the send right,INVALID_APP_IDfor another app. - A grid token may send it into one of its grid's channels from a chunk inside its box, as for the other grid-scoped sends (see Grid tokens).
Native clients send the CHANNEL_MESSAGE_RANGED_REQUEST UDP message (type 32; see the
Replication API wire formats).
Voice on a channel
Audio packets can go to a channel too, so a party or a guild can talk wherever its members are (replication v0.37.0). It works like a channel message, with its own right:
- The sender must be an active member whose role grants
send_voice, and hold theuse_voice_chatpermission for the app (the same key spatial voice needs). A missing one is refused withUNAUTHORIZEDonudpNotifications. - Every other active member receives a
ChannelAudioNotification, wherever they are; the sender gets no echo. - The payload is opaque, at most 1,024 bytes per packet, and carries whatever your app's audio format needs (codec, sequence, timing). The SDKs offer an optional voice header and jitter buffer you can adopt.
mutation {
sendChannelAudio(input: {
channelId: "12",
uuid: "0123456789abcdef0123456789abcdef", # your actor UUID (32 bytes)
payload: "AQEAAAAAAAAUAA==", # base64 audio packet, <= 1024 bytes
sequenceNumber: 3
})
}
subscription {
udpNotifications {
__typename
... on ChannelAudioNotification { channelId uuid audioData sequenceNumber epochMillis }
}
}
A mutation per packet suits a test, not a conversation: send sustained voice over the
binary realtime relay or the UDP path, as CHANNEL_AUDIO_REQUEST (type 35; members
receive CHANNEL_AUDIO_NOTIFICATION, type 36; see the
Replication API wire formats).
Every member's downlink is billed. One speaker sending 50 packets a second of
100 bytes to a 20-member channel delivers about 19 × 50 × 100 bytes, roughly 95 KB a
second, as egress. Keep voice channels small, or use spatial voice
(sendAudioPacket) for proximity chat.
Reference
See the Game API GraphQL reference for the exact inputs and return types, and Permissions overview for how channel roles fit with app tiers and grid permissions.