brainCloud · Code examples, cross-checked against the live API catalog · Aug 2026
Code Examples
29 articles on brainCloud's feature set, ordered simple to complex. It opens with the handful of calls every game needs on day one and ends with the multiplayer, account-architecture, and server-side patterns you reach for once the basics are solid. Every sample here is real, working source, pulled from the actual reference apps.
What this page is for. Getting a developer who is new to brainCloud, or new to one corner of it, to the example code that already solves their problem. Each article takes a single feature, explains it in about a page, and links line-for-line into the working app the code came from, so the step after reading is opening real source rather than a blank file.
It is not the API reference. The API Reference is the authority on every method, its parameters, and its response shape, and it's linked in the bar at the top of this page. This document sits in front of it, answering what a reference can't: which service to reach for, what the call looks like inside an app that shipped, and which mistakes are worth knowing about before you make them.
How to use it. Starting out, read from the top: Tier 1 is the handful of calls every app needs on day one. Later tiers lean on earlier ones in places, and each article names what it builds on. Looking for one specific thing instead, the map below lists every article against the brainCloud services it touches, and the rail on the left jumps straight to any article.
Setup & Initialize gets the app talking to brainCloud at all; login, progression, and private per-player storage are the three independent foundations built on top of it.
Tier 1Client Initialization01 / 29
Setup & Initialize: wire it into the client
Authentication is what runs right after this succeeds; every call in that article, and everywhere else in this series, assumes Init() already ran.
Nothing in this series works without two things happening first, in order: an app has to exist in the brainCloud portal (that's where an appId and secret come from), and the client has to be told about it, by name, before it makes a single other call. That second step is Init() (Unity/C#/C++) or initialize() (the equivalent on every other SDK): it configures the BrainCloudClient, points it at a server URL, and hands it the appId/secret/version that identify which app it's talking to. Authenticate, RunScript, every service call in this series, all assume Init() already succeeded; none of them work before it.
How it works
An app has to exist in the portal before any client code runs. Every environment (dev, prod) is its own app row in the brainCloud portal, each with its own appId and secret. Nothing else in this series is possible until one exists.
The client's Init() call is the hand-off: it has to run, successfully, before anything else. It takes a server URL, secret, appId, and version, builds the underlying BrainCloudClient, and is a hard prerequisite: CanReconnect(), AuthenticateAnonymous, AuthenticateEmailPassword, RunScript, every service call in every later article, all assume Init() already succeeded.
Every other SDK takes those same four values explicitly, by hand, copied out of the portal. The call and argument order differ per platform (see the Initialize entry in the apps list above for each SDK's actual definition): C++ takes (serverURL, secretKey, appId, appVersion), JavaScript takes (appId, secret, appVersion, serverUrl) instead, Unreal matches C++'s order, and Java, Dart, and GDScript each have their own ordering again; there's no portal integration on the client side for any of them.
Unity's Editor plugin removes the copy-paste step. Its OAuth login (PKCE, no manual API key pasted anywhere) authenticates the developer against the portal directly from inside the Unity Editor, then lets them fetch the list of apps on their team, create a new one, or create one from a template/example project, all without ever seeing a raw secret. The result is written into the project's BrainCloudSettings asset.
Unity's runtime Init() call takes zero arguments, and reads from that same settings asset. Instead of Init(url, secret, appId, version), a Unity project calls the parameterless Wrapper.Init(), which pulls all four values from BrainCloud.Plugin.Interface, the same object the Editor plugin populated. No credential ever needs to appear in source.
This has to be the first brainCloud call a Unity app makes. Marketplace calls it in its top-level manager's Awake(), before anything else, and checks Client.Initialized right after, the same pattern Authentication then builds a reconnect-or-login gate on top of.
Init() and Init(url, secretKey, appId, version) are not interchangeable inside the same Unity project. The zero-arg overload only has values to read once the Editor plugin has populated them; calling it before that's done points the client at nothing.
Tier 1AuthenticationIdentity02 / 29
Authentication: a real account in one call, an anonymous one in one fewer
Setup & Initialize is what runs right before this; every call below assumes Init() already succeeded. Player Stats & XP picks up immediately after login succeeds.
brainCloud separates a player's Profile from their Identity. The Profile holds everything the player has accumulated: currency, stats, entities, progress. An Identity is just one way of looking that Profile up, and a Profile can carry several: anonymous, email, or one of brainCloud's supported platform identities (Facebook, Google Play, GameCenter, Steam, and more). bitBuddies opens on an anonymous Identity, one call, no email, no password, and the Profile it creates carries forward untouched if the player later attaches a real email Identity to it. Reconnecting an existing session and creating a new one run through the same post-login code path; there's no separate "returning user" branch to maintain.
How it works
On launch, check for a reconnectable session first.BrainCloudWrapper.CanReconnect() looks for a cached session. If one exists, Wrapper.Reconnect(...) restores it and the login screen is skipped entirely. The client only falls through to login when that check fails.
An anonymous Identity is one call, no form.AuthenticateAnonymous(success, failure) creates a Profile with nothing typed and nothing validated client-side.
An email Identity uses the same call, plus one flag.AuthenticateEmailPassword(username, password, createAccount, success, failure). The createAccount flag is the only thing distinguishing a signup from a login; both hit the same endpoint.
Attaching a second Identity to an existing Profile is a different call.AuthenticateEmailPassword authenticates or creates a Profile from scratch, it does not attach anything. Adding an email Identity to the Profile that's already logged in is IdentityService.AttachEmailIdentity(email, password, success, failure). Switching an already-attached email requires DetachEmailIdentity first.
One success handler covers every path. Reconnect, anonymous login, and email login all resolve to the same post-login method, and it parses the same response regardless of which path produced it.
AttachEmailIdentity and AuthenticateEmailPassword do different things. One adds an Identity to the current Profile. The other authenticates or creates a Profile from scratch. Calling the wrong one to preserve a guest's progress creates a second, empty Profile instead of upgrading the existing one.
Tier 1GamificationPlayer Statistics03 / 29
Player Stats & XP: leveling with one native call, no script required
brainCloud's built-in gamification system tracks Player Statistics and XP without a cloud code script in front of every update. bitBuddies calls PlayerStatisticsService.IncrementExperiencePoints and .IncrementUserStats directly, and both return level-up detection and reward data already computed server-side. Player Statistics can still be driven from cloud code: bitBuddies' own fetchStats and ClaimQuestReward scripts call the same service through bridge.getPlayerStatisticsServiceProxy() elsewhere in this app. The choice is about the call, not the service. When a native call already returns everything a request needs (the increment, the level-up math, and the reward payload, all in one response), routing it through a script adds a hop with nothing to show for it. A script earns its place once extra server-side logic wraps the update, or several service calls would otherwise mean multiple client round-trips instead of one.
How it works
Read the level-up thresholds once, right after login.GamificationService.ReadXpLevelsMetaData returns the XP-per-level table configured in the portal. The client computes progress bars locally from this table, without guessing at breakpoints.
Award XP with one call. The server does the leveling math.PlayerStatisticsService.IncrementExperiencePoints(amount, success, failure) returns the player's new total XP, new level, and, if a level threshold was crossed, the reward payload for that level, all in one response. No second call is needed to find out what happened.
Generic stat increments follow the same pattern.IncrementUserStats(jsonStatData) takes a flat {statName: delta} map for anything that isn't XP: quest progress counters, achievement trackers, whatever the portal has configured as a player statistic.
The local, in-memory cache is a UI helper, not the source of truth.StatTracker is a plain Dictionary<string,int> that mirrors stat values for instant UI feedback (quest progress bars, unlock checks) between server round-trips. It never syncs itself back to brainCloud. A reward gate, a quest claim for instance, sends the current local value up as a parameter and lets the server validate it; the local cache is not authoritative.
Code
Reading the XP table after login (BrainCloudManager.cs:109, 137-150)
Wrapper.GamificationService.ReadXpLevelsMetaData(HandleSuccess("ReadXPLevelsData Success", OnReadXPLevelsData));
private void OnReadXPLevelsData(string jsonResponse)
{
var data = jsonResponse.Deserialize("data");
if (data.ContainsKey("xp_levels") &&
data["xp_levels"] is Dictionary<string, object>[] xp_levels &&
xp_levels != null && xp_levels.Length > 0)
{
CurrentUserInfo.UpdateLevelUpInfo(xp_levels);
}
else
{
throw new Exception("Did not receive XP Levels Data!");
}
}
private void OnLevelUpParent(string jsonResponse, object cbObject)
{
var data = jsonResponse.Deserialize();
var response = data.GetJSONObject("data") as Dictionary<string, object>;
if (response == null || response.Count == 0) return;
if (response.ContainsKey("experiencePoints"))
CurrentUserInfo.UpdateXP(response.GetValue<int>("experiencePoints"));
if (response.ContainsKey("experienceLevel"))
CurrentUserInfo.UpdateLevel(response.GetValue<int>("experienceLevel"));
// rewardDetails.xp.experienceLevels[] is only populated when a threshold was crossed
var experienceLevels = response.GetJSONObject("rewardDetails")?.GetJSONObject("xp")?.GetJSONArray("experienceLevels");
if (experienceLevels != null)
{
foreach (var xpLevel in experienceLevels)
{
if (xpLevel.GetValue<int>("level") != CurrentUserInfo.Level) continue;
var currency = xpLevel.GetJSONObject("rewards")?.GetJSONObject("currency");
if (currency == null) break;
int coins = currency.GetValue<int>("coins");
int gems = currency.GetValue<int>("gems");
CurrentUserInfo.UpdateCoins(CurrentUserInfo.Coins + coins);
CurrentUserInfo.UpdateGems(CurrentUserInfo.Gems + gems);
break;
}
}
}
private void OnClaimButton()
{
Dictionary<string, object> scriptData = new Dictionary<string, object>();
scriptData.Add("questName", _questInfo.QuestTitle);
scriptData.Add("questIndex", _questInfo.QuestLineIndex);
scriptData.Add("questScore", StatTracker.Instance.GetStat(_questInfo.QuestStatToTrack)); // local value, server re-validates it
BrainCloudManager.Client.ScriptService.RunScript(
BitBuddiesConsts.CLAIM_QUEST_SCRIPT_NAME,
scriptData.Serialize(),
BrainCloudManager.HandleSuccess("Claim Quest Success", _questPanel.OnClaimButtonSuccess));
}
Gotchas worth knowing up front
A script earns its round-trip the moment it's replacing more than one client call. bitBuddies' own fetchStats script reads PlayerStatisticsService for both a parent profile and a child profile in one request, switching identity mid-script between the two reads. The same sequence from the client would mean multiple sequential calls, with an identity switch in between, that a single script collapses into one round-trip. Player Statistics is scriptable either way; the break-even is whether a request is one call or several.
A missing XP-level portal config blows up hard.OnReadXPLevelsData throws a raw Exception if xp_levels comes back empty. This call assumes App → Design → Gamification → XP Levels is already configured, and it does not degrade gracefully if it isn't. Configure that before wiring this in.
StatTracker only holds client-side memory. It's populated by local increments (IncrementStat), never by reading anything back from brainCloud. A reinstall or reconnect on a new device starts this cache empty, even though the server-side stats are intact. Treat it as session progress so far, never lifetime total.
Two async calls can race when fired back-to-back without waiting. The loot-box open flow (MysteryBoxPanelUI.OnOpenBox) dispatches the lootbox script call and immediately calls ParentReceivesXP() without waiting on the first callback. Both are in flight at once. This works here because neither depends on the other's result; it is not safe to copy for two calls that do depend on each other.
Tier 1EntityGlobal EntityCustom Entity04 / 29
Entity Storage: three services, three scopes, one game
Persistent State: that article's four tiers are about which mechanism to use; this one shows what the CRUD calls actually look like for the three that are raw JSON entities rather than currency or lobby state.
brainCloud gives you four entity services for storing structured JSON, and unreal-bootcamp's NetworkManager.cpp exercises three of them in one file. Every entity, regardless of which service holds it, has the same underlying shape: a brainCloud-generated unique id, a developer-defined entityType string, JSON data, and version-based optimistic concurrency, an update only succeeds if the version you send matches the one currently on the server. Entities are created and edited entirely at runtime; that's what separates them from Global Properties, which are portal-authored config a client only ever reads. Choosing between the three comes down to one question: who needs to see this record?
User Entity is scoped to a single player. It's always owned by that player, with no unowned form at all, and it's the natural home for save-game data: level completion, inventory, anything that belongs to one account. It's what shows up per-player in the portal's Player Monitoring.
Global Entity is scoped to the whole app: shared data every client reads the same way. An ordinary global entity still has an owner, whichever account's client created it; clearing that owner turns it into a System Entity, brainCloud's name for app-owned data with nobody's personal account behind it. It's viewed and edited app-wide in the portal's Global Monitoring.
Custom Entity is also app-wide, but built for scale rather than a handful of records: it layers pagination, search, and sort into a single query, the shape to reach for once a Global Entity collection would otherwise keep growing without bound. It's defined as a collection in the portal's Custom Entities design section, and, unlike the other two, that collection can be configured owned or unowned depending on what it's used for.
A fourth option, Group Entity, extends the same idea to a group's members instead of one player or the whole app. It's real, but this reference app doesn't touch it; see Groups for the group side of this series.
Each entity also carries an ACL governing what other users may do with it, separate from who owns it. The exact access-control scale isn't the point here: what decides which service and which methods you reach for is the scope above, not the permission detail layered on top of it.
How it works
All three share one shape. A unique id, an entityType string, JSON data, and a version number that gates every update, so a write only lands if nothing else changed the record since you last read it.
User Entity is private by default, and this app makes that explicit.createEntity passes a locked-down ACL (EAcl::NONE), so nobody but the owning player can read or write it, which is the right default for save-game-shaped data like level completion flags.
Global Entity is looked up by a designer-assigned key, not queried. This app fetches with getListByIndexedId(indexedId, maxReturn, ...), a direct lookup by a key like "current level definitions", closer to a hash-table read than a search, and the client only ever reads it. Whether the record is owned by an account or is a fully app-owned System Entity doesn't change that lookup.
Custom Entity is the one built for scale and search.getEntityPage takes a pagination block, a search criteria block, and a sort criteria block, all as one JSON context. This app uses it to page through a "country leaderboard" custom-entity type sorted by data.score, descending, 10 rows at a time, app-level data rather than one record per player. Whether the collection is owned at all is a one-time schema decision (the collection's isOwned setting), not something flipped per call.
All of them follow the same create/read/update pattern, with different scoping arguments: entityType plus JSON data plus an ACL to write, and a lookup key appropriate to that service to read.
Pick the service by scope first. Player-only data goes to User Entity. Shared, admin-authored config at modest scale goes to Global Entity, usually as a System Entity so it belongs to the app rather than to a developer's account. Large or actively-growing collections go to Custom Entity. Using User Entity for something that needs cross-player querying, or Custom Entity for a handful of private per-player fields, fights the grain of each service's actual design.
Owning an entity ties its lifetime to the player. A User Entity, or an owned Global or Custom Entity, is deleted automatically if its owning user is deleted. That's exactly what you want for save data, and exactly what will quietly destroy shared or reference data, so an app-level record wants to be unowned even when a player's client is what created it.
getListByIndexedId is a direct key lookup, not a query. That's why Global Entity is fast, and also why it's built for smaller, largely static collections rather than ones that grow without bound.
Custom Entity is the one built for real querying. Search criteria, sort criteria, and pagination, on large or actively-growing collections, the case Global Entity's single indexed-key lookup wasn't built for. If a Global Entity collection keeps growing or needs filtering and sorting instead of one known key, that's the signal to move to Custom Entity, not a reason to keep stretching Global Entity past its design.
getEntityPage on an owned Custom Entity collection is scoped to the caller by default. It returns only the entities the calling player owns; another player's entity is simply absent, which reads as missing data rather than a permission boundary. Widening it to include entities other players own too is an explicit "options": {"ownedOnly": false} block in the query context.
Ownership is a client-facing boundary, and Cloud Code can step around it. Custom Entity's sys-prefixed methods (sysReadEntity, sysUpdateEntity, sysCreateEntity, and the rest) bypass the ownership and ACL checks the client-facing calls enforce, and are callable only from Cloud Code or S2S. If a rule has to hold no matter what the client sends, that boundary is where it goes, the same pattern as Cloud Code, the pattern.
Tier 2: Data & Economy
Server-authoritative
Currency, catalogs, the profile fields brainCloud already tracks, files, and portal-tunable config all need to be trusted, so most of it routes through the same server-side pattern instead of client-computed math.
Tier 2Script05 / 29
Cloud Code, the pattern: one client call, a fully customizable script behind it
Virtual Currency is the first article that actually depends on this one. This is the only article in the series that covers writing cloud code itself; for everything beyond the client call itself (API hooks, webhooks, external auth scripts, matchmaking filters, entity versioning, MongoDB query syntax, the API-count pricing model), brainCloud's own Cloud Code introduction and Writing Scripts reference are the authoritative source, not this series.
brainCloud doesn't trust the client with anything that touches the economy: currency awards, purchases, quest claims, and cross-account writes all need to happen somewhere the client can't tamper with the outcome. Cloud code is that trusted place. The client asks for an outcome; a script running on brainCloud's own servers decides whether it happens and by how much. Every one of those requests can go through the same client call, ScriptService.RunScript(scriptName, jsonParams, success, failure). What makes it powerful is what's allowed to run behind it: a script can read and write any service the platform exposes, branch on whatever logic the game needs, and be customized as far as the game's own rules require. This article walks through that call and shows what a real script looks like on the other end, so the next three articles can refer to "a RunScript call" without re-explaining it each time.
How it works
The client-side call never changes. Every cloud-code call in this codebase (award currency, claim an item, switch to a child profile) uses the same three arguments: a script name, a small params object serialized to JSON, and success/failure callbacks. What differs every time is the script behind that name, not the call itself.
A script is a main() function with an implicit data global, run on Mozilla Rhino, not Node.js. The script's input arrives as an ambient data object; its return value becomes the client's response. main() must run, unconditionally, as the last line of the file. Rhino supports most modern syntax but not all of it (no class, async/await, or import/export); the full Writing Scripts reference has the exact coverage.
Inside a script, bridge.getXxxServiceProxy() gives server-side access to the same services the client SDK exposes. These are in-process calls, not requests that cross the wire, which is also why they take plain JS objects instead of JSON strings.
Failures return a 200 with an error field, not an exception. Every script checks .status !== 200 on each service call it makes and builds its own {error, message} response instead of throwing. The client's transport call still succeeds; the payload is what indicates whether the operation itself worked.
RunScript is one of several ways a script gets invoked, not the only one. The same script format also runs from another script (bridge.callScript), a scheduled job, an S2S call, a webhook, an API hook that intercepts a built-in operation, or a matchmaking filter. This article only covers the client-invoked case; the Writing Scripts reference covers the rest.
Why write cloud code at all
Trust. Anything that shouldn't be decided by the client, an award, a debit, a claim, runs somewhere the client can't influence the outcome.
Fewer round trips. A script can chain several service calls into one request instead of the client making them one at a time.
Change behavior without shipping a client update. The logic lives on brainCloud's servers, so tuning a reward or fixing a bug doesn't require an app-store review cycle.
Triggers a client can't reach directly. Scheduled jobs, webhooks, and API hooks all run the same script format, for cases where nothing is waiting on a callback at all.
Where to actually write these scripts
Two real tools exist for this beyond the portal's own script editor. The brainCloud MCP server lets an AI assistant read the actual Cloud Code guide, look up a service method's exact signature before using it, then create, update, and run a script directly against a real app. That's the same workflow used to research and verify this entire series.
The brainCloud Cloud Code FS VS Code extension does the editor-side equivalent: it lists your brainCloud apps as folders in the file explorer, gives real create/edit/delete/rename on scripts with service code completion and validation, and syncs changes both ways between your local files and the portal, including a lock indicator so a Live app doesn't get edited by accident. It also works fully offline, editing and saving local .ccjs files with no connection at all, syncing on your terms once you're back online, rather than requiring a live link to the portal to get anything done. It goes further than plain offline editing, too: it maps git branches to specific brainCloud apps (develop to a Dev app, main to Production, for example), stored in a shareable .bcsync file committed alongside the code. Switching branches switches which app you're synced against automatically, which is what makes it practical to keep a dev app and a prod app's script source in the same repository, in sync, without hand-tracking which copy of a script belongs to which environment. None of this is specific to one team's setup or one kind of project; the extension is a generic brainCloud tool, and it's a real quality-of-life upgrade over the portal's own editor for day-to-day cloud-code work regardless of what you're building.
Beyond the client call: webhooks and scheduled scripts
RunScript is the client-invoked entry point, but the same main()/data script format also runs from triggers no client ever touches. A webhook lets an external system, a payment processor, a support tool, anything able to POST JSON, invoke a script directly over HTTP, without going through the brainCloud SDK at all; the incoming payload lands in the script's data exactly like a RunScript call would populate it. The Webhooks reference covers the endpoint shape and setup. A scheduled script runs on a timer the portal manages instead of in response to any call, suited to anything that should happen on a cadence rather than on demand: a nightly leaderboard reset, a subscription-expiry sweep, cleaning up stale data. Both are configured in the portal, not in client code, which is why neither shows up in any of this series' reference apps; the Writing Scripts reference covers the full setup for both.
Code
The client call: same three arguments everywhere (SDK source, BrainCloudScript.cs)
"use strict";
function main() {
var response = {};
var currencyService = bridge.getVirtualCurrencyServiceProxy();
// get user level to award amount equal to user level
var gamificationService = bridge.getGamificationServiceProxy();
var playerStateService = bridge.getPlayerStateServiceProxy();
var userGamificationData = gamificationService.readAllGamification(false);
var CoinsMultiplierStatus = playerStateService.getUserStatus("coin_multiplier");
var multiply = !isEmptyObject(CoinsMultiplierStatus.data);
if (userGamificationData.status !== 200) {
response.status = userGamificationData.status;
response.error = "Failed to read user gamification data";
return response;
}
if (userGamificationData.data && userGamificationData.data.xp) {
var userLevel = userGamificationData.data.xp.experienceLevel;
if (multiply) userLevel *= 2;
var result = currencyService.awardCurrency("Coins", userLevel);
if (result.status == 200) {
response = result.data.currencyMap.Coins.balance;
}
}
return response;
}
main();
// brainCloud never marks a promotion as redeemed for items bought with virtual// currency (only cash purchases verified through VerifyPurchase do that), so// FetchStoreItems.ccjs would keep applying a one-time promo price forever. Record// that this defId has now been bought at least once so it can strip the promo.
var attrKey = "promoRedeemed_" + data.defId;
var attrs = {};
attrs[attrKey] = "true";
playerStateService.updateAttributes(attrs, false);
Gotchas worth knowing up front
Client failure handling only tells you the transport failed. A RunScript failure callback fires for actual HTTP/auth failures. A script that runs fine but decides the operation shouldn't succeed (insufficient funds, item already owned) still returns 200 to the client. The success handler needs to check the response body's own error/success field; the callback firing does not mean the operation worked.
main() is called unconditionally at the bottom of the file. There is no export or entry-point wiring. The script runs top-to-bottom, and the trailing main(); is what actually executes it. Omitting that line, or leaving it inside a conditional, ships a script that silently does nothing.
Writing a script like it's Node.js is the fastest way to ship one that won't save. Rhino doesn't have class, async/await, Promise, import/export, or a spread operator inside a function call (foo(...args), though [...arr] in an array literal is fine). A developer used to modern Node or browser JS should assume none of that works here until checked, rather than finding out from a save error.
The attrs object in BuyItem.ccjs above is never JSON.stringify()-ed before it's passed to updateAttributes, and that's not a style choice. Service proxy calls inside a script are in-process, not a request over the wire, so a native JS object is expected. Stringifying one of these parameters first does not throw; it fails, mis-stores, or silently mis-routes, a much worse debugging session than a clear error.
Tier 2Virtual CurrencyProductScript06 / 29
Virtual Currency: a four-wallet economy, three response formats to watch for
Cloud Code, the pattern for the RunScript call every custom script in this article is invoked through.
brainCloud supports Virtual Currencies: an app can configure an unlimited number of them. bitBuddies runs four: Coins, Gems, and FakeDollars scoped to the parent account, plus a per-buddy BuddyBling scoped to each child account. Every award, consume, and cross-currency exchange goes through a custom Cloud Code script, invoked with the same RunScript call from Article 5. Balances are not tracked client-side between calls. The client reads whatever balance the script response hands back and displays it.
How it works
Initial balances ride in on the login response itself. A separate currency-fetch call at startup is not needed. data.currency.{gems,coins,fakeDollars}.balance is already in the auth payload.
A child's currency (BuddyBling) is fetched separately, per child. It is not in the parent auth payload. It comes back from a dedicated child/fetchCurrencies script call scoped to that one child profile.
Award and consume are separate named scripts, not one generic call.AwardCoinsToUser, AwardGemsToUser, and ConsumeCoinsForUser are three different custom Cloud Code scripts, each invoked through RunScript with its own script-name constant. There is no single "adjustBalance(currency, delta)" entry point; what changes between these calls is which script runs, not how it's called.
Every response nests the new balance somewhere slightly different. Three currency scripts return three differently structured responses; read each one carefully before copying the pattern.
var currency = data["currency"] as Dictionary<string, object>;
if (currency != null)
{
var gems = currency["gems"] as Dictionary<string, object>;
CurrentUserInfo.UpdateGems((int)gems["balance"]);
var coins = currency["coins"] as Dictionary<string, object>;
CurrentUserInfo.UpdateCoins((int)coins["balance"]);
var fakeMoney = currency["fakeDollars"] as Dictionary<string, object>;
CurrentUserInfo.UpdateFakeMoney((int)fakeMoney["balance"]);
}
public void OnConsumeCoins(string jsonResponse)
{
var packet = JsonReader.Deserialize<Dictionary<string, object>>(jsonResponse);
var response = ((packet["data"] as Dictionary<string, object>)["response"] as Dictionary<string, object>);
var result = response["consumeCurrencyResult"] as Dictionary<string, object>; // <- "consumeCurrencyResult", not "getResult"
var currencyMap = (result["data"] as Dictionary<string, object>)["currencyMap"] as Dictionary<string, object>;
var coins = currencyMap["coins"] as Dictionary<string, object>;
CurrentUserInfo.UpdateCoins((int)coins["balance"]);
}
public void AwardBlingToChild(int in_amount)
{
// Params for AwardBlingToChild(childAppId, profileId, increaseAmount)
var scriptData = new Dictionary<string, object>
{
{ "childAppId", BitBuddiesConsts.APP_CHILD_ID },
{ "profileId", GameManager.Instance.SelectedAppChildrenInfo.profileId },
{ "increaseAmount", in_amount }
};
// response.currencyMap.buddyBling.balance: no getResult/consumeCurrencyResult wrapper at all
}
What a coin-award script looks like on the server (different app: Marketplace/CloudCode/AwardUserCoins.ccjs)
var currencyService = bridge.getVirtualCurrencyServiceProxy();
var result = currencyService.awardCurrency("Coins", userLevel);
if (result.status == 200) {
response = result.data.currencyMap.Coins.balance;
}
Gotchas worth knowing up front
Three award/consume scripts, three differently structured responses.response.getResult.data.currencyMap… (award), response.consumeCurrencyResult.data.currencyMap… (consume), and a flat response.currencyMap… (child award) all mean the same thing: the new balance. A parsing helper written against one of these will silently break on the others. A currency-response parser should not be generalized from a single example.
BuddyBling isn't in the login payload. If a child's currency reads zero on first render, check whether child/fetchCurrencies has actually resolved yet. It's a separate round-trip from auth, not bundled with it.
The script bodies that produce these bitBuddies responses aren't in this repo. Every call site and response above is read straight from bitBuddies' real client C#, so that part is fully verified. What actually runs inside AwardCoinsToUser/ConsumeCoinsForUser on the server is documented only in bitBuddies' README, in prose. That's a credible description, not confirmed source.
The AwardUserCoins.ccjs stand-in above is probably the wrong service, even though it's the right pattern. brainCloud's API catalog has a Product service with AwardParentCurrency/ConsumeParentCurrency/AwardPeerCurrency, and "parent/peer currency" is brainCloud's own term for exactly bitBuddies' parent-child app architecture (see Parent–Child Accounts). That's a closer conceptual match for AwardCoinsToUser/AwardBlingToChild than the generic Virtual Currency service the stand-in script uses. This remains unconfirmed, since bitBuddies' script bodies aren't in this repo either way. An equivalent parent/child currency flow should check Product's parent/peer methods before reaching for generic Virtual Currency.
Tier 2EntityLeaderboardGlobal AppScript07 / 29
Persistent State: one app's four tiers, cheapest one that fits
Join-in-Progress is what fills the gap for state that lives only in tier 1; Leaderboards is the deep dive on tier 4; Entity Storage covers the entity services this app never reaches for, and which a different app's ladder would be built on.
State doesn't all need the same durability, and paying for more durability than a piece of data actually needs costs round-trips. This article walks the four tiers CursorParty uses, cheapest first, and why each piece of its state sits where it does.
These are this app's tiers, not brainCloud's. They're an inventory of what one realtime multiplayer app reached for, not a map of brainCloud storage. A turn-based game, an economy, or anything with a large queryable dataset would draw a different ladder from the same platform, most obviously by using the entity services this app never touches (see Entity Storage). What transfers is the habit rather than the list: choose per piece of data, and choose the cheapest tier that still meets the durability that data actually needs. Skipping that choice leads to two common mistakes, paying for server round-trips on state nobody outside the current match cares about, and assuming state survived when it was only ever held in one client's memory.
The four tiers, as this app uses them
Ordered by durability, cheapest first. The tier numbers are this article's shorthand for discussing one app's choices, not brainCloud terminology.
Ephemeral, client-memory-only, relay-transported. The CursorParty splotch canvas (every paint splotch on screen) is never written to any brainCloud service. Every connected client, not just the host, appends to its own local splotch array as relay events arrive. The host's copy isn't authoritative; it's just the one used to resync a late joiner (see the Join-in-Progress article). A host disconnecting mid-match doesn't wipe the canvas or end the match: brainCloud's relay server auto-migrates ownership (MIGRATE_OWNER), and surviving clients, including the newly migrated host, still hold their own full copy. The canvas is lost only if every member leaves the match, and nothing needed it to survive past that point.
Lobby-scoped extra data, genuinely persisted, but only for the lobby's lifetime. Anything passed in extra on updateReady/joinLobby/findOrCreateLobby* (color pick, region ping table, "present since start") is held server-side and round-trips through every subsequent lobby event to every member. This tier fits data every match member needs to see about each other, data that doesn't need to outlive the match.
Portal-configured global properties: read-only config, not per-player state.AllLobbyTypes, the color palette, and splotch duration/lifespan are admin-managed values, fetched once via the Global App service (readProperties/readSelectedProperties). They're persistent in the sense that they don't live in the build, but they're config, not player data, and no client ever writes to them. This is brainCloud's Remote Config mechanism, and it's built for values that change occasionally: a designer retuning splotch duration between releases, not a number moving on a gameplay cadence. A write refreshes an app-wide cache on every API server, which is cheap when it happens rarely and wasteful when it doesn't. Shared state that changes rapidly belongs in Global/System Entities or unowned Custom Entities instead: ordinary documents you can rewrite as often as you like, and query and sort besides. See Global Properties for the read path and its parsing traps, and Entity Storage for both alternatives.
True durable, cross-session persistence: leaderboards and player profile. This is the tier that needs a write API, and the one place a naive implementation causes real bugs. The reference apps write leaderboard results through a Cloud Code script instead of a direct client-side leaderboard post, because the cumulative points leaderboard would double-count if more than one client posted for the same match. Player identity fields, like a chosen display name, go through the Player State service directly, since there's no cross-client duplication risk there.
Code
Tier 2: lobby extra (C++)
// colorIndex/pings round-trip through every lobby event for every member
pBCWrapper->getLobbyService()->updateReady(lobbyId, isReady, buildExtraJson(), callback);
// buildExtraJson() => {"colorIndex":N,"rank":N,"pings":{"us-east":42,...}}
Tier 2: lobby extra (JavaScript)
makeExtraJson (colorIndex, presentSinceStart) {
const extra = { colorIndex, presentSinceStart: !!presentSinceStart }
if (Object.keys(this.state.pingData || {}).length > 0) extra.pings = this.state.pingData
return extra
}
this.bc.lobby.updateReady(this.state.lobby.lobbyId, this.state.user.isReady, extraJson)
Tier 3: portal config (Godot GDScript)
var response: Dictionary = await AppState.bc.global_app_service.read_selected_properties(
["Colours", "AllLobbyTypes", "SplotchDuration"]
)
Tier 4: leaderboard write via Cloud Code, host-only (C++)
// host-only, once per round: a duplicate post from a second client would// permanently inflate the cumulative points leaderboard
pBCWrapper->getScriptService()->runScript("PostMatchResults", payloadStr,
new BCCallback(
[round](const Json::Value &result) { applyLeaderboardResultsFromCloud(round, result["data"]["results"]); },
[round](const std::string &msg) { /* log */ }));
Tier 4: leaderboard write via Cloud Code (JavaScript)
Tier 4: own rank, shared to peers via tier 2 (any SDK)
getGlobalLeaderboardView(leaderboardId, ...) has no "look up another player's rank" mode.
Each player fetches their own rank, then shares it with the lobby the same way as colorIndex:
by putting it in their own `extra` JSON.
Gotchas worth knowing up front
These four tiers are one app's ladder, not a platform taxonomy. They're what CursorParty happens to use. Read them as a worked example of choosing per piece of data, not as the set of options brainCloud offers; the entity services alone would add several more rungs.
Relay is transport, not storage. State that lives only in a client's memory is gone when the match ends. That's tier 1: a legitimate design choice when the state doesn't need to survive, not an oversight.
Global Properties are Remote Config, not a shared variable store. They're built for values read constantly and written occasionally, and each write invalidates an app-wide cache across every API server. If a value changes on a gameplay cadence rather than a release cadence, that's a Global/System Entity or an unowned Custom Entity, both designed to be written often. Note that tier 3 is really about who writes a value and how often rather than how long it survives: a global property is every bit as durable as tier 4.
Route match-result posting through Cloud Code, host-only, never a direct client write. A duplicate leaderboard post from more than one client silently corrupts cumulative totals. postScoreToLeaderboardOnBehalfOf isn't reachable from the client SDK at all, only from Cloud Code, which is what enforces this.
There's no "look up any player's leaderboard rank" call. If the UI needs to show every lobby member's rank, each client fetches its own and shares it via extra (tier 2), the same mechanism used for color and ping.
Tier 2Player State08 / 29
Player State & User Data: the profile fields brainCloud already tracks, plus a free-form attribute bag for the rest
Authentication is usually what triggers these calls, right after login or when a player attaches a real identity. Entity Storage is the article to reach for once your data outgrows a flat key-value bag.
Every brainCloud user has a small set of built-in profile fields: a name, a contact email, a picture URL, a language and country preference, a tester flag. None of that needs modeling. Player State reads and writes those fields directly: no entity, no cloud-code script, no schema to design. bitBuddies, Marketplace, and bcchat all call the same handful of Player State methods for this, right after login or whenever a player edits their profile.
Player State has a second job: UpdateAttributes and GetAttributes give every user a flat, free-form key-value bag, no ACL, no entity type, no query, just a single JSON object of string values attached directly to the account. Marketplace uses it to persist a couple of timestamps for its mock in-app-purchase subscriptions. It's the right tool for a handful of small settings, not the place to reach once data grows past a few fields; that's what Entity Storage (Article 4) is for.
How it works
UpdateUserName and UpdateContactEmail write the two most common profile fields. bitBuddies calls both right after login, syncing brainCloud's copy with whatever it has locally, and bcchat calls updateUserName straight from its settings screen.
UpdateUserPictureUrl points the profile picture field at wherever the image is actually stored. bcchat calls it right after its file-upload flow (Article 9) hands back a downloadUrl; the two features chain together directly.
UpdateAttributes takes a single-layer JSON object where every value is a string, plus a wipeExisting flag that clears everything else first instead of merging. Marketplace passes false here because it's adding a couple of keys to an existing bag, not replacing it.
GetAttributes reads that same bag back, and Marketplace uses it on cold start to check whether a mock subscription's renewal timing survived from a previous session, since there's nothing else on the client holding that state at launch.
Attributes and Entity storage solve different problems. Attributes are a flat bag of strings scoped to the account itself, useful for the kind of small setting that would otherwise end up in local storage. Entity storage is structured, typed JSON with its own ACL and query model. Attributes fit a handful of fields; Entity storage fits anything with more structure than a flat string map.
var username = data["playerName"] as string;
if (username.IsNullOrEmpty() && !CurrentUserInfo.Username.IsNullOrEmpty())
{
Wrapper.PlayerStateService.UpdateUserName(CurrentUserInfo.Username);
}
else if (!username.IsNullOrEmpty())
{
CurrentUserInfo.UpdateUsername(username);
}
var email = data["emailAddress"] as string;
if (email.IsNullOrEmpty() && !CurrentUserInfo.Email.IsNullOrEmpty())
{
Wrapper.PlayerStateService.UpdateContactEmail(CurrentUserInfo.Email);
IsEmailAuthenticated = true;
}
A settings-screen name change, in JavaScript this timeApp.js:197-213
handleName(name)
{
let fullName = name.firstname + " " + name.lastname;
this.bcWrapper.playerState.updateUserName(fullName, result =>
{
if (result.status === 200)
{
this.onLoggedIn();
}
else
{
this.dieWithMessage("Failed to update username to brainCloud");
}
});
}
The profile picture URL, set right after a file upload finishesApp.js:269-276
this.bcWrapper.playerState.updateUserPictureUrl(file.downloadUrl, result =>
{
if (result.status !== 200)
{
this.dieWithMessage("Failed to updateUserPictureUrl");
return;
}
// ...
});
BCManager.Instance.BCWrapper.PlayerStateService.GetAttributes(
(string attrJson, object _) =>
{
var attrData = (JsonReader.Deserialize<Dictionary<string, object>>(attrJson)["data"]
as Dictionary<string, object>)["attributes"] as Dictionary<string, object>;
// pull mockSubStart_/mockSubAutoRenew_/mockSubFinalExpiry_ keys back out
},
(int _, int __, string err, object ___) => { });
Gotchas worth knowing up front
Attribute values are strings, not arbitrary JSON. There's no numeric or boolean type on the wire; Marketplace serializes numbers and booleans to strings before writing (entry.start.ToString(), "true") and parses them back out on read. Build that conversion in from the start, not after a value comes back the wrong type.
Pass wipeExisting: false unless the intent is to erase everything.true clears every attribute the user has before writing the new ones, not just the keys being touched. Marketplace always passes false because it's incrementally adding keys; flipping that by accident silently wipes unrelated settings some other feature stored there.
This is one shared bag per user, not namespaced per feature. Marketplace prefixes its keys (mockSubStart_, mockSubAutoRenew_) because Attributes has no built-in separation between what different systems might store there. Adopt a prefix convention early when more than one feature builds on Attributes.
Attributes stop being the right tool once data has real structure. A handful of named settings fits fine here. The moment a feature needs nested objects, arrays, querying, or per-field ACLs, Entity storage (Article 4) is built for that instead of stretching Attributes to cover it.
Tier 2File09 / 29
File Upload: a two-step handshake, then a raw upload
Persistent State: files are effectively a fifth tier: durable like leaderboards/profile, but for binary data instead of JSON.
Uploading a file to a player's brainCloud account is a two-step handshake, not one call. prepareUserUpload tells brainCloud what's coming (path, name, size, sharing/overwrite flags) and returns an uploadId. uploadFile then performs the actual transfer against that id. The FileUploader JS example (repositories/js-examples/FileUploader) exercises this end to end, including the memory-only variant for uploading generated content, like a screenshot, without ever touching the local filesystem.
How it works
Prepare first, upload second.prepareUserUpload(cloudPath, cloudFileName, isShareable, replaceIfExists, fileSize, callback) returns data.fileDetails.uploadId. Nothing is transferred yet; brainCloud just reserves the slot.
The actual transfer is a separate call using that id.uploadFile(xhr, file, uploadId) takes a raw XMLHttpRequest already wired with progress/complete/error/abort listeners, plus the browser File object and the uploadId from step 1.
Generated content skips the file picker entirely.uploadFileFromMemory(cloudPath, cloudFileName, isShareable, replaceIfExists, blob, callback) uploads a Blob directly. There's no prepareUserUpload handshake for this path, and no local file ever exists.
Progress is tracked through the supplied XMLHttpRequest, not a brainCloud callback. xhr.upload.addEventListener("progress", ...) is standard browser API, wired before the upload call.
prepareUserUpload and uploadFile are two different calls with two different jobs. The first reserves the slot and returns an id. The second does the byte transfer. Calling uploadFile without a fresh uploadId from prepareUserUpload leaves nothing to upload against.
This method name was a real, live bug in the reference app until it was fixed. The example was calling prepareFileUpload, a method that doesn't exist on the File service, instead of prepareUserUpload. It's fixed now (js-examples@a0f3e56), which is also what makes this article's citations trustworthy: the code shown above is the corrected, confirmed-working version, not the version that was silently broken.
Progress tracking is the XMLHttpRequest's job, not the SDK's. brainCloud hands back the transport primitive. Wiring progress/load/error/abort listeners is standard browser code, not a brainCloud callback pattern.
uploadFileFromMemory is the right tool for generated content (screenshots, exported save data). It skips the two-step handshake entirely and uploads a Blob directly. No local file required.
Tier 2App StoreUser ItemsScript10 / 29
Item Catalog & Store: two valid ways to sell something
Virtual Currency for the wallets these purchases spend from; Cloud Code, the pattern for the RunScript call bitBuddies uses here to invoke its purchase script.
The two reference apps in this series sell items two genuinely different ways, and both are correct; the choice depends on what's being sold. bitBuddies' shops sell items priced in virtual currency (Coins, Gems, BuddyBling), so the whole purchase (afford check, debit, grant) happens server-side inside a single custom Cloud Code script, triggered by one RunScript call. Marketplace sells items for real money through the platform's native store (Apple/Google/Steam), so the client talks to AppStoreService and Unity IAP directly. brainCloud's role narrows to catalog lookup plus receipt verification after the platform handles payment, since brainCloud itself never touches the money.
How it works
Fetch the catalog with one script call.GetChildItemCatalog / GetParentShopCatalog return items already split by category. There's no separate "is this in stock" or "what does this cost" round-trip.
Buying is one RunScript call with just an item id. The server checks affordability, debits the currency, and grants the item. The client never computes or asserts the price itself.
The response tells the client what to display, not what to trust. It carries the payout and new balance for UI purposes. The actual debit already happened server-side by the time the response arrives. ### Real-money purchase (Marketplace)
Fetch products from brainCloud, then hand them to the platform SDK.AppStoreService.GetSalesInventory returns brainCloud's catalog, which is used to build Unity IAP's ConfigurationBuilder. brainCloud defines what's for sale; the platform store handles paying for it.
Cache purchase context before initiating the platform purchase.CachePurchasePayloadContext tells brainCloud what's about to be bought, then controller.InitiatePurchase hands off to the real App Store / Play Store / Steam flow.
Verification runs after the platform confirms payment, not before. brainCloud never sees money move, only the platform's signed receipt afterward.
A mock-store path exists for testing without real payment, and it's the one place this app's flow does go through cloud code. Receipt verification for the mock store runs as a script.
Promotional pricing is computed server-side, before the client ever sees the catalog. Marketplace's FetchStoreItems script calls AppStoreService's getEligiblePromotions, builds a lookup from item id to promotional price, and overwrites buyPrice on any catalog item that has one, marking it with isPromotion. The client never asks whether an item is on sale; it reads whichever price the script already decided is the real one.
A brand-new account forces a promotion refresh before the first catalog fetch.SetupUserAccount calls AppStoreService's refreshPromotions explicitly, because promotion eligibility is normally recalculated during authentication, and that recalculation hasn't caught up yet for a profile that's mid-creation. Skipping that call can leave a brand-new player's first store screen showing stale, non-promotional pricing.
public static void PurchaseProduct(BCProduct product, Action<BCProduct[]> onPurchaseFinished = null)
{
InternalSetCallback(onPurchaseFinished);
string id = product.GetProductID(), payload = product.payload;
var iapProduct = controller.products.WithID(id);
if (iapProduct != null && iapProduct.availableToPurchase)
{
void onCacheSuccess(string jsonResponse, object cbObject)
{
controller.InitiatePurchase(iapProduct); // hands off to the real platform store
}
bc.AppStoreService.CachePurchasePayloadContext(APP_STORE, id, payload, onCacheSuccess,
OnBrainCloudFailure("Unable to cache the purchase payload context on brainCloud!",
() => InternalInvokeCallback(null)));
}
}
Marketplace: the one place this app does use cloud codeVerifyPurchaseMockStore.ccjs
"use strict";
function main() {
var response = {};
if (data && data.storeId == "mock" && data.receiptData) {
var postResults = bridge.getAppStoreServiceProxy().verifyPurchase(data.storeId, data.receiptData);
response.data = postResults;
response.success = postResults.status == 200;
if (!response.success) response.errorMessage = "VerifyPurchase was not a success.";
} else {
response.success = false;
response.errorMessage = "Client did not receive the proper data for a mock VerifyPurchase.";
}
return response;
}
main();
var appStoreProxy = bridge.getAppStoreServiceProxy();
var promotions = [];
var promoResult = appStoreProxy.getEligiblePromotions();
if (promoResult.status === 200) {
promotions = promoResult.data.promotions || [];
}
// Build a lookup map: defId -> promotional buyPrice
var promoPriceMap = {};
for (var p = 0; p < promotions.length; p++) {
var promo = promotions[p];
if (promo.items) {
var promoDefIds = Object.keys(promo.items);
for (var d = 0; d < promoDefIds.length; d++) {
var defId = promoDefIds[d];
var promoItem = promo.items[defId];
if (promoItem.buyPrice) promoPriceMap[defId] = promoItem.buyPrice;
}
}
}
// Apply promotional pricing to catalog items
for (var j = 0; j < allCatalogItems.length; j++) {
var catalogItem = allCatalogItems[j];
var originalBuyPrice = JSON.parse(JSON.stringify(catalogItem.buyPrice));
if (promoPriceMap[catalogItem.defId]) {
var promoBuyPrice = JSON.parse(JSON.stringify(promoPriceMap[catalogItem.defId]));
promoBuyPrice.isPromotion = true;
catalogItem.buyPrice = promoBuyPrice;
catalogItem.defaultBuyPrice = originalBuyPrice;
} else {
catalogItem.buyPrice.isPromotion = false;
catalogItem.defaultBuyPrice = originalBuyPrice;
}
}
Forcing a promotion refresh right after account creationSetupUserAccount
// Promotion eligibility is a snapshot that's normally recalculated during// authentication, but for a brand-new account that recalculation hasn't caught up// yet by the time this script runs mid-auth. Force it now so the very first// FetchStoreItems call after account creation already shows correct promo pricing,// instead of requiring the user to relaunch the app.
bridge.getAppStoreServiceProxy().refreshPromotions();
Pick the pattern by what the item costs, not by habit. Virtual-currency items belong in a custom Cloud Code script, invoked through RunScript, that computes afford/debit/grant server-side. Real-money items belong behind AppStoreService plus the platform SDK. Routing a real-money item through a currency script, or a virtual-currency item through AppStoreService, is the wrong tool for that price type.
The "freebie" shop item is a special case, not a separate code path. It goes through the same ClaimParentShopItem call as everything else. The response additionally carries a coolDownUntil timestamp that the client uses to arm a 24-hour countdown timer; the item has no claim method of its own.
AppStoreService's promotion methods are a different brainCloud feature from the standalone Promotions service (the one used to create, edit, or delete a promotion programmatically via S2S or cloud code). Marketplace only ever reads eligible promotions and forces a refresh; use the Portal to configure promotions.
Tier 2Global App11 / 29
Global Properties: tunable config without a client build
Authentication: this call fires in the same post-login burst as everything else in this series.
brainCloud provides Global Properties: portal-configured key/value data, read with a single client call. Loot box drop rates, per-rarity move speeds, and child-account limits are all Global Properties in bitBuddies, fetched right after login. A designer can retune drop rates or unlock requirements without a new client build. By this point in the series, a new developer already has auth, stats, currency, and a store wired up. Global Properties covers what's left in Tier 2: live-tunable numbers, without a hand-rolled config system.
How it works
Ask for exactly the properties you use, by name.ReadSelectedProperties(string[]) takes a fixed array of property names. This is not a "fetch everything the portal has" call. Adding a new tunable property server-side also means adding its name to this array client-side.
A property's value is not a ready-to-use primitive. Complex config (a rarity table, a per-type speed map) comes back double-encoded: parsing the JSON once yields the property envelope; a second parse pulls the value string into the real data.
Simple scalar properties still come back as strings. Even a plain integer, like a max-child-count, needs an explicit parse (int.TryParse) rather than a direct cast. The SDK does not hand back a ready-made int.
A property can be server-only, which makes it a place to store a secret. Leave it off ReadSelectedProperties/ReadProperties and only cloud code/S2S can read it, via globalAppServiceProxy; a client never can. Good for a third-party API key a script needs for an outbound call, or a drop rate that shouldn't be exposed.
isSecret goes further: masked even from server-side view unless whitelisted. Masked in the portal UI, masked in API responses, stripped from audit logs. Cloud code/S2S read the real value through a separate accessor (bridge.getGlobalProperty()), and only if their tier is whitelisted for it; off the whitelist gets a 403, not a masked placeholder. Sourcing: step 4 is confirmed against brainCloud's public API reference. isSecret and a related LicensorOnly flag (restricts a property to super users on a specific email domain) are real but documented internally, not exercised in any reference app here. Managed in the portal under Design → Cloud Data → Global Properties.
var childAccountMaxObj = data["ChildAccountMaximum"] as Dictionary<string, object>;
if (int.TryParse((string)childAccountMaxObj["value"], out int value3))
{
GameManager.Instance.ChildCountMaximum = value3;
}
var buddyMoveSpeedObj = (Dictionary<string, object>)data["BuddyMoveSpeedInfo"];
string moveSpeedJson = (string)buddyMoveSpeedObj["value"];
// moveSpeedJson = "{\"Starter\":1,\"Basic\":1.1,\"Rare\":1.2,\"SuperRare\":1.3,\"Legendary\":1.4}"
var moveSpeedDict = (Dictionary<string, object>)JsonReader.Deserialize(moveSpeedJson);
List<float> moveSpeeds = new List<float>();
foreach (var kvp in moveSpeedDict)
{
moveSpeeds.Add(Convert.ToSingle(kvp.Value));
}
GameManager.Instance.BuddyMoveSpeeds = moveSpeeds; // order = dictionary iteration order, not indexed by rarity key
Creating a server-only property, from brainCloud's own docs (Cloud Code, not exercised in this repo)
var globalAppProxy = bridge.getGlobalAppServiceProxy();
var propertyName = "initialHealth";
var jsonValue = {};
jsonValue.health = 6;
jsonValue.regen = 1;
var postResult = globalAppProxy.sysUpdatePropertyJson(propertyName, jsonValue);
if (postResult.status == 200) {
// Success! Only cloud code and S2S can call this; a client never can.
}
Gotchas worth knowing up front
ReadSelectedProperties won't just hand you everything. It returns only the fixed list of names passed in. A new tunable added in the portal is invisible to the client until its name is added to that array. There is no fallback "unknown property" path.
Don't update a property on every request.SysUpdatePropertyJson refreshes an app-wide cache across every API server on write. For anything that changes often during the day, use Global/System Entities or unowned Custom Entities (Persistent State) instead.
A missing or unparseable scalar fails silently.int.TryParse failing on ChildAccountMaximum leaves ChildCountMaximum at its default (0), with nothing logged. If child-account creation caps unexpectedly at zero, check this parse first.
A 403 Forbidden on a secret property doesn't mean the property doesn't exist. It means the caller isn't on that property's read whitelist for its tier. A masked placeholder value, not an error, is what a whitelisted caller gets back instead. Don't debug a 403 on a known-real property as a naming or typo issue before checking whether it's flagged secret and whether the caller's tier is actually whitelisted for it.
Tier 3: Social
Connecting players to each other, before or outside a match
Chat, friends, groups, leaderboards, tournaments, invites, and messages.
Tier 3ChatRTT12 / 29
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
C++
// 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);
JavaScript
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, () => {})
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.
Tier 3FriendPresenceRTT13 / 29
Friends & Presence: a real feature, not a roadmap item
Groups: bcchat implements both together, and registers presence per-group here rather than per-friend; Global Chat for the RTT dispatch pattern this reuses.
brainCloud's Friend service works alongside the RTT Presence extension to let a player see who else in their network is online, live. Two reference apps exercise it, scoped two different ways: the bcchat React example (repositories/js-examples/bcchat) fetches a friend list, then scopes presence per group; the BombersRTT Unity example (repositories/unity-examples/BombersRTT) scopes presence directly to the friend list itself, no group involved. An earlier draft of this series described this feature as a roadmap item, SDK surface that "exists today but isn't yet exercised by any reference app." That was incorrect. This article corrects that framing with what's actually there.
How it works
Fetch friends once, right after connecting to RTT.listFriends(platform, includeSummaryData) returns the roster in a single call. No separate "check who's online" call is needed to render the list.
The two reference apps pick two different presence scopes, and both are real. bcchat registers per group the player belongs to (registerListenersForGroup); BombersRTT registers across the whole friend list directly (registerListenersForFriends + getPresenceOfFriends). Presence also supports an arbitrary-profile-list scope (registerListenersForProfiles) for cases outside both. Which one to use follows from what the UI needs to show.
Live presence changes arrive through the same RTT callback as chat. They're tagged service: "presence", operation: "INCOMING".
Adding or removing a friend is fire-and-forget here. This reference app skips any request/accept handshake. The UI updates optimistically, and addFriends/removeFriends confirms the change server-side.
Code
Enable RTT presence alongside chat, right after loginApp.js:296-297
RegisterListenersForFriends is real and live, just not in bcchat. bcchat only registers per group; BombersRTT is the reference app that scopes presence to the whole friend list instead (RegisterListenersForFriends + GetPresenceOfFriends, plus SetVisibility(true) so the player shows up as online to their friends at all).
Chat and presence pushes arrive on the same kind of RTT callback, distinguished only by service. A handler checking operation alone, without also checking service, will misread one for the other.
addFriends here is unilateral. There's no pending-invite state in this reference app: anyone can add anyone. Mutual consent, where needed, has to be built as a separate layer; the call itself doesn't enforce it.
Tier 3GroupChatPresence14 / 29
Groups: a shared roster with real membership and data infrastructure of its own
Global Chat: a group's channel is the exact same getChannelId/channelConnect flow, just with channel type "gr" instead of "gl"; Friends & Presence for the presence half of this same reference app; Entity Storage for Group Entity, the group-scoped data store this article names but doesn't demonstrate.
brainCloud's Group service is more than a roster with Chat and Presence layered on top. Membership is real infrastructure in its own right: open and closed groups, invitations, join requests with approve/reject, and per-member roles and attributes. A group also carries its own data, separate from anything shared with a single member: Group Data, one JSON blob per group, and Group Entities, a full paginated, queryable collection scoped to the group, both with their own ACLs. This article covers what bcchat, the reference app here, actually drives: create, join, leave, plus Chat and Presence layered on the roster once a groupId exists, which is genuinely where those two services' patterns reuse directly. Group Data, Group Entities, invitations, join requests, and roles are the rest of the service: real and documented, just not part of this particular walkthrough.
How it works
List the groups you're already in, once at login.getMyGroups() returns every group the player belongs to.
Loading a group is a three-step chain, reusing Chat's exact pattern. Resolve the group's channel id via getChannelId("gr", groupId, ...), connect to it the same way as Global Chat's "gl" channel, then layer group-specific membership and presence on top.
Membership and presence are separate calls from the channel connect.readGroupMembers(groupId) gets the roster; registerListenersForGroup(groupId, true) gets live online/offline status for that roster, including each member's initial state in the response.
Creating a group is one call with a full config payload: name, type, visibility, ACL, and default attribute blobs for owner and members. A successful call produces a normal group, loaded the same way as any other.
this.bcWrapper.group.createGroup(
name,
"bcchat", // groupType: an app-defined string, not a fixed enum
true, // isOpenGroup
null, // acl
{}, // ownerAttributes
{}, // defaultMemberAttributes
{}, // data
result =>
{
if (result.status === 200) this.loadGroup(result.data);
});
Join / leave (App.js:555-566, App.js:930-942)
this.bcWrapper.group.joinGroup(group.groupId, result =>
{
if (result.status === 200) this.loadGroup(group);
});
this.bcWrapper.group.leaveGroup(group.groupId, result => { /* ... */ });
Gotchas worth knowing up front
Groups have a real member cap, not an unlimited roster.Max Size is a per-group-type portal setting, 2 to 50 members, set under Design → Groups → Group Types. The platform-wide default ceiling is also 50, specifically to limit optimistic-locking contention on group and group entity writes; raising it past 50 is a support request, not a client-side flag.
A group's chat channel, membership, and presence are three independent calls. No single call returns all three. Skipping any one of them (channel connect, readGroupMembers, registerListenersForGroup) leaves a group missing chat, a roster, or live status, respectively.
readGroupMembers's response is keyed by profile id, which is a feature, not friction. Looking up one specific member's data is a direct result.data[profileId], no scan required. Iterating the whole roster is still one line: Object.keys(result.data).
registerListenersForGroup's response carries each member's presence at registration time. That's separate from the live RTT push that follows: it's the initial snapshot, not a live update. The relationship mirrors channelConnect's message history to the live chat push (see the Global Chat article).
groupType is an app-defined string ("bcchat" here). The SDK doesn't fix it to an enum. It's typically used server-side to distinguish group kinds (guild vs. party vs. clan) when a game has more than one.
Tier 3Leaderboard15 / 29
Leaderboards: post a score, read a page, and watch the config gotchas
Persistent State: leaderboards are tier 4 in that article's framing (durable, cross-session); this article is the deep dive.
brainCloud provides both global and social leaderboards, each configured with a score type: High Value, Low Value, Cumulative, or Last Entry. Posting and reading a leaderboard only takes two calls. The hard part is that a leaderboard's score type (does it keep the highest value, or the lowest?) has to match how the app posts to it, or scores look fine on read and then silently vanish on the next post. The spider_solitaire Dart example (repositories/dart-examples/spider_solitaire) runs three leaderboards per difficulty (high score, fastest time, fewest moves), and its LeaderboardService documents every one of these gotchas inline, from real production pain.
How it works
Pick a score type that matches the sort direction, and pass it at post time.HIGH_VALUE keeps the best (highest) score per player; LOW_VALUE keeps the best (lowest). Time and move-count leaderboards need LOW_VALUE; otherwise brainCloud's default "keep the highest" behavior silently drops every improvement.
postScoreToDynamicLeaderboardUTC lets the score type be specified per call. That matters because it only takes effect the first time a leaderboard is created. Once a leaderboard exists with the wrong type, the only fix is deleting it in the portal; it can't be reconfigured from the client.
A leaderboard doesn't exist until someone posts to it. Reading one nobody has played yet returns a noLeaderboardFound reason code instead of an empty page. UI logic should treat that as zero entries, not an error.
Post multiple metrics in parallel when one game result feeds more than one leaderboard. A win here posts to high-score, fastest-time, and fewest-moves all at once. They're independent leaderboards, so there's no ordering dependency between the writes; posting them one after another isn't necessary.
Embed a display name in the score payload as a fallback. Reading a page back returns a profile-level name when one exists. Embedding a player-supplied name in the score data at post time covers the case when it doesn't.
enum LeaderboardMetric {
highScore('High Score', SortOrder.HIGH_TO_LOW, SocialLeaderboardType.HIGH_VALUE),
fastestTime('Fastest Time', SortOrder.LOW_TO_HIGH, SocialLeaderboardType.LOW_VALUE),
fewestMoves('Fewest Moves', SortOrder.LOW_TO_HIGH, SocialLeaderboardType.LOW_VALUE);
// HIGH_TO_LOW pairs with HIGH_VALUE; LOW_TO_HIGH pairs with LOW_VALUE. Mismatch here// means scores look correct on read but get silently dropped on the next post.
}
/// A leaderboard only starts existing once someone posts the first score to/// it, so reading one nobody has played yet returns `noLeaderboardFound`/// rather than an empty page, so treat that the same as "no entries."
Future<Map<String, dynamic>> getLeaderboardPage({
required String leaderboardId, required SortOrder sortOrder,
int startIndex = 0, int endIndex = 99,
}) async {
final response = await _bc.socialLeaderboardService.getGlobalLeaderboardPage(
leaderboardId: leaderboardId, sortOrder: sortOrder, startIndex: startIndex, endIndex: endIndex);
if (!response.isSuccess() && response.reasonCode == ReasonCodes.noLeaderboardFound) {
return const {'leaderboard': []};
}
_throwIfFailed(response, 'getGlobalLeaderboardPage($leaderboardId)');
return _unwrap(response.data);
}
// Prefer profile-level name; fall back to the playerName embedded in the// score data; finally fall back to a placeholder.
final name = profileName.isNotEmpty ? profileName
: (dataName.isNotEmpty ? dataName : 'Player');
Gotchas worth knowing up front
Score type and sort order are a pair; getting them wrong fails silently. A LOW_TO_HIGH leaderboard posted to with the default (HIGH_VALUE) score type keeps looking correct until a player posts a better (lower) score and it doesn't update, because the server treats a higher number as the improvement.
Fixing a misconfigured leaderboard means deleting it in the portal. No post method reconfigures it: postScoreToDynamicLeaderboardUTC's type argument only governs creation of a brand-new leaderboard.
noLeaderboardFound isn't a failure state for a fresh leaderboard. It's the expected response before the first post. The caller should special-case that reason code rather than surface it as an error.
Post a partial or incomplete result to fewer leaderboards, deliberately. This reference app posts an abandoned game's score to the high-score board only, skipping fastest-time and fewest-moves, because those metrics only mean something for a completed game.
Tier 3TournamentEvent16 / 29
Tournament: the reward arrives as an event, not something you poll for
Leaderboards: a tournament is a leaderboard with a lifecycle wrapped around it; Player-to-Player Events for the generic event mechanism a tournament's completion rides on.
brainCloud's Tournament service builds a competitive season on top of a leaderboard: join, post scores, and the server tracks standings and payouts. The join and post calls aren't the interesting part; the interesting part is how a client learns a tournament has ended and a reward is waiting. BombersRTT (repositories/unity-examples/BombersRTT) never polls for that. A SYSTEM_TOURNAMENT_COMPLETEEvent (the same generic Event mechanism from Player-to-Player Events) arrives in the normal event queue, and the client reacts to it the same way it reacts to a player-sent invite.
The full lifecycle
A tournament run passes through six stages. BombersRTT, the reference app for this article, only touches the last three (join, play, claim); the first three are configured once, server-side, and apply to every run.
Create.SysCreateTournamentTemplate(tournamentCode, configJson) (cloud-code/S2S only) defines what a tournament is: entryType ("PLAYER" or "GROUP"), an entry fee, payoutRules (reward by rank, e.g. rank 1 gets 100 coins, top 10% gets 5), and a postScript, a Cloud Code script that runs once rankings are calculated and is what actually enqueues each participant's SYSTEM_TOURNAMENT_COMPLETE event.
Schedule. The template alone has no dates. Scheduling lives on the underlying leaderboard: GetTournamentStatus returns a tournamentTimetable with tRegistrationStart/tRegistrationEnd/tPlayStart/tPlayEnd and a tState (ACTIVE, etc). Each recurrence of a recurring tournament is a new versionId on that same leaderboardId, which is exactly the pair GetTournamentStatus/ ClaimTournamentReward take.
Join.JoinTournament(leaderboardId, ...) enrolls the player and automatically collects any entry fee, failing if they can't afford it.
Play.PostTournamentScoreUTC/PostTournamentScoreWithResultsUTC post to the tournament's leaderboard, same as any leaderboard score post.
Reward computed. Once play ends, the postScript resolves each participant's rank against payoutRules. ViewCurrentReward previews the projected reward mid-tournament from current standing; ViewReward reads the final reward after the run has ended. Neither call pays out.
Claim.ClaimTournamentReward is what actually pays out. This is the step BombersRTT demonstrates, triggered by the SYSTEM_TOURNAMENT_COMPLETE event from step 1's postScript, covered below.
Tournaments and divisions
A division set is the same machinery, used to shrink the competitive pool, not to rank players by skill. SysCreateDivisionSetConfig(divSetId, configJson) (cloud-code/S2S only) sets a templateLeaderboardId and a cap (maxPlayers or maxGroups); brainCloud fills one division instance up to that cap, then opens a new one, and repeats for as long as players keep joining. Which instance a player lands in is random, based on enrollment order, not their skill or rank. The point is engagement: competing against 10 people for a top spot feels winnable in a way competing against a worldwide leaderboard of thousands doesn't, so divisions exist to make that top spot reachable, not to sort players into Bronze/Silver/Gold. A skill-based ladder is a real, related, but separate pattern brainCloud calls Tiers: a series of ordinary leaderboards and tournaments a design team sets up one per tier, with cloud code moving a player up or down a tier based on how the previous tournament went. Divisions don't do that on their own. Client-side, the calls mirror the plain-tournament calls one-to-one: JoinDivision(divSetId, tournamentCode, initialScore, ...) plays the role of JoinTournament, returning the specific leaderboardId for whichever instance the player landed in, and GetDivisionInfo/GetGroupDivisionInfo play the role of GetTournamentStatus. Everything downstream, posting scores, viewing rewards, claiming, uses that returned leaderboardId exactly the way a plain tournament does.
How it works
A tournament is identified by a leaderboard id and a version number. There's no separate tournament id: GetTournamentStatus(leaderboardId, versionId, ...) and ClaimTournamentReward both take that same pair.
Completion arrives as a system-generated Event; it isn't fetched separately. The client's normal GetEvents() poll (see Player-to-Player Events) returns SYSTEM_TOURNAMENT_COMPLETE alongside any player-sent events. brainCloud enqueues it the same way.
The event handler routes straight into the claim call. There's no separate "check if I have a reward" step. The event is the signal to claim.
Claiming and checking status are separate calls.ClaimTournamentReward processes whatever's outstanding; GetTournamentStatus is a read-only lookup a UI can call anytime to show current standing without triggering a payout.
Code
System event routes directly to a claim, alongside player-sent event typesGPlayerMgr.cs:268-288
public bool ProcessBCEvents()
{
bool bNewItemProcessed = false;
// Do we have any events to process?
if (m_baseBrainCloudEventDataList.Count > 0)
{
BaseBrainCloudEventData baseBrainCloudEventData = GetNextAvailableBrainCloudEvent();
if (baseBrainCloudEventData != null)
{
if (baseBrainCloudEventData.eventType.Contains(BrainCloudConsts.JSON_EVENT_SYSTEM_TOURNAMENT_COMPLETE))
{
// SYSTEM_TOURNAMENT_COMPLETE Event received// claim the reward
TournamentClaimReward(baseBrainCloudEventData, null, null, baseBrainCloudEventData);
bNewItemProcessed = true;
}
// Process other event types here
}
}
return bNewItemProcessed;
}
The claim call: leaderboard id + version, not a separate tournament idGPlayerMgr.cs:290-297
SYSTEM_TOURNAMENT_COMPLETE arrives through the same event queue as everything else. This reference app's ProcessBCEvents is the exact queue Player-to-Player Events's player-invite events flow through; it's one more eventType value to branch on, not a separate delivery mechanism.
The leaderboard/version pair is load-bearing.leaderboardId and versionId together identify which run of a recurring tournament the reward belongs to. Pairing the current leaderboard id with a stale version, or vice versa, targets the wrong tournament period.
This is genuinely one SDK's worth of evidence. No other reference app in this repo exercises Tournament, and none exercises the create/schedule/divisions surface described above at all; those steps are confirmed against brainCloud's public API reference, not reference-app source.
Tier 3Event17 / 29
Player-to-Player Events: a persisted mailbox that can also push live
Lobby Signals for the live, in-match equivalent of this. Events are the tool for when the recipient might not even be online right now.
brainCloud's Event service delivers a message to a specific player, and persists it if they aren't connected to receive it right away. SendEvent(toProfileId, eventType, jsonData) queues a small JSON payload for that player. If the recipient is offline, it waits in their mailbox until they call GetEvents, whether that's ten seconds or ten hours later. But if the recipient is already connected with RTT and has registered an event callback, the same SendEvent call also arrives live, no poll required. BombersRTT (repositories/unity-examples/BombersRTT) demonstrates both paths at once: its "invite a friend to my lobby" flow lands instantly on a friend who's already in the app, and still waits correctly in their mailbox if they log in later.
How it works
Send is fire-and-forget, addressed by profile id.EventService.SendEvent(toProfileId, eventType, jsonData) does the work. eventType is an app-defined string ("OFFER_JOIN_LOBBY", "CONFIRM_JOIN_LOBBY", "REFUSED_JOIN_LOBBY" in this reference app), and jsonData is whatever payload the recipient needs to act on it.
A connected recipient gets it live, no poll needed. Registering RTTService.RegisterRTTEventCallback(callback) once (alongside RTT chat/presence) means every event sent to this player while they're connected arrives immediately through that callback, tagged operation: "GET_EVENTS" even though nothing was polled.
GetEvents() is the catch-up path, not the only path. Called once at login, it returns everything that queued up while the player wasn't connected to receive the live push. Same events, same data, different delivery timing depending on whether the recipient was online when they were sent.
Every event needs to be explicitly deleted after handling, on both paths.DeleteIncomingEvent(evId) acknowledges and removes it. Events aren't self-expiring, so a client that receives one, live or polled, but never deletes it will see it again on the next GetEvents call.
A three-message handshake is just three SendEvent calls with different eventType strings, each triggering a different UI response on the receiving end. There's no dedicated "invite" API here; the whole pattern is built out of the generic event primitive.
public void ConnectToGlobalChat()
{
GCore.Wrapper.RTTService.RegisterRTTChatCallback(chatCallback);
GCore.Wrapper.RTTService.RegisterRTTEventCallback(eventCallback);
// do a get channel call instead of manually appending these, this is for demo purposes
GCore.Wrapper.ChatService.ChannelConnect(GCore.Wrapper.Client.AppId + ":gl:main", 25, onChannelConnected);
}
private void eventCallback(string in_message)
{
Dictionary<string, object> jsonMessage = (Dictionary<string, object>)JsonReader.Deserialize(in_message);
switch (jsonMessage["operation"] as string)
{
case "GET_EVENTS":
Dictionary<string, object> jsonData = (Dictionary<string, object>)jsonMessage[BrainCloudConsts.JSON_DATA];
GCore.Wrapper.Client.EventService.DeleteIncomingEvent(jsonData["evId"] as string);
switch (jsonData["eventType"] as string)
{
case "OFFER_JOIN_LOBBY":
// bring up the offer to join display on the requested client
break;
case "CONFIRM_JOIN_LOBBY":
// they confirmed to join the lobby! create/join the match
break;
case "REFUSED_JOIN_LOBBY":
GEventManager.TriggerEvent(GEventManager.ON_REFUSED_INVITE_FRIEND);
break;
}
break;
}
}
The live push and the poll deliver the same event, tagged the same way. A live-pushed event arrives through RegisterRTTEventCallback's callback with operation: "GET_EVENTS", the identical operation name GetEvents() returns on the catch-up path. Branching only on operation without checking which callback fired conflates the two; this reference app keeps them apart by handling them in separate functions entirely.
DeleteIncomingEvent is required on the live path too, not just the poll, and nothing expires an event automatically. A live-pushed event is still sitting in the recipient's persisted mailbox; BombersRTT's eventCallback deletes it immediately on receipt. Skipping that on either path means the same event gets re-delivered on the next GetEvents catch-up call.
The whole invite/accept/refuse flow is three app-defined eventType strings. The SDK doesn't provide three dedicated methods for this. Nothing enforces that "OFFER_JOIN_LOBBY" gets answered with "CONFIRM_JOIN_LOBBY" or "REFUSED_JOIN_LOBBY"; that contract lives entirely in the client code on both ends.
Events also carry system-generated types the app never sent, like SYSTEM_TOURNAMENT_COMPLETE in this reference app (triggering an automatic reward claim). An event-processing loop should branch on eventType defensively rather than assume every event came from another player.
Tier 3Messaging18 / 29
Messaging: a real inbox
Player-to-Player Events: the closest sibling service, and the comparison worth understanding before picking either one.
brainCloud's Messaging service gives players a persistent inbox: unread counts, message boxes, mark-as-read state, all retained until the recipient deletes the message explicitly. That's a different durability model than an Event, which is consumed once, or a Lobby Signal, which is never stored. bcchat's direct-message feature sends through this service, via sendSimpleMessage.
How it works
Sending is one call, addressed by a profile id list.sendSimpleMessage(profileIds, text, callback) posts a plain string message to one or more recipients, with no channel, lobby, or prior connection required.
The richer sendMessage variant takes a structured payload, conventionally with a text field, for anything beyond plain strings. bcchat only uses the simple form.
Reading is a separate, multi-step API surface.getMessageBoxes lists boxes, getMessageCounts returns total and unread counts per box, getMessagesPage fetches content, and markMessagesRead updates read state. None of these are called in bcchat's running code path.
Unlike Events, nothing here is auto-consumed. A message persists until deleteMessages removes it. There's no drain-the-queue behavior the way GetEvents effectively empties the event queue.
sendDirectMessage(friend, message)
{
this.bcWrapper.messaging.sendSimpleMessage([friend.id], message, result =>
{
if (result.status === 200) { /* ... */ }
else { this.dieWithMessage("Failed to send message"); }
});
}
Reading: written, but the one call site is commented out (App.js:317-318, App.js:362-376)
// Fetch mailboxes// this.fetchMailBoxes(); <-- never actually invoked
fetchMailBoxes()
{
this.bcWrapper.messaging.getMessageCounts(result =>
{
if (result.status === 200) { /* never reached in practice */ }
else { this.dieWithMessage("Failed to retrieve messageboxes: " + result.status_message); }
});
}
Gotchas worth knowing up front
Choose between Messaging and Events based on payload durability. A message sits in an inbox until explicitly deleted (deleteMessages), suited to "you have mail" patterns a player checks on their own time. An Event follows a drain-the-queue-at-login pattern (see Article 16), better suited to transient state like invites that stop mattering once handled.
sendSimpleMessage takes a list of profile ids. A single call can address several specific players at once; it isn't limited to a 1:1 primitive.
Tier 3Push Notification19 / 29
Push Notification: registration, receipt, and a client-triggered send
Messaging and Player-to-Player Events. Both reach a player who has the app open or checks it later. Push Notification is the one mechanism aimed at a player who currently has neither.
brainCloud supports push notifications across iOS, Android, and Facebook, with advanced features including Localization, Segments, Promotions, Scheduled sends, and Groups. Two reference apps, each real on its own platform, cover the actual client-side work: iOS via Basic Example (repositories/swift-examples/Basic Example), and Android via ConcentrationGame (repositories/java-examples/ConcentrationGame). Getting a device token in the first place is platform-native work brainCloud has no part in, APNs on iOS, Firebase Cloud Messaging on Android, but handing that token to brainCloud is one call either way, RegisterPushNotificationDeviceToken. ConcentrationGame goes further and shows the receiving side too: a real FirebaseMessagingService override that turns an incoming push into an actual on-screen Android notification.
How it works
iOS asks the OS for permission and a token first.UIApplication.shared. registerForRemoteNotifications() is native iOS API; brainCloud has no part in it. It triggers the OS permission prompt, and didRegisterForRemoteNotificationsWithDeviceToken delivers the raw APNs token once granted.
Android's token comes from Firebase instead, and arrives asynchronously.FirebaseMessaging.getInstance().getToken() returns a task; the token isn't available until that task's completion listener fires, not immediately after the call.
Hand the token to brainCloud once you have it, with a platform argument on Android. iOS calls pushNotificationService.registerDeviceToken(PlatformObjc.iOS(), token, ...). Android calls getPushNotificationService().registerPushNotificationToken( Platform.GooglePlayAndroid, token, ...), an explicit platform argument iOS's call doesn't take.
Receiving is a platform-native override, not a brainCloud callback, and ConcentrationGame actually implements it.MyFirebaseMessagingService. onMessageReceived(RemoteMessage) reads the payload and builds a real Android Notification to display it. brainCloud's job ends at delivering the push to FCM/APNs; turning it into something the user sees on screen is the app's job, and this is what that looks like done for real.
A client can trigger its own test push. Basic Example wires sendSimplePushNotification to a UI button, sending a push to the logged-in profile on demand. Real production sends normally go through the portal or S2S (Localization, Segments, Promotions, Scheduled, Groups, all outside client code), but the client-callable send exists and is real here, useful for exactly this kind of manual testing.
//this is the new way to get the firebase token.
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
if (!task.isSuccessful()) {
Log.w("NEW_TOKEN", "getInstanceId failed", task.getException());
return;
}
// Get new Instance ID token
String token = task.getResult();
Log.i("NEW_TOKEN", token);
brainCloudManager.getBrainCloudWrapper().getPushNotificationService().registerPushNotificationToken(Platform.GooglePlayAndroid, token, theCallback);
});
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
super.onMessageReceived(remoteMessage);
// Used for displaying old school GCM messages
if (remoteMessage.getData().size() > 0) {
Log.d(TAG, "Message data payload: " + remoteMessage.getData());
NotificationManagerCompat manager = NotificationManagerCompat.from(this);
Notification notification = new NotificationCompat.Builder(this, "Messages")
.setContentText(remoteMessage.getData().get("message"))
.setContentTitle(remoteMessage.getData().get("title"))
.setSmallIcon(R.drawable.ic_launcher_background)
.build();
manager.notify(0, notification);
}
}
Gotchas worth knowing up front
The registration call's shape differs by platform, and it's not just the platform argument. iOS's registerDeviceToken takes a PlatformObjc.iOS() constant. Android's registerPushNotificationToken takes Platform.GooglePlayAndroid plus a token that only exists after an async Firebase callback fires, not synchronously after the call that requests it. Porting one platform's registration call to the other by pattern-matching the arguments will not compile, let alone work.
A Firebase token isn't available until its completion listener fires. Calling registerPushNotificationToken before FirebaseMessaging.getInstance().getToken()'s listener has run has nothing to register yet. ConcentrationGame nests the registration call inside that listener specifically to avoid this.
Turning a received push into something visible is entirely the app's responsibility. brainCloud and FCM/APNs's job ends at delivering the payload to the OS layer. onMessageReceived building and posting an Android Notification by hand, reading title/message straight out of the raw data payload, is what that responsibility looks like; nothing does this automatically.
sendSimplePushNotification being client-callable doesn't mean production sends should go through it. It's real and useful for manual testing, exactly what Basic Example uses it for, but brainCloud's Localization, Segments, Promotions, Scheduled, and Groups push features (portal/S2S concerns) are the tools for anything beyond a developer triggering their own test notification.
Tier 4: Asynchronous Multiplayer
Multiplayer with no connection held open
Turn-based matches and offline raid attacks: two ways to play against another person without a live socket between you.
Tier 4Async MatchRTT20 / 29
Async Match: turn-based multiplayer with no connection held open
Lobbies: the realtime alternative this series covers next; async match trades a live connection for a simpler request/response turn cycle.
brainCloud calls this pattern Turn-by-Turn Async, the same model popularized by Words with Friends and Draw Something. TicTacToe (repositories/unity-examples/TicTacToe) plays an entire match (challenge, alternating turns, win detection, rematch) through plain request/response calls. No lobby, no relay, no connection held open between turns. The one piece of realtime it uses is optional: an RTT callback that reports a turn happened, so the waiting player doesn't have to poll. With that connection open, the notification arrives the moment the opponent's turn lands, immediate enough to feel like a live socket even though no connection is ever held open for the match itself. Finding an opponent is a separate concern from playing one: TicTacToe layers real, rating-based MatchMaking and a real Lobby-based quick-match option on top of the async match loop, and in this codebase's own history, both were added after the core turn-by-turn loop was already working, in under ten minutes. Async Match doesn't need matchmaking to function; the two meet at exactly one narrow point, a chosen opponent's profile id, which is what makes bolting one onto the other that fast.
How it works
Finding an opponent is a real matchmaking search, not a hardcoded name.EnableMatchMaking opts the local player into being discoverable once, at startup; FindPlayers(rangeDelta, numMatches) then returns a pool of similarly-rated opponents to pick from. A second path, a real Lobby, pairs two players automatically for a quick match instead. Either way, what comes out is a specific opponent profile id.
CreateMatchWithInitialTurn creates the match against whichever opponent was just picked. It takes that profile id, the initial board state, a push-notification message, and whose turn it is. The match exists immediately once called; the waiting-for-opponent phase already happened in matchmaking or the lobby, not here.
Every turn is a versioned write.SubmitTurn takes the match's current version number alongside the new state. That's what lets the server reject a stale or out-of-order submission instead of silently overwriting a newer state.
The RTT callback signals a change; it doesn't hand you the turn data. Registering RegisterRTTAsyncMatchCallback only tells the client something changed. The handler then re-requests the full match state with ReadMatch instead of trusting a partial payload.
In-progress and completed matches live in two different lists.FindMatches returns active matches; FindCompleteMatches is a separate call for finished ones. A match moves from one list to the other rather than picking up a status flag on a shared list.
Match history isn't carried in the match object; you fetch it on demand.ReadMatchHistory is its own call, used once a match is detected as finished, to show the full turn-by-turn replay.
Code
Push notification for "your turn" is registered once, re-fetches on signal (TicTacToe.cs:189, 210-215)
App.Bc.RTTService.RegisterRTTAsyncMatchCallback(queryMatchStateRTT);
private void queryMatchStateRTT(string in_json)
{
queryMatchState(); // re-fetch, don't trust the RTT payload directly
}
private void queryMatchState()
{
App.Bc.AsyncMatchService.ReadMatch(App.OwnerId, App.MatchId, (response, cbObject) =>
{
var data = JsonReader.Deserialize<Dictionary<string, object>>(response)["data"] as Dictionary<string, object>;
int newVersion = int.Parse(data["version"].ToString());
if (App.MatchVersion + 1 < (ulong)newVersion)
{
App.MatchVersion = (ulong)newVersion;
App.BoardState = (string)(data["matchState"] as Dictionary<string, object>)["board"];
}
});
}
Submitting a turn: versioned, so a stale write can be rejectedTicTacToe.cs:303-312
App.Bc.AsyncMatchService.SubmitTurn(
App.OwnerId,
App.MatchId,
App.MatchVersion,
JsonWriter.Serialize(boardStateJson),
"A turn has been played", // push notification message
null, null, null,
OnTurnSubmitted, OnFailureCallback);
// Enable Match Making, so other Users can also challenge this Profile
App.Bc.MatchMakingService.EnableMatchMaking(null, (status, code, error, cbObject) =>
{
Debug.Log("MatchMaking enabled failed");
});
// ...later, to search:
App.Bc.MatchMakingService.FindPlayers(RANGE_DELTA, NUMBER_OF_MATCHES, OnFindPlayers);
App.Bc.AsyncMatchService.FindMatches(OnFindMatches);
// ...inside OnFindMatches, after populating the active list:
App.Bc.AsyncMatchService.FindCompleteMatches(OnFindCompletedMatches);
Gotchas worth knowing up front
Don't expect the RTT payload to carry match data.RegisterRTTAsyncMatchCallback's payload isn't parsed for match data in this reference app at all. It only triggers a fresh ReadMatch. A handler that tries to extract board state from the RTT event itself would find nothing there.
The MatchVersion check guards against replaying stale state, not just detecting change. The comparison is App.MatchVersion + 1 < newVersion, stricter than a plain not-equal check.
This match really is connectionless between turns. There's no relay socket and no lobby membership to maintain. The only standing registration is the one RTT callback, and even that's optional: a game could poll ReadMatch instead.
Matchmaking and the match itself are genuinely decoupled, not just in theory. Both MatchMaking and the Lobby-based quick-match path were added to this codebase after the core async-match loop already worked, in under ten minutes. CreateMatchWithInitialTurn only ever needs one thing from either of them, an opponent's profile id, which is why bolting a discovery mechanism on afterward doesn't touch the match code at all.
Watch for Cloud Code wrapping the response on match creation.OnCreateMatchSuccess explicitly checks for a response wrapper key, commented as "Cloud Code returns wrap the data in a responseJson." That indicates this reference app expects CreateMatchWithInitialTurn to sometimes be fronted by a script instead of always being a direct client call.
One-Way Match: attack while they're offline, replay it when they're back
Async Match: the other connectionless multiplayer pattern in this series; the two solve different problems (turn-taking vs. one-sided raids).
brainCloud calls this pattern One-way Offline: asynchronous multiplayer popularized by Clash of Clans and Boom Beach, where a player attacks an opponent's base while they're offline, and the opponent sees a replay of what happened once they're back. BCClashers (repositories/unity-examples/BCClashers) builds this out of three services working together: MatchMaking finds an opponent and tracks rating, One-Way Match brackets the attack itself, and Playback Stream records every action so the defender can watch exactly what happened, frame by frame, the next time they log in.
The full lifecycle
One-Way Match's whole raid loop is six stages, in a fixed order, spanning three services.
Enable.EnableMatchMaking opts the local player into being a valid target for other players' searches. It doesn't grant the caller anything; it's a visibility switch, off by default, that controls whether this player can be found at all.
Search.FindPlayersUsingFilter(rangeDelta, numMatches, jsonExtraParams, ...) runs an app-defined Cloud Code filter server-side, not a built-in brainCloud rule. This app's filter is named FilterOneWayMultiplayer in its own code comment ("drops candidates with no raidable defense, server-side"); the filter script itself isn't in this repo.
Attack starts.OneWayMatchService.StartMatch brackets a Playback Stream and returns its playbackStreamId. From this point, every action is recorded against that stream id instead of sent live, since the defender isn't connected.
Record.PlaybackStreamService.AddEvent(streamId, eventData, summaryData, ...) logs each meaningful action as it happens. This app adds only one event, for the final outcome (eventId: Defender, the rank achieved), but the same call works for any attack-in-progress action worth replaying later.
Seal, in order.PlaybackStreamService.EndStream closes the recording first, then OneWayMatchService.CompleteMatch(streamId) finalizes the match. The stream must be sealed before the match that owns it is marked complete.
Settle. A win or loss adjusts rating via MatchMakingService. IncrementPlayerRating/DecrementPlayerRating. Separately, and at any time, not just post-match, a defender can call TurnShieldOnFor(minutes, ...) to mark themselves temporarily protected; whether step 2's filter actually excludes a shielded defender depends entirely on what that app-defined filter script checks, since brainCloud itself doesn't enforce shield exclusion automatically.
Dictionary<string, object> eventData = new Dictionary<string, object>();
eventData.Add("eventId", (int)EventId.Defender);
eventData.Add("defenderRank", in_defenderRank);
_bcWrapper.PlaybackStreamService.AddEvent(_playbackStreamId, JsonWriter.Serialize(eventData), CreateEndGameSummaryData(), OnRecordSuccess, OnFailureCallback);
private void OnRecordSuccess(string in_jsonResponse, object cbObject)
{
_bcWrapper.PlaybackStreamService.EndStream(_playbackStreamId); // seal the recording first
_bcWrapper.OneWayMatchService.CompleteMatch(_playbackStreamId); // then finalize the match
}
Replaying any past match by its stream id (NetworkManager.cs:761-780, abridged)
// Used by the "Watch" button on the dashboard's match-history cards.
public void ReadStreamById(string in_playbackStreamId)
{
_bcWrapper.PlaybackStreamService.ReadStream(in_playbackStreamId, OnReadStreamSuccess, OnFailureCallback);
}
public void TurnOnShield()
{
if (_shieldActive) return;
DecreaseGoldAmountForShield();
GameManager.Instance.CurrentUserInfo.ShieldTime = 60;
_bcWrapper.MatchMakingService.TurnShieldOnFor(60, OnTurnOnShieldSuccess);
}
Gotchas worth knowing up front
Three services, one feature. MatchMaking (opponent search and rating), One-Way Match (the attack bracket itself), and Playback Stream (the recording) are three separate services. They add up to asynchronous raid multiplayer only when used together; no single service does all three.
Nothing in the API enforces stream-then-match ordering; it has to be enforced by the caller.EndStream before CompleteMatch is a convention this app follows carefully. The method signatures don't prevent calling them out of order, but doing so seals a match around an unfinished recording.
Re-reading old matches uses the same ReadStream call as watching your own attack. The dashboard's match-history "Watch" button and the code path for viewing your own raid are the same method, fed a different playbackStreamId.
TurnShieldOnFor's argument is minutes, not seconds. brainCloud's own reference confirms it, and this app's code agrees with itself throughout: ShieldTime gets set from a server-returned .Minutes elsewhere, and the on-screen countdown timer converts it with ShieldTime * 60 to get seconds. TurnOnShield's TurnShieldOnFor(60, ...) call shields the base for a full hour, not one minute.
This is only one SDK's worth of evidence. No other reference app in this repo exercises One-Way Match, MatchMaking's rating/shield calls, or Playback Stream. The pattern isn't yet proven across every client.
Tier 5: Matchmaking
Getting players into the same live match
Matchmaking and in-match broadcast, both push-based: no polling, no hand-rolled matchmaker.
Tier 5LobbyRTTGlobal App22 / 29
Lobbies: matchmaking without hand-rolling a matchmaker
brainCloud's Lobby service handles matchmaking in one call. findOrCreateLobby takes a lobby type, a matchmaking algorithm, and the player's data, and brainCloud either slots the player into a compatible in-progress search or starts one. No polling is required: every step (search started, a player joined, the room is assigned, the room is ready) arrives as a push event over the realtime connection the client already has open. The set of lobby types a game offers lives in portal config, not client code, so shipping a new game mode is a server-side edit plus a UI that reads it, no client release required.
How it works
Read lobby types from the server instead of hardcoding them.AllLobbyTypes is a portal-managed global property: a JSON object where each entry looks like {"lobby": "CursorPartyV2", ...}. Every reference client reads it once at login via the Global App service and builds its lobby-type picker from the response, never a fixed list.
Matchmake with findOrCreateLobby. Pass it a matching algorithm (the reference apps use {"strategy":"ranged-absolute","alignment":"center","ranges":[1000]}, which starts players near the middle of a rating band and widens it as the search runs), a rating, and an extra JSON blob carrying anything other members should see about the player: color choice, region ping, or anything else that matters for the game.
Use the region-aware variant for latency-sensitive games. Call getRegionsForLobbies, ping each candidate region, then pass the ping map into findOrCreateLobbyWithPingData (or set it via extra.pings). That gives both the matchmaker and the lobby UI something to reason about beyond rating: actual latency.
Everything after that arrives on its own; nothing needs to be fetched. Register a lobby event callback once. MATCHMAKING_IN_PROGRESS, MEMBER_JOIN, MEMBER_LEFT, STARTING, ROOM_ASSIGNED, ROOM_READY, DISBANDED all arrive on it with the full lobby state attached. No reference app re-fetches with getLobbyData after the initial join.
Code
C++
// Dynamic lobby-type list: never hardcoded
pBCWrapper->getBCClient()->getGlobalAppService()->readProperties(
new BCCallback([](const Json::Value &result) { applyLobbyTypes(result); }, ...));
// AllLobbyTypes.value is a JSON-encoded string; each value has a "lobby" field// Matchmake
pBCWrapper->getLobbyService()->findOrCreateLobby(
lobbyType, 0, 1,
"{\"strategy\":\"ranged-absolute\",\"alignment\":\"center\",\"ranges\":[1000]}",
"{}", {}, "{}", false,
buildExtraJson(), // {"colorIndex":N,"rank":N,"pings":{...}}
settings.teamCode,
new BCCallback([](const Json::Value &) {},
[](const std::string &msg) { errorAndReturnToMenu(msg); }));
var algo := {"strategy": "ranged-absolute", "alignment": "center", "ranges": [1000]}
var extra := {"colorIndex": AppState.my_color_index, "presentSinceStart": true}
if use_ping:
await AppState.bc.lobby_service.get_regions_for_lobbies([lobby_type])
var ping_data: Dictionary = await AppState.bc.lobby_service.ping_regions()
extra["pings"] = ping_data
AppState.bc.lobby_service.set_ping_data(ping_data)
return await AppState.bc.lobby_service.find_or_create_lobby_with_ping_data(
lobby_type, 0, 1, algo, {}, {}, false, extra, team_code, [])
Gotchas worth knowing up front
extra is the only per-member broadcast channel at join time. Anything another lobby member needs to know about a player before the match starts (color pick, team, region latency) rides extra, since there's no separate lobby-profile endpoint. It round-trips through every subsequent lobby event.
Tier 5Lobby23 / 29
Lobby Signals: one call for any "tell everyone in this match" event
Global Chat for the portal-registered, cross-session channel this is not; Lobbies for the connection this rides on; Player-to-Player Events for reaching someone who isn't even in your lobby.
A lobby signal is a one-call broadcast to everyone currently in a lobby: sendSignal(lobbyId, payload), riding the RTT connection the lobby already holds open. There's no channel to pre-register in the portal, no separate connect step, no history to fetch. Every reference RelayTestApp port builds its "this lobby" chat tab (the tab scoped to just the current match) on this call alone, without touching the Chat service. The primitive isn't limited to chat text. Anything that fits "broadcast this small JSON blob to everyone in my lobby" works: a ready-check, a vote, a draft pick, whatever the game needs.
How it works
Send any JSON payload with sendSignal(lobbyId, payload). There's no schema. The reference apps send {"text": "..."}", but the payload's structure is entirely up to the app.
Receive it back through the same lobby event callback already registered for MEMBER_JOIN/ROOM_READY/etc., as an event whose operation is SIGNAL. The payload comes back under signalData, and the server attaches the authoritative sender identity under from (cxId, name). The sender name doesn't need to be trusted as client-asserted; the server already vouches for it.
Filter the sender's own echo. The server sends a signal back to its own sender along with everyone else's, so every reference app compares from.cxId against its own id and skips rendering (or re-applying) its own signal, since it already rendered it optimistically the moment it sent.
_brainCloudWrapper.LobbyService.SendSignal(_lobbyID, signalData, successCb, failureCb);
// received in the "SIGNAL" case of the same lobby-event handler used for MEMBER_JOIN etc.
Godot (GDScript)
func _on_lobby_signal_send_requested(text: String) -> void:
AppState.bc.lobby_service.send_signal(AppState.lobby_id, {"text": text})
AppState.lobby_chat_history.append({"from": AppState.username, "text": text, "is_me": true})
func _on_lobby_signal_received(data: Dictionary) -> void:
var from_cx_id: String = data.get("from", {}).get("cxId", "")
if from_cx_id == AppState.user_cx_id: return # skip echo of own signal
var text: String = data.get("signalData", {}).get("text", "")
Gotchas worth knowing up front
This is not the Chat service. There's no message history to fetch, no portal registration, and messages don't survive the lobby ending. Chat that needs to outlive a single match belongs in Global Chat instead (see the earlier article).
Every sender receives its own signal back, every time. Every reference app filters it out by comparing from.cxId. Skipping this filter double-renders the sender's own messages.
The mechanism isn't chat-specific. It's a generic, low-latency, server-brokered lobby broadcast; the reference apps only ever put {"text": "..."} in the payload, but a ready check, a drawing-tool selection, or a draft pick is exactly as valid.
Tier 6: Realtime & Advanced Architecture
Where the harder design decisions live
Live networked movement, catching up late joiners, and account architectures that go beyond one player/one save file: the most structurally complex material in the series.
Tier 6Relay24 / 29
Multiplayer / Relay: real-time movement without running your own server
Lobbies hands you the host/port/passcode this connects to; Join-in-Progress builds directly on sendToPlayers.
brainCloud's relay is a low-latency multiplayer channel that opens once a lobby reaches ROOM_READY: the event already carries everything needed to connect, host, port, passcode, lobby id. Three ways to send are available: to one player, to a bitmask of players, or to everyone, over WebSocket, TCP, or UDP depending on the game and platform. brainCloud's servers handle the fan-out; there's no game server to provision or scale. Pick a protocol and start sending bytes.
How it works
Connect using what the lobby already provided.ROOM_READY's payload carries the host, port, and passcode; call connect(protocol, host, port, passcode, lobbyId, callback) and that opens the relay socket.
Pick a protocol per platform, but respect the forced overrides. UDP gives the lowest latency for twitch gameplay, TCP guarantees order, and WebSocket is what works in a browser. The reference apps let the player choose, except on GameLift and i3D-hosted servers, which force WebSocket regardless of the client's pick, because the fan-out infrastructure behind them doesn't support raw TCP/UDP from the client. The browser case isn't optional: the JS relay client only ever opens a WebSocket. There's no TCP/UDP option in-browser at all.
Three ways to send are available.send(data, toNetId, ...) targets one peer by its relay-assigned net id. sendToPlayers(data, playerMask, ...) targets an arbitrary bitmask of peers (bit N = net id N). sendToAll(data, ...) (same as TO_ALL_PLAYERS) reaches everyone except the sender. Every send takes reliable/ordered/channel, and four priority channels are available (HIGH_PRIORITY_1, HIGH_PRIORITY_2, NORMAL_PRIORITY, LOW_PRIORITY). Movement can stay unordered-but-frequent on one channel while a rare reliable event uses another, so neither blocks the other.
Resolve identity, not just net id.getNetIdForCxId/getCxIdForNetId map the compact numeric id used on the wire back to the actual player. That resolution should happen at receive time, since the net id table gets torn down at endMatch.
Ping is per-connection, and the SDK doesn't share it automatically.getPing() only reports the local round-trip time; it isn't broadcast to other players. Showing every player's ping, as the reference apps do, requires broadcasting it as an ordinary app-level relay message.
Code
C++: connect + send
pBCWrapper->getRelayService()->registerRelayCallback(&bcRelayCallback);
pBCWrapper->getRelayService()->registerSystemCallback(&bcRelaySystemCallback);
auto protocol = (gameliftOrI3d) ? BrainCloud::eRelayConnectionType::WS : settings.protocol;
pBCWrapper->getRelayService()->connect(protocol, state.server.host, port,
state.server.passcode, state.server.lobbyId,
&bcRelayConnectCallback);
// send to a bitmask of players
auto str = Json::FastWriter().write(json);
pBCWrapper->getRelayService()->sendToPlayers(
(const uint8_t *)str.data(), (int)str.length(),
playerMask, /*reliable*/true, /*ordered*/false, (BrainCloud::eRelayChannel)0);
JavaScript: protocol selection + ping broadcast
// Priority: gamelift → i3d (both force WS) → user-selected protocol
if (ports.gamelift) { port = ports.gamelift; ssl = false }
else if (ports.i3d) { port = ports.i3d; ssl = false }
else { ssl = this.state.relayProtocol === 'wss'; port = ports.ws }
// app-level ping broadcast, every 2s: the SDK does not do this for you
pingInterval = setInterval(() => {
const msg = { op: 'relay_ping', data: { ping: this.bc.relay.getPing() } }
this.bc.relay.sendToAll(Buffer.from(JSON.stringify(msg), 'ascii'), false, false, this.bc.relay.CHANNEL_HIGH_PRIORITY_1)
}, 2000)
Godot (C#): masked send for "shockwaves only to checked players"
_brainCloudWrapper.RelayService.SendToAll(jsonBytes, _sendReliable, _sendOrdered, _sendChannel); // movement
_brainCloudWrapper.RelayService.SendToPlayers(jsonBytes, BuildSendMask(), true, false, _sendChannel); // shockwave, masked
ulong BuildSendMask() // one bit per allowed recipient, resolved cxId -> netId
{
ulong mask = 0;
foreach (var cxId in _allowSendTo.Keys)
if (_allowSendTo[cxId]) mask |= 1ul << (int)RelayService.GetNetIdForCxId(cxId);
return mask;
}
Godot (GDScript): masked send for "shockwaves only to checked players"
AppState.bc.relay_service.send(data, BrainCloudRelay.TO_ALL_PLAYERS, reliable, ordered, channel) # movement
var mask := 0 # one bit per allowed recipient, resolved cxId -> netId
for cx in _send_to:
if _send_to[cx] and _cx_to_net_id.has(cx):
mask |= 1 << int(_cx_to_net_id[cx])
AppState.bc.relay_service.send_to_players(data, mask, true, false, channel) # shockwave, masked
Gotchas worth knowing up front
Resolve cxId ↔ netId at receive time, not connect time. The mapping gets torn down on endMatch, so a value cached early and used after the match ends resolves to nothing.
Tier 6Relay25 / 29
Join-in-Progress: catching up a late joiner in two messages
Join-in-progress needs exactly one new idea on top of everything in the Relay article: detect that a specific new peer just connected, and resend that peer, and only that peer, whatever state it missed. brainCloud's relay system events provide that connect signal directly. The resync itself is plain application-level messages over the same sendToPlayers call already used for gameplay. There's no separate "join in progress" API to learn.
How it works
Watch the relay's CONNECT system event, not the lobby's MEMBER_JOIN. The lobby fires MEMBER_JOIN when someone joins the lobby, which happens before that peer is actually connected to the relay. The relay's own system callback fires CONNECT only once the peer is genuinely on the wire. That's the signal to act on.
Only the host resyncs, and only to that one peer. On CONNECT, the host resolves the new peer's net id, builds a single-bit mask (1 << netId), and sends a game_start message (authoritative match start time plus round number) and a splotch_sync message (the accumulated canvas). Both go via sendToPlayers, not sendToAll: nobody else needs either message.
Chunk the resync. The full canvas can exceed a single relay packet's size budget, so it's split into batches under ~900 bytes, comfortably below the relay's own ~1024-byte cap. The first batch is flagged (first: true) so the receiver clears its canvas before appending; every batch after that just appends.
Use reliable: true, ordered: false, and know why. Dropping a chunk of the canvas isn't acceptable, but the chunks don't need to arrive in order: each batch is self-contained, and only the first one triggers a clear.
Resolve netId → cxId at receive time, not when the mask is built. The mapping is torn down at endMatch, so anything needed later should be captured into the message payload itself, not left as a lookup for after the fact.
Code
C++
// relay system callback
else if (json["op"].asString() == "CONNECT") // new player joined mid-game
{
if (state.lobby.ownerCxId == state.user.cxId && state.gameStartTime != 0)
{
const auto &cxId = json["cxId"].asString();
auto netId = pBCWrapper->getRelayService()->getNetIdForCxId(cxId);
uint64_t mask = (uint64_t)1 << (uint64_t)netId;
sendGameStartToMask(mask);
sendSplotchSyncToMask(mask); // chunked, host -> just this one player
}
}
// chunked resend, ~900-byte batches
pBCWrapper->getRelayService()->sendToPlayers(
(const uint8_t *)str.data(), (int)str.length(),
mask, /*reliable*/true, /*ordered*/false, (BrainCloud::eRelayChannel)0);
func on_relay_system(msg: Dictionary) -> void:
if msg.get("op") == "CONNECT" and is_host:
_send_splotch_sync(msg.get("netId"))
func _send_splotch_sync(net_id: int) -> void:
# chunks _splotch_records into <=900-byte packets (relay MAX_PACKETSIZE is 1024)
AppState.bc.relay_service.send(packet, net_id, true, true, BrainCloudRelay.CHANNEL_HIGH_PRIORITY_2)
func _on_splotch_sync(data: Dictionary) -> void:
if data.first: # clear canvas
...
# replay each entry preserving original `t` so fade timing matches other clients
Gotchas worth knowing up front
MEMBER_JOIN (lobby) fires too early to resync from. It signals that someone joined the lobby, not that they're relay-connected yet. Wait for the relay's own CONNECT system event.
Resync to a mask, not to everyone. Broadcasting the full canvas to already-caught-up peers on every join wastes bandwidth and risks visibly "replaying" splotches those peers already have. Target the single joining peer's net-id bit instead.
Preserve original timestamps on replay, don't stamp them with "now." Synced state with any time-based behavior, like fade-out or expiry, needs each replayed entry to carry its original timestamp, or a late joiner sees a full-strength copy that outlives the original instead of matching everyone else's fade timing.
Tier 6App ManagementScript26 / 29
Parent–Child Accounts: one login, multiple isolated save files
Authentication covers Identity, a different concept from the Profile-switching this article describes. Virtual Currency covers BuddyBling, the child-scoped currency this architecture makes possible.
brainCloud's parent–child app relationship is what makes bitBuddies' single most distinctive design pattern possible: one player login (the "Parent" app) owns top-level currency and progression, while each individual bitBuddy the player owns lives in its own isolated Child app profile, with its own stats, its own currency (BuddyBling), its own inventory. The client never merges these. Every call that touches a specific buddy explicitly states which child app and which child profile it means. The pattern fits any game that needs many independently-progressing sub-entities under one player account (pets, bases, characters, save slots), without hand-rolling that isolation.
How it works
Fetch every linked child profile with one script call, right alongside the other post-login calls. No separate "does this player have any buddies yet" check is needed first.
Every child-scoped script name is namespaced under "child/…", and profile-creation scripts nest one level further under "child/lootboxes/…". This is a real, verified naming scheme, visible as literal string constants in the client, not a convention described for the article's sake.
Every child-scoped call explicitly passes which child app it means. A single hardcoded child app id (APP_CHILD_ID) answers "which app is a bitBuddy," and most calls additionally pass a profileId that answers "which specific buddy." Together, those two values are the client's half of the parent/child switch.
An empty child list is a normal state, not an error. A brand-new player has zero child profiles until they open their first loot box. The response handler checks for that and just refreshes the (empty) screen instead of treating it as a failure.
The actual profile switch happens server-side, inside cloud code, through IdentityService. The client never touches this directly. Every child-scoped script calls identityProxy.switchToChildProfile(profileId, appId, forceCreate) to make the session act as that child's profile for the rest of the script, does its work, then calls identityProxy.switchToParentProfile(parentIdentifier) to switch back before returning. That pair is the real mechanism behind "one login, multiple isolated save files": the session hops between Profiles mid-script, it doesn't merge them.
A first-time switch and a returning switch are different calls, not a flag.switchToChildProfile(null, appId, true) (forceCreate=true, no profile id) creates a brand-new child profile. switchToChildProfile(profileId, appId, false) switches to one that already exists. switchToSingletonChildProfile(appId, forceCreate) is a shortcut for apps where a parent only ever has one child profile in a given child app, and it fails with a specific reason code if more than one exists.
private void OnGetChildAccounts(string jsonResponse)
{
var data = jsonResponse.Deserialize("data", "response");
var children = data?.GetJSONArray("children");
if (children == null || children.Length == 0)
{
StateManager.Instance.RefreshScreen(); // new player, no buddy yet, not an error
_isProcessing = false;
return;
}
ReadChildrenInfo(children);
GetChildItemCatalog();
// ...
}
public const string GET_CHILD_ACCOUNTS_SCRIPT_NAME = "child/getChildProfiles";
public const string ADD_CHILD_ACCOUNT_SCRIPT_NAME = "child/addChildAccount";
public const string AWARD_STARTER_BUDDY_SCRIPT_NAME = "child/lootboxes/addStarterChildAccount";
public const string AWARD_BASIC_LOOTBOX_SCRIPT_NAME = "child/lootboxes/addBasicChildAccount";
public const string AWARD_RARE_LOOTBOX_SCRIPT_NAME = "child/lootboxes/addRareChildAccount";
public const string UPDATE_CHILD_PROFILE_NAME_SCRIPT_NAME = "child/updateChildAccountName";
public const string DELETE_CHILD_PROFILE_SCRIPT_NAME = "child/deleteChildProfile";
public const string CLAIM_CHILD_ITEM_SCRIPT_NAME = "child/claimMouseMerchantItem";
public const string APP_CHILD_ID = "50974";
The server-side switch itselfChildUtils
const MASTER_PARENT_NAME = "Master";
function switchToChildAccount(profileId, appId, identityProxy) {
var switchResponse = identityProxy.switchToChildProfile(profileId, appId, false);
return switchResponse.status == 200;
}
function switchToChildAppSafely(appId, identityProxy) {
var switchResponse = identityProxy.switchToSingletonChildProfile(appId, false);
if (switchResponse.status != 200) {
switch (switchResponse.reason_code) {
case 40208:
// no parent-child relationship created yet, force it
switchResponse = identityProxy.switchToSingletonChildProfile(appId, true);
break;
case 40372:
// run state disabled, nothing to do
break;
}
}
return switchResponse.status == 200;
}
Switching back before the script returnsChildUtils
function updateBuddyLevel(profileId, amountToIncrease, childAppId, identityProxy, playerStatProxy, playerStateProxy) {
var switchToChildResult = identityProxy.switchToChildProfile(profileId, childAppId, false);
if (switchToChildResult) {
// ... read the child's XP, increment it, check for a level-up ...// Return to parent
identityProxy.switchToParentProfile(MASTER_PARENT_NAME);
}
}
Gotchas worth knowing up front
APP_CHILD_ID is a single hardcoded constant, not per-buddy. All bitBuddies share one child app. What's per-buddy is the profileId passed alongside it. Confusing the two ("which app" versus "which profile within that app") sends a call to the wrong buddy.
switchToChildProfile and switchToParentProfile change what the current session is, not what data a query targets. Every service call made between those two lines operates on the child's Profile: reading stats, incrementing XP, checking currency, all of it. Forgetting the switchToParentProfile call at the end of a script leaves the session pointed at the child profile for anything that runs after it.
switchToSingletonChildProfile's failure reason code tells you what to do next, not just that it failed.40208 means no child profile exists yet for that app, the fix is retrying with forceCreate=true, not treating it as an error. 40372 means the app's run state is disabled, and the correct response is to do nothing, not retry.
Tier 6Lobby27 / 29
Long-lived Lobbies: less client code than you'd expect
Lobbies for the baseline matchmaking flow this article is a variant of.
There is no separate SDK call for "a lobby that outlives one match" versus "a lobby for one round." Lobby lifetime, TTL, and whether it disbands when the match starts are portal-configured per lobby type, not client parameters. The client-side flow for creating, joining, and reading a lobby is identical either way. What changes is which lobby type the client points at, and how it reads back what the server already decided.
How it works
Lifetime is a lobby-type property read back from the server, not a call the client makes. A lobby event's payload can include a lobbyTypeDef object with a rules.disbandOnStart flag (and a teams map). A UI that needs to know "does this lobby type end when the match starts" reads that flag; it can't be set from the client.
jsonSettings/config-override calls exist for per-instance tuning, but the meaning of that blob is server-defined.createLobbyWithConfig/createLobbyWithConfigAndPingData override a lobby type's default config for one specific instance. Which fields actually mean anything in that blob (TTL, keep-alive, which backing infrastructure) is entirely a portal/lobby-type concern, not something documented on the client method signature.
A "room browser" instead of algorithmic matchmaking is a different call, not a different lobby type.findLobby/findOrCreateLobby do algorithmic matchmaking: rating and rules decide who a player is placed with. getLobbyInstances/getLobbyInstancesWithPingData instead list visible, existing lobby instances so a player can browse and pick one directly. That's the natural fit for a persistent "come join my room" pattern, regardless of how long that room type lives.
The RelayTestApp/CursorParty rematch flow is this pattern in practice.END_MATCH only tears down the relay connection and per-round match state; it never calls leaveLobby/destroyLobby. From the Match Summary screen, lobby members call updateReady(false) and then updateReady(true) to queue for a rematch; once every current member has re-readied, the same lobby instance fires STARTING/ROOM_READY for the next round exactly as it did for the first, with no new findOrCreateLobby call and no new lobby. The lobby itself outlives any single match; only per-round state (relay connection, gameStartTimeMs, splotch canvas, coverage/scoreboard) resets between rounds.
Code
Reading lifetime back from a lobby event (JavaScript)
const lobbyTypeDef = result.data.lobby.lobbyTypeDef
if (lobbyTypeDef) {
state.disbandOnStart = lobbyTypeDef.rules.disbandOnStart
state.lobbyTeamNames = Object.keys(lobbyTypeDef.teams)
}
// lobbyTypeDef is only present on fuller events (e.g. MEMBER_JOIN).// Cache it the first time you see it; it's stripped from lighter events like MEMBER_UPDATE
Browsing existing instances instead of matchmaking (C++ / any SDK, same pattern)
pBCWrapper->getLobbyService()->getLobbyInstances(lobbyType, criteriaJson, callback);
// vs. findOrCreateLobby, which algorithmically places you rather than letting you pick
Gotchas worth knowing up front
What the reference ports don't demonstrate is cross-session persistence, not cross-round persistence. The rematch flow above (see "How it works" #4) is a real, working long-lived-lobby example: one lobby instance survives repeated END_MATCH → ready-up → STARTING cycles for as long as players keep queuing. None of the ports go further than that, though: nobody fully quits the app and reconnects to the same still-alive lobby later, so that narrower scenario (and the portal-side TTL/keep-alive config it depends on) is still unvalidated. Check current brainCloud docs before relying on it.
jsonSettings/config-override fields are opaque from the client's point of view. The SDK just passes through whatever JSON it's given; the portal's lobby-type definition decides what any of those fields actually do. Pull the field list from the current lobby-type config in the portal, not a guess based on the client method signature.
Tier 7: Server & Custom Infrastructure
A different audience: your own backend, not a game client
Everywhere else in this series, a player's client talks to brainCloud. Here, your own server does, either running arbitrary trusted logic or as a full custom room server brainCloud launches on demand.
Tier 7S2S28 / 29
Server-to-Server: your backend gets its own login, and a raw request format
Cloud Code, the pattern is the client-triggered equivalent of trusted server logic. This article is for when the trusted logic lives on a server you run yourself, outside brainCloud. Room Servers is the concrete case where you'll use this the most.
Everything in this series so far has been client code talking to brainCloud through a player's session. S2S is a different door: a backend authenticates as a server, not as a player, and gets full access to brainCloud's services through one generic request format. There's no per-service method wrapper the way the client SDKs have ScriptService.RunScript or LobbyService.findOrCreateLobby. A JSON object names the service and operation, and gets sent as-is. cpp-s2s's test suite and the csharp-examples/RSM room server both use exactly this pattern, so it's well proven across two languages.
How it works
Create a context with server credentials, not a player login.S2SContext::create(appId, serverName, serverSecret, serverUrl, autoAuth) in C++, or BrainCloudS2S.Init(appId, serverName, serverSecret, useShared, s2sUrl) in C#. These are app-level secrets issued for a specific server identity, not a player's email or anonymous id.
Decide up front whether auto-auth is wanted. That last boolean matters more than it looks. With auto-auth off, authenticateSync() (or Authenticate(callback)) must be called before sending any request, and re-authenticating after the session expires is the caller's responsibility. With auto-auth on, the library handles that. cpp-s2s runs its entire test suite twice, once with each setting, because the behavior differs enough to be worth testing separately.
Every call has the same generic format: service, operation, data. There's no getLobbyService()->something(). A request like {"service": "lobby", "operation": "GET_LOBBY_DATA", "data": {"lobbyId": lobbyId}} gets sent as that whole object. The service and operation names come from brainCloud's API reference, the same names a client SDK method wraps internally, just exposed directly here instead of hidden behind a method call.
Check status on the response directly. A raw S2S response comes back as JSON with a status field. There's no typed success or failure callback deciding that the way client SDK calls do. A status other than 200 should be treated as a failure and branched on accordingly.
Raw request JSON fails at runtime on a typo, not compile time. There's no IDE autocomplete to catch "service": "lobbby" the way a client SDK method call would. Check the exact service and operation strings against brainCloud's API reference before shipping a new call.
Auto-auth changes error-handling responsibilities, not just setup code. With it off, an expired session on a long-running server process is a bug the caller must catch and recover from. With it on, the library covers that case, but the case where auto-auth itself fails still needs handling.
This context is a server identity; treat it like any backend credential.S2SContext::create takes the app's server secret. That's not something to hand to a client build or check into a public repo.
A 200 status on the transport doesn't mean the requested operation succeeded. The same "transport succeeded, check the response body" rule from cloud code (see Article 5) applies here too. Read the actual response payload, not just whether the callback fired.
Tier 7RoomServerS2SLobby29 / 29
Room Servers: brainCloud calls your server, then your server calls back
S2S Basics is the request pattern the spun-up room server uses to talk back to brainCloud. Multiplayer / Relay is what you'd use instead if you don't need a custom server at all.
Everywhere else in this series, brainCloud's relay servers handle the networking directly. Room Servers are for when that's not enough, when a dedicated process needs to run real game logic, not just message fan-out. The pattern is a round trip: a lobby is configured to launch a custom server, brainCloud calls a webhook on that infrastructure to ask for one, the server spins up and answers with connect info, and from that point the server is a first-class brainCloud citizen too, authenticating over S2S the same way any backend would. csharp-examples/RSM (Room Server Manager) is the reference for the launch side. The client side reuses the exact same lobby flow as Relay; it just connects a raw socket to the game's server address instead of brainCloud's.
How it works
brainCloud requests a server by POSTing to a webhook. The request hits /requestRoomServer with a lobby id in the body. The webhook's job is to validate the request, spin up (or find) a process to host that lobby, and respond with where to reach it.
The response tells brainCloud, and eventually the client, where to connect. The reply is {lobbyId, connectInfo: {roomId, url, ...}}. This is the same format the client later receives in the lobby's ROOM_READY event; brainCloud simply relays the answer forward.
The spun-up server authenticates over S2S, using the pattern from the previous article. Once the game server process is running, it's not special-cased. It creates an S2S context with its own app credentials and authenticates like any other backend.
Some deployments need a readiness handshake before the server accepts players.BrainCloudS2SPrl.IsPreReadyLaunch() gates whether the server needs to run a pre-ready launch sequence before it requests lobby data and starts accepting connections. Check that flag before assuming it's safe to jump straight to GET_LOBBY_DATA.
The client connects the same way it would for Relay, just to a different address. It watches for the lobby's ROOM_READY event exactly like the Relay article describes, then opens a raw TCP socket, using the same IRelayTCPSocket helper the SDK ships for relay connections, but pointed at the game's server address and port instead of brainCloud's.
Code
The webhook that answers brainCloud's launch requestRSM.cs:19-68
The client side: same ROOM_READY event as Relay, a raw socket insteadmain.cpp:164-186
if (operation == "ROOM_READY")
{
serverConnectionInfo = json["data"];
connectToServer();
}
void connectToServer()
{
auto address = serverConnectionInfo["connectData"]["address"].asString();
auto port = serverConnectionInfo["connectData"]["ports"]["7777/tcp"].asInt();
unique_ptr<IRelayTCPSocket> tcpSocket(IRelayTCPSocket::create(address, port));
while (!tcpSocket->isConnected())
{
if (!tcpSocket->isValid())
{
printf("Socket connection failed\n");
isGameRunning = false;
}
tcpSocket->updateConnection();
this_thread::sleep_for(10ms);
}
}
Gotchas worth knowing up front
This is Lobby plus a raw socket handoff, not a separate matchmaking system. Everything from Article 22 about findOrCreateLobby and portal-configured lobby types still applies. Room Servers change what happens after the lobby is ready, not how players find each other.
The webhook has to respond fast, or the client waits.RSM.cs here is deliberately minimal: listen, validate, spin up, respond. A real deployment needs to handle concurrent requests and probably a process pool, not a listener that blocks on one connection at a time the way this reference does.
Check IsPreReadyLaunch() before assuming lobby data can be requested immediately. Skipping the PRL handshake when a deployment requires it means the server tries to read lobby state it isn't cleared to see yet.
The connectData port key is literal, not a placeholder.ports["7777/tcp"] in the client code reads a specific port the server declared it's listening on. A room server listening on a different port needs that key to match, or the client ends up asking for a port that was never offered.
The webhook secret and the S2S server secret are two different credentials with two different jobs. The webhook validates that the incoming launch request actually came from brainCloud. The S2S secret authenticates the spun-up game server back to brainCloud afterward. Securing one doesn't cover the other.
Appendix
Documented, not yet exercised
Features with real API surface and real documentation, but no reference app in this repo uses them yet. Included for review, cited to brainCloud's own API docs rather than to a repo file and line.
Appendix ACampaign
Campaign: schedule the change once, let it start and stop itself
Read this one differently than the rest of the series. Every other article here cites real, working code from a reference app in this repo. This one can't, because nothing in this repo uses brainCloud's Campaign service, checked against both the client source and the actual cloud-code scripts running in the reference apps' brainCloud accounts. It's written up anyway, for review purposes, grounded directly in brainCloud's own API documentation and verified live against it (via the brainCloud MCP server), not written from memory of what Campaign probably does. Treat the code below as "this is the documented structure of the API," not "this is how our apps use it."
Campaign is brainCloud's mechanism for running a scheduled promotion or A/B test without a build or a manual switch once it's live. A campaign is configured in the portal once: a start and end time, the player segment it targets, one or more weighted scenarios (a scenario is a variant, and one of them can be the untouched "control" group), and a set of overrides each scenario applies. From that point, the campaign runs itself: it starts on schedule, enrolls eligible players into a scenario, applies that scenario's overrides to what those players see, tracks basic engagement metrics per player, and expires on schedule. The client's whole job is one call after login to ask "what am I enrolled in," then apply whatever comes back exactly the way it already applies Global Properties.
How it works
A campaign's overrides sit directly on top of systems this series already covers. The response from GetMyCampaigns carries an overrides block that can replace values in globalProperties (the same system Article 11 reads directly), cashProducts (real-money price points, the App Store side of Article 10), and items (virtual-currency buyPrice, the Item Catalog side of the same article). A campaign isn't a fourth, separate config system. It's a scheduled, segment-targeted layer that temporarily overrides the three systems already covered.
The client makes one call, typically right after login, and gets back everything it needs to apply those overrides.GetMyCampaigns returns every campaign the current player is enrolled in, including which scenario the player landed in, the campaign's own free-form campaignJson payload, the scenario's own scenarioJson payload, and the overrides block itself. There's no separate "check eligibility" call; enrollment already happened server-side before this response exists.
Which scenario a player lands in is a weighted, one-time assignment, not something the client requests. A campaign can define several scenarios, one of which can be a "control" variant that changes nothing, and the server randomly assigns each newly eligible player to one, honoring whatever weights the portal has configured. Once assigned, a player stays in that scenario for the life of the campaign.
Live edits to a campaign's payload don't require a version bump or a client update.SysUpdateCampaignJson and SysUpdateScenarioJson (Cloud Code / S2S only) replace just the free-form JSON payload, leaving the schedule, targeting, and scenario weights untouched. Already-authenticated players pick up the new payload on their very next GetMyCampaigns call. That's the mechanism for hot-fixing campaign copy or config mid-run, without touching the campaign's actual A/B structure.
Forcing a specific player into a specific scenario is a QA tool, not a general-purpose override, and it's gated behind a real profile flag.SysTriggerCampaignForUser can push a chosen scenario onto a specific player, bypassing the normal weighted assignment, but only if that player's profile already has isTester=true set. There's no way to force-enroll an arbitrary live player. A QA flow built around this needs PlayerState.UpdateIsTester (Article 8) run against the test account first.
This is a billing-gated feature, not just an SDK class everyone can use. Every Campaign method's error list carries the same code: FEATURE_NOT_SUPPORTED_BY_BILLING_PLAN, requiring a plan that includes Enterprise features. That's worth knowing before designing around it. A reference app on a lower-tier plan couldn't exercise this even if someone wrote the client code today.
Code
The one client call: ask what campaigns you're enrolled in (JavaScript)
var optionsJson = {};
_bc.campaign.getMyCampaigns(optionsJson, result =>
{
var status = result.status;
console.log(status + " : " + JSON.stringify(result, null, 2));
});
What comes back, trimmed to the interesting fields
Hot-fixing a campaign's payload without touching its schedule or targeting (Cloud Code)
var campaignCode = "CHRISTMAS2026";
var version = -1; // -1 = apply regardless of the current version
var campaignJson = { "theme": "winter", "bannerId": 7 };
var campaignProxy = bridge.getCampaignServiceProxy();
var postResult = campaignProxy.sysUpdateCampaignJson(campaignCode, version, campaignJson);
// postResult.data.version is the new version; feed it back in next time// if you want a concurrent-edit check instead of a blind overwrite
Forcing a QA account into a specific scenario (Cloud Code)
var profileId = "aProfileId"; // this profile must already have isTester=true
var campaignCode = "CHRISTMAS2026";
var optionsJson = {
"ignoreEnabled": false,
"ignoreScheduleStart": false,
"ignoreControl": false,
"scenarioCode": "a" // skip weighted assignment, force this exact scenario
};
var campaignProxy = bridge.getCampaignServiceProxy();
var postResult = campaignProxy.sysTriggerCampaignForUser(profileId, campaignCode, optionsJson);
Best practices worth flagging up front
Design the overrides against systems the client already has a read path for. A client that doesn't already know how to apply a globalProperties override (Article 11) or a catalog/store price override (Article 10) gains a fourth thing to build by adding Campaign on top, not a free one. Get those two solid first.
campaignJson is broadcast to every enrolled player, so it shouldn't hold anything a client isn't meant to see. It's returned in full on every GetMyCampaigns call. There's also a real size cap, enforced server-side, so it should stay a small config payload, not a place to stash content.
Use the version parameter as an optimistic-concurrency check once more than one person can edit a campaign. Passing the campaign's last-known version instead of -1 means a concurrent edit from the portal (or another script) surfaces as CAMPAIGN_VERSION_MISMATCH instead of silently getting overwritten.
Flag the test account isTester=true before touching anything Campaign-specific.SysTriggerCampaignForUser refuses to run against a player who isn't flagged. Set that in test setup before the scenario-forcing step, or the force-enroll call fails with a permissions-shaped error that has nothing to do with the campaign itself.
Confirm the billing plan actually includes Campaign before designing a feature around it.FEATURE_NOT_SUPPORTED_BY_BILLING_PLAN is the one error code every single method in this service shares. It's an Enterprise-tier feature, not something available by default.