# Level-Based Stats

**URL:** https://heroiclabs.com/docs/hiro/guides/personalizer/level-based-stats/
**Keywords:** level-based stats, hiro, level based stats
**Categories:** hiro, level-based-stats, personalizer

---


# Level-Based Stats

In this guide, we'll explore how to use a custom [Personalizer](../../../concepts/personalizer) to implement level-based stats. It is an extremely common feature in games to have various values increase when users level up.

For our example, we want to increase the user's maximum stamina, based on their level. By default, the maximum capacity of an [Energy](../../../concepts/energy/) is statically defined in the server's config files, but we need this to change dynamically based on the level [Stat](../../../concepts/stats/) of the individual user.

1. At level 1, the user has a maximum of 10 stamina.
![Level 1, 10 Max Stamina]({{< fingerprint_image "/images/pages/hiro/guides/level-based-stats/level-1.png" >}})

2. After becoming level 5, the user now has a maximum of 22 stamina! 
![Level 5, 22 Max Stamina]({{< fingerprint_image "/images/pages/hiro/guides/level-based-stats/level-5.png" >}})

## Prerequisites

To follow this guide you'll need to:

* [Install Nakama](../../../../nakama/getting-started/install/docker/)
* [Install Hiro](../../../../hiro/concepts/getting-started/install/)
* [Install Unity](https://unity3d.com/get-unity/download)

## Server-side

Let's start by taking a look at the server-side code that we'll be using to implement level-based stats, beginning with creating the config file where we can define the maximum stamina at each level.

### `stamina-personalizer.json`

We can simply create an array of maximum energy values, where each element correlates to the user's level. This results in the user having 10 maximum energy at level 1, and 50 maximum energy at level 9 (and above).

```json
{
    "stamina_at_level": [
      10,
      12,
      14,
      18,
      22,
      28,
      34,
      42,
      50
    ]
  }
```

### `stamina_personalizer.go`

#### Setup

Next, we'll need to create our custom [Personalizer](../../../concepts/personalizer). Here we add the setup code and custom types that we'll be using in the following steps.

```go
package main

import (
	"context"
	"encoding/json"
	"io"

	"github.com/heroiclabs/hiro"
	"github.com/heroiclabs/nakama-common/runtime"
)

var _ hiro.Personalizer = (*StaminaPersonalizer)(nil)

type StaminaPersonalizerConfig struct {
	StaminaAtLevel []int `json:"stamina_at_level"`
}

type StaminaPersonalizer struct {
	nk          runtime.NakamaModule
	statsSystem hiro.StatsSystem

	config *StaminaPersonalizerConfig
}
```

#### `NewStaminaPersonalizer` function

When creating the `StaminaPersonalizer`, we load the `stamina-personalizer.json` file and parse it into the config variable to be used later.

```go
func NewStaminaPersonalizer(nk runtime.NakamaModule, statsSystem hiro.StatsSystem, pathStaminaPersonalizer string) (hiro.Personalizer, error) {
	// Try to load the stamina personalizer config file.
	file, err := nk.ReadFile(pathStaminaPersonalizer)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	// Read the file content bytes.
	bytes, err := io.ReadAll(file)
	if err != nil {
		return nil, err
	}

	// Try to parse the bytes so we can use the stamina_at_level array.
	config := &StaminaPersonalizerConfig{}
	if err = json.Unmarshal(bytes, &config); err != nil {
		return nil, err
	}

	return &StaminaPersonalizer{
		nk:          nk,
		statsSystem: statsSystem,
		config:      config,
	}, nil
}
```

#### `GetValue` function

Next, we create our `GetValue` function. This is called whenever a value is needed from our Hiro config, such as when getting our maximum energy value.

In our setup, it will first list all of the user's stats, and try to find their level stat. After finding the level stat, it will then search the config data we defined earlier to determine how much maximum energy the user should have based on their current level. Finally, it will try to get the user's energy that we have called `stamina`, then override the `MaxCount` property with the value we just calculated based on the level stat.

```go
func (p *StaminaPersonalizer) GetValue(ctx context.Context, logger runtime.Logger, nk runtime.NakamaModule, system hiro.System, userID string) (any, error) {
	switch system.GetType() {
	// We only need to modify the Energy system config.
	case hiro.SystemTypeEnergy:
		// Get all stats for the user.
		statsList, err := p.statsSystem.List(ctx, logger, nk, []string{userID})
		if err != nil {
			logger.WithField("error", err.Error()).Error("statsSystem.List error")
			return nil, err
		}
		level := 1
		// Try to get the user's level stat.
		if stats, found := statsList[userID]; found {
			if stat, found := stats.GetPublic()["level"]; found {
				level = int(stat.GetValue())
			}
		}

		// If something goes wrong, treat the level as 1.
		if level < 1 {
			level = 1
		}

		var staminaMax int32
		// Keep searching for the max stamina until we find the correct value for their level.
		for i, staminaMaxConfig := range p.config.StaminaAtLevel {
			staminaMax = int32(staminaMaxConfig)
			// Stop searching when we are the user's level.
			if i+1 >= level {
				break
			}
		}

		if staminaMax > 0 {
			// Retrieve the Energy config, ready to modify.
			config, ok := system.GetConfig().(*hiro.EnergyConfig)
			if !ok {
				logger.Error("unexpected energy system config type, using default")
				return nil, nil
			}

			// Try to get the "stamina" energy.
			if stamina, found := config.Energies["stamina"]; found {
				// Override the base value with the "stamina_at_level" value.
				stamina.MaxCount = staminaMax
			}

			return config, nil
		}

		fallthrough
	default:
		return nil, nil
	}
}
```
### `main.go`

In our `main.go` file where Hiro is initialized, we need to make sure to add the required systems, and to hook up our custom [Personalizer](../../../concepts/personalizer).

#### `InitModule` function

```go
// ...
systems, err := hiro.Init(ctx, logger, nk, initializer, binPath, hiroLicense,
	hiro.WithBaseSystem(fmt.Sprintf("base-system-%s.json", env), true),
	hiro.WithEnergySystem(fmt.Sprintf("base-energies-%s.json", env), true),
	hiro.WithStatsSystem(fmt.Sprintf("base-stats-%s.json", env), true))
if err != nil {
	return err
}

staminaPersonalizer, err := NewStaminaPersonalizer(nk, systems.GetStatsSystem(), "stamina-personalizer.json")
if err != nil {
	return err
}
systems.SetPersonalizer(staminaPersonalizer)
// ...
```

### Hiro system definitions

Next, we create our Hiro system definitions to make use of level-based stats in our game. While each registered system needs a config file, you can set up `base-system-dev1.json` however you like, we only need to focus on `energy` and `stats` for this example in the `base-energies-dev1.json` and `base-stats-dev1.json` files respectively.

#### Energy

For this example, we implicitly assign an [Energy](../../../concepts/energy/) called `stamina` to all users. By default, it has a maximum capacity of 10, and refills 1 energy every 60 seconds.

```json
{
    "energies": {
        "stamina": {
            "start_count": 10,
            "max_count": 10,
            "refill_count": 1,
            "refill_time_sec": 60,
            "implicit": true,
            "additional_properties": {}
        }
    }
}
```

#### Stats

By defining `level` as a whitelisted stat, it allows the stat to be granted to users, and to be modified by them. 

```json
{
    "whitelist": [
        "level"
    ]
}
```

## Client-side

### `LevelBasedStatsGameCoordinator`

This file bootstraps our game with a list of systems to be used, and provides a list of systems for deterministic start-up. In our case, we're initializing the `Energies` and `Stats` core systems from Hiro.

```csharp
// ...
systems.Add(nakamaSystem);

// Add the Energies system
var energiesSystem = new EnergiesSystem(logger, nakamaSystem);
systems.Add(energiesSystem);

 // Add the Stats system
var statsSystem = new StatsSystem(logger, nakamaSystem);
systems.Add(statsSystem);

return Task.FromResult(systems);
// ...
```

### `StaminaDisplay`

The `StaminaDisplay` will update the UI whenever the `EnergiesSystem` is refreshed.

{{< note "warning" >}}
Due to energies regenerating over time, you would need to set up a timer here to refresh the `EnergiesSystem` every so often to show stamina filling up passively.
{{< / note >}}

```csharp
public class StaminaDisplay : MonoBehaviour
{
    [SerializeField] private TMP_Text staminaText;

    private EnergiesSystem _energiesSystem;
    private IDisposable _energiesDisposer;

    private IEnumerator Start()
    {
        // Get the relevant systems and listen for updates.
        _energiesSystem = this.GetSystem<EnergiesSystem>();

        _energiesDisposer = SystemObserver<EnergiesSystem>.Create(_energiesSystem, OnEnergiesSystemChanged);

        // Wait until energies are ready to begin.
        yield return new WaitUntil(() => _energiesSystem.IsInitialized);

        // Refresh the system to get the latest data.
        _energiesSystem.RefreshAsync();
    }

    private void OnEnergiesSystemChanged(EnergiesSystem system)
    {
        // Get our stamina data.
        if (!system.Energies.TryGetValue("stamina", out var stamina))
        {
            return;
        }

        staminaText.text = $"{stamina.Current}/{stamina.Max}";
    }

    private void OnDestroy()
    {
        _energiesDisposer?.Dispose();
    }
}
```

### `LevelUpDisplay`

The `LevelUpDisplay` handles displaying the user's level, and allows them to level-up, as well as spend energy. These functions are called from buttons that are hooked-up inside the Unity editor.

```csharp
public class LevelUpDisplay : MonoBehaviour
{
    [SerializeField] private TMP_Text levelText;

    private StatsSystem _statsSystem;
    private EnergiesSystem _energiesSystem;

    private IDisposable _statsDisposer;

    private const string LevelStatKey = "level";
    private const string StaminaEnergyKey = "stamina";

    private IEnumerator Start()
    {
        // Get the relevant systems and listen for updates.
        _statsSystem = this.GetSystem<StatsSystem>();
        _energiesSystem = this.GetSystem<EnergiesSystem>();
        
        _statsDisposer = SystemObserver<StatsSystem>.Create(_statsSystem, OnStatsSystemChanged);

        // Wait until stats are ready to begin.
        yield return new WaitUntil(() => _statsSystem.IsInitialized);
        
        // Refresh the system to get the latest data.
        _statsSystem.RefreshAsync();
    }

    private void OnStatsSystemChanged(StatsSystem system)
    {
        // Get our level.
        if (!system.PublicStats.TryGetValue(LevelStatKey, out var level))
        {
            // We don't have a level yet, assume we are level 1.
            levelText.text = "1";
            return;
        }

        // Update the UI using our actual level.
        levelText.text = level.Value.ToString();
    }

    public async void LevelUp()
    {
        // Check if we already have a level.
        if (!_statsSystem.PublicStats.ContainsKey(LevelStatKey))
        {
            // We don't have a level yet, so we should be level 1, going to level 2.
            await _statsSystem.PublicUpdateAsync(LevelStatKey, 2, StatUpdateOperator.Max);
        }
        else
        {
            // Increment our level.
            await _statsSystem.PublicUpdateAsync(LevelStatKey, 1, StatUpdateOperator.Delta);
        }

        // Refresh energies due to max stamina changing as the user levelled up.
        _ = _energiesSystem.RefreshAsync();
    }

    public void SpendStamina()
    {
        _energiesSystem.SpendEnergyAsync(StaminaEnergyKey, 1);
    }

    private void OnDestroy()
    {
        _statsDisposer?.Dispose();
    }
}
```