Player Stats & XP: leveling with one native call, no script required
Authentication for what runs right before this; Cloud Code, the pattern for the approach several later articles switch to.
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
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!");
}
}public void ParentReceivesXP(int xpMultiplier = 1)
{
if (xpMultiplier < 1)
{
xpMultiplier = 1;
}
Wrapper.PlayerStatisticsService.IncrementExperiencePoints(PARENT_PINK_STAR_INCREASE_AMOUNT * xpMultiplier,
HandleSuccess("LevelUpParent Success", OnLevelUpParent),
HandleFailure("LevelUpParent Failed", OnFailureCallback));
}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;
}
}
}BrainCloudManager.Client.PlayerStatisticsService.IncrementUserStats(statData.Serialize());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.