Global Chat: a live channel in under 10 lines
Lobby Signals for chat that's scoped to a single match instead of your whole player base; Groups reuses this exact channel pattern for a group's own chat.
brainCloud's Chat service pushes messages to connected clients instead of requiring a poll. brainCloud RTT (the Real-Time Technology extension) carries that push: connect to a channel once, and every message posted after that, including the sender's own, arrives live over the same realtime connection. No socket plumbing to write, no re-fetch after posting. The reference implementation (CursorParty / RelayTestApp, ported identically across C++, JavaScript, and both Godot SDKs) is a "global lobby" chat tab; any of those clients can read from and post to it interchangeably, because they all talk to the same portal-registered channel over the same live connection.
How it works
- Enable RTT first. Chat service calls need the client's realtime connection (RTT) already up; every call otherwise fails with RTT_NOT_ENABLED. Enable it once, right after login, before touching chat at all.
- Register the channel in the portal before you touch it in code. The channel must already exist under App → Design → Messaging → Chat Channels. getChannelId(channelType, channelSubId, ...) resolves an id for it; it does not create one on the fly. An unregistered sub-id fails with CHAT_UNRECOGNIZED_CHANNEL (40603). channelType is "gl" for a global channel, "gr" for a group channel.
- Connect, don't fetch. channelConnect(channelId, maxReturn, ...) does two things in one call: it registers the client as a live listener on the channel, and it returns up to maxReturn recent messages as the initial history. Render that once. Every message after this point, from any member including the sender, arrives on its own via the RTT chat callback.
- Register once for the live push. registerRTTChatCallback delivers every new message the moment it's posted, tagged operation: "INCOMING". Append it to the message list. There is nothing else to poll.
- Sending needs no follow-up. Post with postChatMessageSimple and stop there. The message returns through the same RTT callback as everyone else's, so a successful post needs no success-path re-fetch.
- A channel doesn't have to be portal-registered if it's created dynamically instead. channelType: "dy" is a third option alongside "gl"/"gr", made with SysCreateChannel(channelType, channelSubId), which returns a real channelId on the spot instead of requiring a Chat Channels entry. That call is cloud-code/S2S only, same as the Global Properties writes in Global Properties: a client asks a script to create the channel, it never calls SysCreateChannel itself.
Code
// Resolve the channel, then connect: one call registers the live listener AND
// returns initial history.
pBCWrapper->getChatService()->getChannelId(
"gl", CHAT_CHANNEL_SUB_ID,
new BCCallback(
[](const Json::Value &result) {
std::string channelId = result["data"]["channelId"].asString();
pBCWrapper->getChatService()->channelConnect(
channelId, 30,
new BCCallback(
[channelId](const Json::Value &connectResult) {
s_chatChannelId = channelId;
for (const auto &m : connectResult["data"]["messages"])
s_chatMessages.push_back(parseChatMessage(m));
std::reverse(s_chatMessages.begin(), s_chatMessages.end());
}, nullptr));
}, nullptr));
// Registered once, alongside the lobby RTT callback, wherever RTT gets enabled
pBCWrapper->getRTTService()->registerRTTChatCallback(&bcRTTCallback);
// Dispatched from the app's central RTT callback when eventJson["service"] == "chat"
void chat_onRTTChatEvent(const Json::Value &eventJson)
{
if (eventJson["operation"].asString() != "INCOMING") return;
s_chatMessages.push_back(parseChatMessage(eventJson["data"]));
}
// Send: no re-fetch; the message arrives back via chat_onRTTChatEvent like anyone else's
pBCWrapper->getChatService()->postChatMessageSimple(s_chatChannelId.c_str(), s_chatInputBuf, true, nullptr);const CHAT_CHANNEL_TYPE = 'gl'
const CHAT_CHANNEL_SUB_ID = 'gl' // must be pre-registered in Portal > Messaging > Chat Channels
bcWrapper.chat.getChannelId(CHAT_CHANNEL_TYPE, CHAT_CHANNEL_SUB_ID, result => {
if (result.status === 200 && result.data.channelId) this.connectChannel(result.data.channelId)
})
connectChannel (channelId) {
this.props.bcWrapper.chat.channelConnect(channelId, 30, result => {
const messages = result.data.messages.slice().reverse().map(m => ({
fromName: (m.from && m.from.name) || 'Player',
text: (m.content && m.content.text) || ''
}))
this.props.bcWrapper.rttService.registerRTTChatCallback(this.onRTTChatEvent)
this.setState({ channelId, messages })
})
}
// Only "INCOMING" is handled
onRTTChatEvent (result) {
if (result.operation !== 'INCOMING') return
const m = result.data || {}
this.setState({ messages: this.state.messages.concat([{
fromName: (m.from && m.from.name) || 'Player',
text: (m.content && m.content.text) || ''
}]) })
}
// send: no re-fetch; delivered back via onRTTChatEvent
bcWrapper.chat.postChatMessageSimple(channelId, input, true, () => {})_bc.ChatService.GetChannelId(ChannelType, ChannelSubId, (jsonResponse, cbObject) => {
var channelId = /* parse data.channelId */ "";
ConnectChannel(channelId);
}, (status, reasonCode, jsonError, cbObject) => { _retryAtSec = _clock + RetryBackoffSec; });
void ConnectChannel(string channelId) {
_bc.ChatService.ChannelConnect(channelId, MaxHistory, (jsonResponse, cbObject) => {
_channelId = channelId;
// ...replay data.messages, oldest-first...
_bc.RTTService.RegisterRTTChatCallback(OnRttChatEvent);
}, (status, reasonCode, jsonError, cbObject) => { });
}
// Only "INCOMING" is handled
void OnRttChatEvent(string jsonResponse) {
var msg = /* deserialize */;
if (msg["operation"] as string != "INCOMING") return;
AddMessageRowFromWire(msg["data"] as Dictionary<string, object>);
}
// send: no re-fetch; delivered back via OnRttChatEvent
_bc.ChatService.PostChatMessageSimple(_channelId, text, true, (jsonResponse, cbObject) => { }, (status, reasonCode, jsonError, cbObject) => { });var result: Dictionary = await AppState.bc.chat_service.get_channel_id(_CHANNEL_TYPE, _CHANNEL_SUB_ID)
var channel_id: String = result.get("data", {}).get("channelId", "")
var connect_result: Dictionary = await AppState.bc.chat_service.channel_connect(channel_id, 30)
# connect_result.data.messages is the one-time initial history; everything after
# arrives via _on_rtt_chat_event, already chronological, so it's only ever appended.
AppState.bc.rtt_service.register_rtt_chat_callback(_on_rtt_chat_event)
# Live push for the global chat channel, delivered to every connected member including the sender
func _on_rtt_chat_event(msg: Dictionary) -> void:
if msg.get("operation", "") != "INCOMING":
return
var data: Dictionary = msg.get("data", {})
AppState.global_chat_history.append({
"from_name": data.get("from", {}).get("name", "Player"),
"text": data.get("content", {}).get("text", ""),
})
# send: no re-fetch; delivered back via _on_rtt_chat_event
await AppState.bc.chat_service.post_chat_message_simple(_channel_id, text, true)Gotchas worth knowing up front
- RTT_NOT_ENABLED is the most common first-call failure. Every reference app enables RTT immediately after login and gates the whole chat feature on it.
- The sender receives their own message back. channelConnect's push delivers every message to every connected member, including the sender. That is why there is no re-fetch after posting: the feature is built to work this way.
- channelConnect's initial history and the live push are framed differently but share the same fields. History arrives as an array under data.messages. Each live push is a single message under data, tagged operation: "INCOMING". The same "from name / content text" parsing applies to both.
- Register the sub-id in the portal before you reference it in code. A new chat surface (a trade channel, a guild channel) needs to be set up under Chat Channels first. getChannelId does not create it on the fly.
- Only one chat callback can be registered at a time per client. Registering a second one silently replaces the first rather than stacking. An app with more than one place that wants live chat should route them all through a single shared handler, as every reference app does, instead of registering per-screen.
- GetSubscribedChannels doesn't return dynamic channels. Track those yourself, the way bcchat does: page a Custom Entity collection (dynamicChannel) for the client-facing list (ChannelTableViewController.swift:47-52). The SysCreateChannel call that populates it isn't in this repo.