View as Markdown

How to implement a player XP system

An XP system is simple in principle: players earn points, cross a threshold, and level up. That simplicity hides a surprising amount of design complexity. How you model XP and levels, how you count thresholds, when rewards are granted, and how you rebalance the progression curve without penalizing existing players are all decisions that are difficult to change once your game is live.

In this guide, you’ll see how to implement an XP and levels system in Hiro step by step, from design decisions to an end-to-end working sample project.

Before you begin #

You’ll need:

  • Familiarity with Hiro’s Achievements system, since this guide models player levels as achievements.
  • Familiarity with Hiro’s Economy system, since this guide models XP as a currency.
  • Familiarity with Hiro publishers, used to distribute XP into player levels.
  • A Hiro project with the Economy and Achievements systems initialized. See Getting started.

In this guide #

Modelling XP and levels #

XP and levels can be modeled using a combination of Stats, Progression, Currency and Achievement systems. The right approach depends on your game’s specific needs.

For most games with a linear progression track, we recommend modeling XP and levels using currencies and achievements.

Why model XP as a currency #

In Hiro, a currency is any named balance you want to track per player: coins, gems, etc. Once defined, a currency can be earned as a reward from completing any number of actions in Hiro, like claiming an achievement, ranking on a leaderboard, or making store purchases.

Modeling XP as a currency gives you:

  • Reward pipeline integration: any Hiro system that issues rewards can grant XP.
  • Single source of truth: The lifetime total XP is always available in the wallet as a single value with no separate record to query, making it straightforward to surface in UI.
  • Reward modifier support: XP grants can be multiplied by modifiers such as a 2x boost, configured using reward modifiers.

Why model levels as achievements #

The achievements system in Hiro tracks a player’s progress toward a goal. When progress hits the target, the system grants a reward and marks the goal complete. Achievements can also be nested. A parent achievement holds a set of child goals, each with their own target and reward, completed in sequence.

This structure maps directly onto a level track. Each player level is an XP goal with a reward. Completing one unlocks the next.

  player_levels  (parent achievement)
  │
  ├── level_2   XP target: 100   reward: 20 gems   ─┐
  │                                                  │  sequential:
  ├── level_3   XP target: 200   reward: 20 gems   ─┤  completing one
  │                                                  │  unlocks the nex
  └── level_4   XP target: 400   reward: 50 gems   ─┘

Modeling levels as achievements gives you:

  • Automatic reward and unlock handling: once a level’s XP threshold is reached via UpdateAchievements, Hiro automatically grants the level-up reward, marks the level complete, and unlocks the next one. No custom reward or unlock logic needed.
  • Declarative rewards: level-up rewards are defined in config alongside the level itself. No separate reward logic to write or maintain.
  • Easy to rebalance the XP curve: the max_count values across sub-achievements are the XP curve. Adjusting them is how designers control progression pacing. Editing max_count values in base-achievements.json is a config change with no code changes.
  • Demotion protection by design: achievement completions are permanent records. A player who reached level 10 remains at level 10 after a rebalance. Only players who have not yet completed that level are affected.

There’s some custom logic required in your server RPC before calling UpdateAchievements: calculating how much XP to apply to each level and carrying any overflow into the next. The implementation section walks through this in full.

Comparing with modelling XP using Stats #

A pure Stats-based approach is also possible: track both XP and the player’s current level as Stats, incrementing them directly in your server code instead of introducing a currency and an achievement tree. The tradeoffs are the same ones covered above, in reverse. A Stat can’t appear in a reward block, so every source that grants XP needs its own server call rather than using the Hiro reward pipeline. Stats also carry no threshold or completion semantics, so your code has to detect level-up crossings, grant rewards, and guard against re-granting them, work that UpdateAchievements already handles for you with the achievements approach. Stats still have a place alongside achievements, for example exposing level as a public stat on a player’s profile, or as a stats_min precondition for a Progression node, but they aren’t a substitute for tracking XP and levels in the first place.

Implementation #

This section walks through building the full XP grant flow from config to a working server RPC. For the full working example, see our end-to-end sample project with both server and client implementations.

Key steps:

  1. Define the configuration: XP currency + level achievements
  2. Grant XP: via reward objects or a direct RPC
  3. Distribute XP to player levels: publisher + carry-over loop
  4. Read XP state on the client: query Economy and Achievements systems
Step 1

Define the configuration #

The data model section above covers the design reasoning. Here’s what you need in JSON configuration files before writing any server code.

Define xp as a currency #

Add xp to initialize_user.currencies:

base-economy.json
1
2
3
4
5
6
"initialize_user": {
    "currencies": {
        "xp": 0,
        "gems": 0
    }
}

Define levels as sub-achievements #

