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.
At level 1, the user has a maximum of 10 stamina.
After becoming level 5, the user now has a maximum of 22 stamina!
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.
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).
funcNewStaminaPersonalizer(nkruntime.NakamaModule,statsSystemhiro.StatsSystem,pathStaminaPersonalizerstring)(hiro.Personalizer,error){// Try to load the stamina personalizer config file.
file,err:=nk.ReadFile(pathStaminaPersonalizer)iferr!=nil{returnnil,err}deferfile.Close()// Read the file content bytes.
bytes,err:=io.ReadAll(file)iferr!=nil{returnnil,err}// Try to parse the bytes so we can use the stamina_at_level array.
config:=&StaminaPersonalizerConfig{}iferr=json.Unmarshal(bytes,&config);err!=nil{returnnil,err}return&StaminaPersonalizer{nk:nk,statsSystem:statsSystem,config:config,},nil}
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.
func(p*StaminaPersonalizer)GetValue(ctxcontext.Context,loggerruntime.Logger,nkruntime.NakamaModule,systemhiro.System,userIDstring)(any,error){switchsystem.GetType(){// We only need to modify the Energy system config.
casehiro.SystemTypeEnergy:// Get all stats for the user.
statsList,err:=p.statsSystem.List(ctx,logger,nk,[]string{userID})iferr!=nil{logger.WithField("error",err.Error()).Error("statsSystem.List error")returnnil,err}level:=1// Try to get the user's level stat.
ifstats,found:=statsList[userID];found{ifstat,found:=stats.GetPublic()["level"];found{level=int(stat.GetValue())}}// If something goes wrong, treat the level as 1.
iflevel<1{level=1}varstaminaMaxint32// Keep searching for the max stamina until we find the correct value for their level.
fori,staminaMaxConfig:=rangep.config.StaminaAtLevel{staminaMax=int32(staminaMaxConfig)// Stop searching when we are the user's level.
ifi+1>=level{break}}ifstaminaMax>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")returnnil,nil}// Try to get the "stamina" energy.
ifstamina,found:=config.Energies["stamina"];found{// Override the base value with the "stamina_at_level" value.
stamina.MaxCount=staminaMax}returnconfig,nil}fallthroughdefault:returnnil,nil}}
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.
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.
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 systemvarenergiesSystem=newEnergiesSystem(logger,nakamaSystem);systems.Add(energiesSystem);// Add the Stats systemvarstatsSystem=newStatsSystem(logger,nakamaSystem);systems.Add(statsSystem);returnTask.FromResult(systems);// ...
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.
publicclassStaminaDisplay:MonoBehaviour{ [SerializeField]privateTMP_TextstaminaText;privateEnergiesSystem_energiesSystem;privateIDisposable_energiesDisposer;privateIEnumeratorStart(){// 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.yieldreturnnewWaitUntil(()=>_energiesSystem.IsInitialized);// Refresh the system to get the latest data._energiesSystem.RefreshAsync();}privatevoidOnEnergiesSystemChanged(EnergiesSystemsystem){// Get our stamina data.if(!system.Energies.TryGetValue("stamina",outvarstamina)){return;}staminaText.text=$"{stamina.Current}/{stamina.Max}";}privatevoidOnDestroy(){_energiesDisposer?.Dispose();}}
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.
publicclassLevelUpDisplay:MonoBehaviour{ [SerializeField]privateTMP_TextlevelText;privateStatsSystem_statsSystem;privateEnergiesSystem_energiesSystem;privateIDisposable_statsDisposer;privateconststringLevelStatKey="level";privateconststringStaminaEnergyKey="stamina";privateIEnumeratorStart(){// 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.yieldreturnnewWaitUntil(()=>_statsSystem.IsInitialized);// Refresh the system to get the latest data._statsSystem.RefreshAsync();}privatevoidOnStatsSystemChanged(StatsSystemsystem){// Get our level.if(!system.PublicStats.TryGetValue(LevelStatKey,outvarlevel)){// 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();}publicasyncvoidLevelUp(){// 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();}publicvoidSpendStamina(){_energiesSystem.SpendEnergyAsync(StaminaEnergyKey,1);}privatevoidOnDestroy(){_statsDisposer?.Dispose();}}