# Player XP and leveling

**URL:** https://heroiclabs.com/docs/hiro/guides/gameplay-mechanics/player-xp/
**Keywords:** xp, experience points, leveling, progression, player xp
**Categories:** hiro, guide, gameplay-mechanics

---


# 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](https://github.com/heroiclabs/sample-projects/tree/main/guides/PlayerXP).

## Before you begin

You'll need:

- Familiarity with Hiro's [Achievements system](/hiro/concepts/achievements/), since this guide models player levels as achievements.
- Familiarity with Hiro's [Economy system](/hiro/concepts/economy/virtual-currencies/), since this guide models XP as a currency.
- Familiarity with Hiro [publishers](/hiro/concepts/publishers/), used to distribute XP into player levels.
- A Hiro project with the Economy and Achievements systems initialized. See [Getting started](/hiro/concepts/getting-started/install/).

## In this guide

- [Data model: how XP maps to Hiro systems](#data-model-how-xp-maps-to-hiro-systems)
- [Implementation](#implementation)
- [Advanced topics](#advanced-topics)
  - [Delta vs cumulative XP thresholds](#delta-vs-cumulative-xp-thresholds)
  - [Integrating with LiveOps](#integrating-with-liveops)

## Modelling XP and levels

XP and levels can be modeled using a combination of [Stats](/hiro/concepts/stats/), [Progression](/hiro/concepts/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](/hiro/concepts/economy/virtual-currencies/) and [achievements](/hiro/concepts/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](/hiro/concepts/economy/rewards/#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](#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](https://github.com/heroiclabs/sample-projects/tree/main/guides/PlayerXP) with both server and client implementations.

**Key steps:**

1. [Define the configuration](#define-the-configuration): XP currency + level achievements
2. [Grant XP](#granting-xp): via reward objects or a direct RPC
3. [Distribute XP to player levels](#distributing-xp-to-player-levels): publisher + carry-over loop
4. [Read XP state on the client](#reading-xp-state-on-the-client): query Economy and Achievements systems

{{< pretitle "Step 1" >}}

### Define the configuration

The [data model section](#data-model-how-xp-maps-to-hiro-systems) 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`:

{{< code type="server" filename="base-economy.json" hideable=false >}}

```json
"initialize_user": {
    "currencies": {
        "xp": 0,
        "gems": 0
    }
}
```

{{< /code >}}

#### 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:

```json
"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"] }
}
```

| Field | What it means |
|---|---|
| `max_count` | The 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](#delta-vs-cumulative-xp-thresholds). |
| `auto_claim` | When set to `true`, grants the level-up reward automatically when the threshold is reached. |
| `precondition_ids` | Chains 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:

{{< accordion title="base-achievements.json" >}}
```json
"player_levels": {
    "name": "Player Level",
    "description": "Earn XP to level up.",
    "max_count": 1,
    "sub_achievements": {
        "level_1": {
            "name": "Level 1",
            "max_count": 100,
            "auto_claim": true,
            "reward": {
                "guaranteed": { "currencies": { "gems": { "min": 10 } } }
            }
        },
        "level_2": {
            "name": "Level 2",
            "max_count": 200,
            "precondition_ids": ["level_1"],
            "auto_claim": true,
            "reward": {
                "guaranteed": { "currencies": { "gems": { "min": 20 } } }
            }
        },
        "level_3": {
            "name": "Level 3",
            "max_count": 300,
            "precondition_ids": ["level_2"],
            "auto_claim": true,
            "reward": {
                "guaranteed": { "currencies": { "gems": { "min": 30 } } }
            }
        },
        "level_4": {
            "name": "Level 4",
            "max_count": 400,
            "precondition_ids": ["level_3"],
            "auto_claim": true,
            "reward": {
                "guaranteed": { "currencies": { "gems": { "min": 40 } } }
            }
        },
        "level_5": {
            "name": "Level 5",
            "max_count": 500,
            "precondition_ids": ["level_4"],
            "auto_claim": true,
            "reward": {
                "guaranteed": { "currencies": { "gems": { "min": 50 } } }
            }
        },
        "level_6": {
            "name": "Level 6",
            "max_count": 600,
            "precondition_ids": ["level_5"],
            "auto_claim": true,
            "reward": {
                "guaranteed": { "currencies": { "gems": { "min": 60 } } }
            }
        },
        "level_7": {
            "name": "Level 7",
            "max_count": 700,
            "precondition_ids": ["level_6"],
            "auto_claim": true,
            "reward": {
                "guaranteed": { "currencies": { "gems": { "min": 70 } } }
            }
        },
        "level_8": {
            "name": "Level 8",
            "max_count": 800,
            "precondition_ids": ["level_7"],
            "auto_claim": true,
            "reward": {
                "guaranteed": { "currencies": { "gems": { "min": 80 } } }
            }
        },
        "level_9": {
            "name": "Level 9",
            "max_count": 900,
            "precondition_ids": ["level_8"],
            "auto_claim": true,
            "reward": {
                "guaranteed": { "currencies": { "gems": { "min": 90 } } }
            }
        },
        "level_10": {
            "name": "Level 10",
            "max_count": 1000,
            "precondition_ids": ["level_9"],
            "auto_claim": true,
            "reward": {
                "guaranteed": { "currencies": { "gems": { "min": 100 } } }
            }
        }
    }
}
```
{{< /accordion >}}


{{< pretitle "Step 2" >}}

### Granting XP

Because XP is modeled as a currency (see [Why model XP as a currency](#why-model-xp-as-a-currency)), there are two ways to grant it:

- [Grant Path A: XP given as Hiro rewards](#grant-path-a-xp-given-as-hiro-rewards): any Hiro system that supports rewards can grant XP automatically when a player earns that reward.
- [Grant Path B: Direct grant through game logic](#grant-path-b-direct-grant-through-game-logic): trigger the XP increase directly from an RPC called by your game client.

Both pathways fire a `currencyGranted` event, which you'll use in [Step 3](#distributing-xp-to-player-levels) to advance player levels.

{{< diagram src="/images/pages/hiro/guides/xp-levels/XP_grant_pathways.svg" desc="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](#why-model-xp-as-a-currency) 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:

{{< accordion title="base-achievements.json with XP as reward" >}}
```json
"achievements": {
        "defeat_thunder_world": {
            "name": "Defeat Thunder World",
            "description": "Complete the Thunder World quest chain.",
            "category": "quests",
            "auto_reset": false,
            "auto_claim_total": false,
            "count": 0,
            "max_count": 1,
            "sub_achievements": {
                "clear_goblin_camp": {
                    "name": "Silence the Storm Heralds",
                    "description": "A storm cult has taken root in the Ashfen Marshes, channelling elemental power to call down lightning strikes across the region. Disrupt their ritual sites and scatter the worshippers before the summoning is complete.",
                    "auto_claim": false,
                    "auto_reset": false,
                    "count": 0,
                    "max_count": 1,
                    "reward": {
                        "guaranteed": {
                            "currencies": {
                                "xp": { "min": 50 }
                            }
                        }
                    }
                }
			}
		}
}
```
{{< /accordion >}}

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](https://github.com/heroiclabs/sample-projects/tree/main/guides/PlayerXP)'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.

{{< screenshot
src="images/pages/hiro/guides/xp-levels/xp_guide_reward_path.png"
alt="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:
{{< accordion title="Register RPC in main.go" >}}
```go
systems, err := hiro.Init(ctx, logger, nk, initializer, binPath, hiroLicense,
    hiro.WithEconomySystem(fmt.Sprintf("definitions/%s/base-economy.json", env), true),
    hiro.WithAchievementsSystem(fmt.Sprintf("definitions/%s/base-achievements.json", env), true),
    // ... other systems
)
if err != nil {
    return err
}

if err := initializer.RegisterRpc("rpc_grant_xp", rpcGrantXP(systems)); err != nil {
    return err
}
```
{{< /accordion >}}

#### 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.
```go
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:

```go
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
	}
}
```
{{< note "important" >}}
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.
{{< /note >}}

#### 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:
```go
    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](https://github.com/heroiclabs/sample-projects/tree/main/guides/PlayerXP) 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.
{{< accordion title="Snippet from base-achievements.json showing reward modifier definition" >}}
```json
            "reward": {
                        "guaranteed": {
                            "currencies": {
                                "xp": { "min": 50 },
                                "gems": { "min": 10 }
                            },
                            "reward_modifiers": [
                                {
                                    "id": "xp",
                                    "type": "currency",
                                    "operator": "multiplier",
                                    "value": {
                                        "min": 2     // doubles grant amoun
                                    },
                                    "duration_sec": {
                                        "min": 120   // modifier active for 120 seconds
                                    }
                                }
                            ]
                        }
                    }
```
{{< /accordion >}}

#### 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:
```json
{ "amount": 150 }
```

Call the RPC using your Nakama client session:

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

To see this in action, open the [sample project](https://github.com/heroiclabs/sample-projects/tree/main/guides/PlayerXP)'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](#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.

{{< screenshot
src="images/pages/hiro/guides/xp-levels/xp_guide_direct_grant.png"
alt="Direct XP Grant tab in the sample project showing an XP amount entered and the Earn XP button"
>}}

{{< note "important" >}}
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.
{{< /note >}}

{{< pretitle "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.

{{< note important >}}
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](../../../concepts/publishers/) listening for the `currencyGranted` event always receives the post-modifier delta, and handles every XP grant pathway, reward or RPC, from a single place.
{{< /note >}}

#### 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.

```go
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.

```go
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

{{< accordion title="Example code showing carry-over loop">}}
```go
remaining := actuallyGranted
for i := 1; remaining > 0; i++ {
    levelID := "level_" + strconv.Itoa(i)
    sub, ok := playerLevels.SubAchievements[levelID]
    if !ok {
        break
    }
    if sub.Count >= sub.MaxCount {
        continue
    }
    toApply := remaining
    if needed := sub.MaxCount - sub.Count; toApply > needed {
        toApply = needed
    }
    updates[levelID] = toApply
    remaining -= toApply
}
```
{{< /accordion >}}

**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.

```go
if len(updates) > 0 {
    systems.GetAchievementsSystem().UpdateAchievements(ctx, logger, nk, userID, updates)
}
```

The full `main.go` for this guide is below ([download](/docs/code/hiro/xp_guide/main.go)). To test it out in a fully working example with client integrations, see the [Player XP sample project](https://github.com/heroiclabs/sample-projects/tree/main/guides/PlayerXP).

{{< accordion-code title="Complete main.go" file="code/hiro/xp_guide/main.go" lang="go" >}}

{{< pretitle "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:

```csharp
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`:
{{< accordion title="Example code showing how to get current level" >}}
```csharp
public int GetCurrentLevel()
{
    var playerLevels = achievementsSystem.GetAchievements("progression")
        .FirstOrDefault(a => a.Id == "player_levels");

    if (playerLevels?.SubAchievements == null)
        return 0;

    int level = 0;
    foreach (var kv in playerLevels.SubAchievements
        .OrderBy(k => int.TryParse(k.Key.Replace("level_", ""), out int n) ? n : 0))
    {
        if (kv.Value.ClaimTimeSec > 0)
        {
            if (int.TryParse(kv.Key.Replace("level_", ""), out int num) && num > level)
                level = num;
        }
    }
    return level;
}
```
{{< /accordion >}}

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:

{{< accordion title="Example code showing how to get progress to the next level" >}}
```csharp
public (long current, long max) GetProgressToNextLevel()
{
    var playerLevels = achievementsSystem.GetAchievements("progression")
        .FirstOrDefault(a => a.Id == "player_levels");

    if (playerLevels?.SubAchievements == null)
        return (0, 0);

    foreach (var kv in playerLevels.SubAchievements
        .OrderBy(k => int.TryParse(k.Key.Replace("level_", ""), out int n) ? n : 0))
    {
        var sub = kv.Value;
        if (sub.ClaimTimeSec <= 0)
            return (sub.Count, sub.MaxCount);
    }

    // All levels completed. Return total XP so a progress bar renders at 100%
    long xp = economySystem.Wallet.TryGetValue("xp", out var v) ? v : 0;
    return (xp, xp);
}
```
{{< /accordion >}}

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

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

#### Complete XPController

The [sample project](https://github.com/heroiclabs/sample-projects/tree/main/guides/PlayerXP) includes a full `XPController` class that wraps all of the above into one place ([download](/docs/code/hiro/xp_guide/XPController.cs)). Use it as a starting point for your own game.

{{< accordion-code title="Complete XPController.cs" file="code/hiro/xp_guide/XPController.cs" lang="csharp" >}}

## 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.

```json
"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.

```json
"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](#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:

```go
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

| Scenario | Delta | Cumulative |
|---|---|---|
| Raise a threshold | Clean, only affects players not yet at that level | Clean |
| Lower a threshold | Players advance on their next XP grant | Players advance on their next XP grant |
| Add new levels above all players' current XP | No migration needed, count starts at zero naturally | Migration required; broadcast `wallet["xp"]` to new levels |
| Add new levels below some players' current XP | Migration needed; must reconstruct cumulative totals per player | Migration needed; broadcast `wallet["xp"]` directly |


{{< note "important" >}}
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.
{{< /note >}}

### 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](/hiro/concepts/economy/rewards/#reward-modifiers) 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

- [Achievements system](/hiro/concepts/achievements/)
- [Economy system](/hiro/concepts/economy/virtual-currencies/)
- [Publishers](/hiro/concepts/publishers/)