Each player level is a sub-achievement inside a parent player_levels achievement. For example, for a linear level progression where every level requires the same 100 XP gap to complete, you would define each level as follows:

1
2
3
4
5
6
"sub_achievements": {
    "level_2": { "max_count": 100, "auto_claim": true },
    "level_3": { "max_count": 100, "auto_claim": true, "precondition_ids": ["level_2"] },
    "level_4": { "max_count": 100, "auto_claim": true, "precondition_ids": ["level_3"] },
    "level_5": { "max_count": 100, "auto_claim": true, "precondition_ids": ["level_4"] }
}
FieldWhat it means
max_countThe XP needed to complete that level as a delta from the previous level, not a cumulative lifetime total. You can also implement this as cumulative XP. See the comparison of delta vs cumulative XP thresholds.
auto_claimWhen set to true, grants the level-up reward automatically when the threshold is reached.
precondition_idsChains levels in order so only the active level advances.

The full base-achievements.json: player_levels parent achievement with a sub-achievement for each level:

Step 2

Granting XP #

Because XP is modeled as a currency (see Why model XP as a currency), there are two ways to grant it:

Both pathways fire a currencyGranted event, which you’ll use in Step 3 to advance player levels.

Diagram showing two pathways for granting XP to players and how both lead to level advancement. Path A shows XP granted as a reward through Hiro reward objects from systems such as achievements, event leaderboards, and rewarded videos. Path B shows XP granted directly via a server RPC called by the game client. Both pathways fire a currencyGranted event from Hiro's internal systems, which a publisher listens for to trigger level progression updates.

Grant Path A: XP given as Hiro rewards #

Using the reward pipeline integration covered earlier, any Hiro system that supports rewards can grant XP directly, no RPC code required. For example, a quest configured as a Hiro achievement can define XP inside its reward JSON:

In this case, the achievement system handles the grant at the right moment when the conditions are met. Other Hiro systems where you can grant XP via a reward object include event leaderboards, challenges, rewarded videos.

To see this in action, open the sample project’s Main scene and select the Quests tab. Complete Silence the Storm Heralds and claim its reward: Total XP increases from 0 to 50. Next, complete Hold the Thunderpass: its reward includes a temporary XP booster, which appears as a countdown next to XP Booster in the header. Any XP granted while that booster is active is doubled.

Quests tab in the sample project showing Hold the Thunderpass claimed with a 50 XP reward and an active XP Booster countdown

Grant Path B: Direct grant through game logic #

Another way to grant XP is to manually trigger an XP increase from gameplay logic, for example, from killing an enemy or any gameplay event. In this case, you can perform the granting logic on the server, in a rpc_grant_xp RPC, which is called by your game client directly.

1. Register the RPC #

In main.go, register the handler inside InitModule after hiro.Init() returns. Both WithEconomySystem and WithAchievementsSystem must be enabled:

2. Implement the RPC handler #

Create a struct to model the JSON payload the client sends when calling this RPC. In this case, it’s only the XP amount.

1
2
3
type grantXPRequest struct {
	Amount int64 `json:"amount"`
}

Inside the handler, there are two ways to apply the XP update to the economy system: direct call of Economy.Grant and wrapping the XP gain in a Hiro Reward object.

Calling Economy.Grant directly #

This is the simplest approach. Update the wallet with the exact amount of XP to change:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func rpcGrantXP(systems hiro.Hiro) func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
		if req.Amount <= 0 {
			return "", runtime.NewError("amount must be greater than 0", 3)
		}
        // update wallet with XP amoun
		if _, _, _, err := systems.GetEconomySystem().Grant(ctx, logger, nk, userID, map[string]int64{"xp": req.Amount}, nil, nil, nil); err != nil {
			return "", err
		}

		return "{}", nil
	}
}
Calling Economy.Grant directly bypasses the Hiro reward pipeline. If there’s an active reward modifier for XP, it’s ignored. This approach is best used when you don’t want reward modifiers to affect certain XP gain pathways.

Wrapping XP as a Hiro reward #

Alternatively, take advantage of reward modifiers by granting XP through the reward pipeline. This means dynamically creating a Hiro reward object inside the RPC:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
    econ := systems.GetEconomySystem()
	rewardConfig := econ.RewardCreate()
	rewardConfig.Guaranteed = &hiro.EconomyConfigRewardContents{
		Currencies: map[string]*hiro.EconomyConfigRewardCurrency{
			"xp": {EconomyConfigRewardRangeInt64: hiro.EconomyConfigRewardRangeInt64{Min: req.Amount, Max: req.Amount}},
		},
	}

	reward, err := econ.RewardRoll(ctx, logger, nk, userID, rewardConfig)
	if err != nil {
		return "", err
	}

	if _, _, _, err := econ.RewardGrant(ctx, logger, nk, userID, reward, nil, false); err != nil {
		return "", err
	}

