Level-Based Stats #

In this guide, we’ll explore how to use a custom 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 is statically defined in the server’s config files, but we need this to change dynamically based on the level Stat of the individual user.

  1. At level 1, the user has a maximum of 10 stamina.

    Level 1, 10 Max Stamina
    Level 1, 10 Max Stamina

  2. After becoming level 5, the user now has a maximum of 22 stamina!

    Level 5, 22 Max Stamina
    Level 5, 22 Max Stamina

Prerequisites #

To follow this guide you’ll need to:

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
    "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. Here we add the setup code and custom types that we’ll be using in the following steps.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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.

InitModule function #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// ...
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 called stamina to all users. By default, it has a maximum capacity of 10, and refills 1 energy every 60 seconds.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
    "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.

1
2
3
4
5
{
    "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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ...
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.

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.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
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();
    }
}