The sample project that accompanies this guide uses this approach. In the project, once an XP booster is activated, all XP gains are doubled for the duration defined in the reward modifier.

3. Call the RPC from the client #

With the RPC handler implemented, when a player earns XP, your game client calls rpc_grant_xp with the delta XP amount. The RPC payload requires a single field:

1
{ "amount": 150 }

Call the RPC using your Nakama client session:

1
2
// Unity (C#)
var response = await client.RpcAsync(session, "rpc_grant_xp", "{\"amount\": 150}");

To see this in action, open the sample project’s Main scene and select the Direct XP Grant tab. Enter an amount and select Earn XP: Total XP increases by that amount. The sample project’s rpc_grant_xp wraps the grant as a Hiro reward (see Wrapping XP as a Hiro reward above), so if you grant XP again while an XP booster is active from the Quests tab, the amount added to Total XP is doubled.

Direct XP Grant tab in the sample project showing an XP amount entered and the Earn XP button
When a currency such as XP is granted as a reward from another system (for example, a quest modeled as an achievement), the client’s economy state isn’t automatically refreshed. Call economySystem.RefreshAsync() after the granting action to ensure the updated XP balance is reflected on the client.
Step 3

Distributing XP to player levels #

In the previous step, you completed the client-to-server loop by updating the wallet with XP gains. You now need to pass this XP gain to the level system so players can level up. In step 1 you structured levels in the achievement JSON config. The next step is to update the achievement system with the XP gains.

Applying the level update directly inside the grant RPC breaks when a reward modifier is active: the RPC only sees the pre-modifier amount (for example, 50 XP from a quest reward), not the post-modifier wallet delta (100 XP, after a 2x booster). A publisher listening for the currencyGranted event always receives the post-modifier delta, and handles every XP grant pathway, reward or RPC, from a single place.

Inside the Publisher #

Inside the publisher, filter for the currencyGranted event and parse event metadata for the delta XP value. Then call a function to handle the level updates.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func (p *XPLevelPublisher) Send(ctx context.Context, logger runtime.Logger, nk runtime.NakamaModule, userID string, events []*hiro.PublisherEvent) {
	for _, event := range events {
		if event.Name != "currencyGranted" || event.Metadata["currencyId"] != "xp" {
			continue
		}
		xp, err := strconv.ParseInt(event.Value, 10, 64)
		if err != nil || xp <= 0 {
			continue
		}
		if err := advanceLevelsFromXP(ctx, logger, nk, p.achievements, userID, xp); err != nil {
			logger.WithField("error", err.Error()).Error("advanceLevelsFromXP failed")
		}
	}
}

Advance player levels #

Inside the function to advance levels, a few steps need to take place:

  • Fetch the current level state
  • Run the carry-over loop, walking levels in order
  • Call UpdateAchievements

UpdateAchievements takes a plain Go map of level IDs to XP amounts. The API itself has no concept of order. You need to prepare that map correctly before handing it to Hiro.

Fetch the current level state. You need to know how much progress each level already has (Count) and how much it still needs to complete (MaxCount), so read the player’s full achievement state first.

1
2
achievements, _, err := systems.GetAchievementsSystem().GetAchievements(ctx, logger, nk, userID)
playerLevels, ok := achievements["player_levels"]

Run the carry-over loop. A single XP grant can cross multiple level thresholds, so you need to distribute XP correctly across levels rather than applying the full amount to just one. Without this, XP would be lost. Levels are keyed level_1, level_2, and so on, so walk them in order by constructing each key directly rather than sorting the map, give each one only what it needs to complete, and carry the remainder into the next. A missing key marks the end of the defined levels (or a malformed config), and stops the loop.

For example with a 200 XP grant:

  • level_3 needs 50 XP to complete → apply 50, carry 150
  • level_4 needs 100 XP to complete → apply 100, carry 50
  • level_5 has 200 XP threshold, partially fills → apply 50, carry 0

Call UpdateAchievements. Once the map is ready, a single call sends all level progress to Hiro. It’s only called if there’s something to update. Hiro then handles reward granting, marking each level complete, and unlocking the next.

1
2
3
if len(updates) > 0 {
    systems.GetAchievementsSystem().UpdateAchievements(ctx, logger, nk, userID, updates)
}

The full main.go for this guide is below (download). To test it out in a fully working example with client integrations, see the Player XP sample project.

Step 4

Reading XP state on the client #

The server handles all XP logic. The client reads from two Hiro systems to display the player’s current state:

  • EconomySystem: the player’s wallet, which holds the xp currency total
  • AchievementsSystem: the player_levels achievement group, which holds per-level progress

Read total XP #

Total XP lives in the wallet as the "xp" currency. Read it from EconomySystem.Wallet after a refresh:

1
long totalXP = economySystem.Wallet.TryGetValue("xp", out var xp) ? xp : 0;

This is the lifetime accumulated XP. It never decreases or resets.

Read the current level #

Each level sub-achievement has a ClaimTimeSec field. When a level’s XP threshold is reached, Hiro auto-claims it and sets ClaimTimeSec to the Unix timestamp of that event. A value greater than zero means the level is complete.

To find the player’s current level, walk the sub-achievements in order and track the highest level number where ClaimTimeSec > 0:

The sub-achievements come back as an unordered map, so sorting numerically (not alphabetically) is important: without it, level_10 sorts before level_2.

Read progress to the next level #

Each level sub-achievement has two fields that describe its progress:

  • Count: the XP the player has accumulated toward this level so far
  • MaxCount: the total XP required to complete this level

The next level to complete is the first sub-achievement in order where ClaimTimeSec is still zero. Return its Count and MaxCount as a tuple so the caller can drive a progress bar directly:

Call the method, then derive the percentage from the returned values:

1
2
var (progressCurrent, progressMax) = GetProgressToNextLevel();
float pct = progressMax > 0 ? (float)progressCurrent / progressMax * 100f : 0f;

Complete XPController #

The sample project includes a full XPController class that wraps all of the above into one place (download). Use it as a starting point for your own game.

Advanced topics #

The base implementation above is enough to run a working XP and levels system. The topics below aren’t required to complete that implementation, but are worth understanding if you plan to rebalance your XP curve or run experiments on it.

Delta vs cumulative XP thresholds #

This guide uses the delta approach throughout: each level’s max_count is the XP gap from the previous level. There’s an alternative, the cumulative approach, where max_count is the total lifetime XP required to reach that level.

How each approach works #

Delta

Each sub-achievement starts its count at zero when it unlocks. The player must earn exactly max_count XP past the previous level to complete it. precondition_ids is required to prevent completed levels from absorbing future grants.

1
2
3
"level_2": { "max_count": 100 },
"level_3": { "max_count": 200, "precondition_ids": ["level_2"] },
"level_4": { "max_count": 400, "precondition_ids": ["level_3"] }

Cumulative XP to reach level 4: 100 + 200 + 400 = 700.

Cumulative

Each sub-achievement’s max_count is the total lifetime XP the player must have earned. No precondition_ids needed; every level receives the same grant, and the internal cap at max_count acts as the threshold gate.

1
2
3
"level_2": { "max_count": 100 },
"level_3": { "max_count": 300 },
"level_4": { "max_count": 700 }

The values map directly to total XP earned, so designers can read the curve at a glance.

Grant code differences #

The carry-over loop described in Advance player levels is specific to the delta approach. With delta max_count, broadcasting the same grant amount to all levels causes overflow to be silently discarded at each threshold. The loop applies only what each level can absorb and carries the remainder forward.

With cumulative max_count, you pass the full grant to all levels in a single call:

1
2
3
4
5
6
levelUpdates := map[string]int64{
    "level_2": xpAmount,
    "level_3": xpAmount,
    "level_4": xpAmount,
}
systems.GetAchievementsSystem().UpdateAchievements(ctx, logger, nk, userID, levelUpdates)

No carry-over arithmetic needed. The internal cap handles threshold detection for each level independently.

Rebalancing and migration #

ScenarioDeltaCumulative
Raise a thresholdClean, only affects players not yet at that levelClean
Lower a thresholdPlayers advance on their next XP grantPlayers advance on their next XP grant
Add new levels above all players’ current XPNo migration needed, count starts at zero naturallyMigration required; broadcast wallet["xp"] to new levels
Add new levels below some players’ current XPMigration needed; must reconstruct cumulative totals per playerMigration needed; broadcast wallet["xp"] directly
Never use precondition_ids with cumulative max_count. Preconditions block a level from receiving any count update until its predecessor completes. With cumulative thresholds, this causes a level’s count to permanently lag behind the player’s lifetime XP total.

Integrating with LiveOps #

Once the base XP system is running, you can use the Satori Personalizer to live-tune the progression curve.

A/B testing XP curves #

Override max_count values per cohort via a Satori feature flag on the Hiro-Achievements config key. One cohort gets a faster early curve (lower max_count on levels 2–5); the control group gets the base config. Satori handles cohort assignment. Compare D7 retention and level 5 reach rate between groups in the Satori experiment dashboard.

Segment-specific XP boosts #

Define a reward modifier on the XP currency in economy config. Use a Satori feature flag targeting an audience (at-risk churners, new players in their first week) to override the modifier value for that segment. Players outside the audience receive the base config.

See also #