Appearance
Initializing Data on The Player Model
This page describes how to use appropriate hooks to initialize and update members on the player model.
Appearance
This page describes how to use appropriate hooks to initialize and update members on the player model.
There are a few different situations where you may need to initialize or update data in your player model (or other entity models). The appropriate mechanism depends on the use case. For example, initializing data when a new player account is created is different from updating data every time the player logs in.
We'll go over the following use cases for this on this page:
To initialize non-serialized data within an otherwise serialized type, you can use [MetaOnDeserialized]
.
Typically, most members in a player model are [MetaMember]
s so that they are automatically serialized in order to keep their values when the model is stored and loaded from the database or sent from the server to the client. However, you can also have non-serialized members, often used for the purpose of holding redundant data at runtime to achieve better performance. Such redundant data may need to be recomputed when the model is deserialized. The [MetaOnDeserialized]
attribute can be used for this purpose.
For example, imagine your player model contains a list of weapons, each with a strength number. For some game logic operations, you want to calculate the sum of the strengths of all of the player's weapons.
...
public class PlayerModel : PlayerModelBase<...>
{
...
[MetaMember(100)] public List<WeaponModel> Weapons = new();
public int GetTotalWeaponStrength()
{
int sum = 0;
foreach (WeaponModel weapon in Weapons)
sum += weapon.Strength;
return sum;
}
public void AddWeapon(WeaponModel weapon)
{
Weapons.Add(weapon);
}
}
[MetaSerializable]
public class WeaponModel
{
[MetaMember(1)] public WeaponType Type;
[MetaMember(2)] public int Strength;
...
}
However, if GetTotalWeaponStrength()
is called frequently, it can be more efficient to cache the total strength directly in the player model, updating it whenever the Weapons
list is modified.
...
public class PlayerModel : PlayerModelBase<...>
{
...
[MetaMember(100)] public List<WeaponModel> Weapons = new();
int _totalWeaponStrength = 0;
public int GetTotalWeaponStrength()
{
int sum = 0;
foreach (WeaponModel weapon in Weapons)
sum += weapon.Strength;
return sum;
return _totalWeaponStrength;
}
public void AddWeapon(WeaponModel weapon)
{
Weapons.Add(weapon);
_totalWeaponStrength += weapon.Strength;
}
}
The above example code has a problem: when the PlayerModel
is serialized and then deserialized, _totalWeaponStrength
is again 0, no matter what Weapons
contains. This is because _totalWeaponStrength
is not a [MetaMember]
and therefore not serialized. One solution would be to make it a [MetaMember]
, but that would be wasteful in the serialized storage, because it can always be recomputed based on Weapons
. In this example, the wastefulness may not be significant, but in bigger use cases, it can be.
An alternative solution is to add a [MetaOnDeserialized]
method to initialize _totalWeaponStrength
after deserialization.
...
public class PlayerModel : PlayerModelBase<...>
{
...
int _totalWeaponStrength = 0;
[MetaOnDeserialized]
void InitializeRedundantData()
{
foreach (WeaponModel weapon in Weapons)
_totalWeaponStrength += weapon.Strength;
}
...
}
This method will get called right after the PlayerModel
has been deserialized, so any code that runs afterwards can rely on _totalWeaponStrength
having been computed. Note that any runtime modification to the Weapons
list will still need to carefully update _totalWeaponStrength
correctly.
If you need to use the SharedGameConfig
in the [MetaOnDeserialized]
method, you can access it by adding a MetaOnDeserializedParams
parameter and accessing the Resolver
member. In the following example, we assume the Strength
comes from the config instead of being stored in WeaponModel
.
[MetaOnDeserialized]
void InitializeRedundantData(MetaOnDeserializedParams onDeserializedParams)
{
SharedGameConfig gameConfig = (SharedGameConfig)onDeserializedParams.Resolver;
// If you wish, here you can also assign gameConfig to PlayerModelBase's GameConfig property,
// which can be convenient if the code below relies on it.
foreach (WeaponModel weapon in Weapons)
_totalWeaponStrength += gameConfig.Weapons[weapon.Type].Strength;
}
[MetaOnDeserialized]
methods can be added on any [MetaSerializable]
class, not just the PlayerModel
, which is convenient if the redundant data is stored deeper within PlayerModel
than at the top level. The method is always called right after its containing object has been deserialized, which for the PlayerModel
's subobjects is before the PlayerModel
has been deserialized.
To update the player model after it has been loaded from the database you can use playerModel.GameOnRestoredFromPersistedState()
. Most often, this happens because the player came back online after being offline for a while. However, it can also happen when the player is loaded on the server for any other reason, such as someone looking at it via the LiveOps Dashboard.
You can use this method to refresh any state in the model that may be affected due to changes that happened while the player was offline. Typical use cases are:
GameTick()
method, which runs while the player is online. Doing it also in GameOnRestoredFromPersistedState()
ensures that the state is updated when the player actor is loaded on the server even if the player is offline (and GameTick()
is not run), such as when you look at the player in the LiveOps Dashboard.GameOnRestoredFromPersistedState()
is a good place to do this, because it is called at an early time when the player is loaded on the server, ensuring that code that runs after it can assume the items have been set up. As another example, you could clean up stale config references from the player model in case config items have been removed.For these use cases, using GameOnRestoredFromPersistedState()
is more suitable than a [MetaOnDeserialized]
method, for the following reasons:
GameOnRestoredFromPersistedState()
runs only on the server. There is no need for the client to re-run the same operations, as the model will already be up to date when the server sends it. In contrast, [MetaOnDeserialized]
methods also run on the client when it deserializes the player model received from the server.CurrentTime
property is correctly set when GameOnRestoredFromPersistedState()
is called, unlike with [MetaOnDeserialized]
. The same is true for the properties Log
, EventStream
, GameConfig
, LogicVersion
, and IsDeveloper
.The following example uses GameOnRestoredFromPersistedState()
to recharge the player's energy and ensure it has all the starter items. Details about ItemModel
and ItemInfo
are omitted for brevity.
...
public class PlayerModel : PlayerModelBase<...>
{
...
[MetaMember(100)] public int Energy = 0;
[MetaMember(101)] public MetaTime EnergyLastRechargedAt;
[MetaMember(102)] public MetaDictionary<ItemId, ItemModel> Items = new();
// Note: Normally these values would be in the game config.
// In this example, they're hardcoded for brevity.
MetaDuration EnergyRechargeDuration => MetaDuration.FromMinutes(5);
const int MaxEnergy = 10;
protected override void GameOnRestoredFromPersistedState(MetaDuration elapsedTime)
{
// Note: elapsedTime is how long the player state
// was stored in the database since the last usage.
// We don't use that here, but instead RechargeEnergy()
// computes its own elapsed time for energy recharging.
RechargeEnergy();
EnsureHasStarterItems();
}
void RechargeEnergy()
{
// Update energy based on how many full EnergyRechargeDuration
// cycles have elapsed since energy was last recharged.
MetaDuration elapsed = CurrentTime - EnergyLastRechargedAt;
int cycles = (int)(elapsed.Milliseconds / EnergyRechargeDuration.Milliseconds);
Energy = Math.Min(MaxEnergy, Energy + cycles);
EnergyLastRechargedAt += cycles * EnergyRechargeDuration;
}
void EnsureHasStarterItems()
{
foreach (ItemInfo itemInfo in GameConfig.Items.Values)
{
if (itemInfo.IsStarterItem && !Items.ContainsKey(itemInfo.Id))
Items.Add(itemInfo.Id, new ItemModel(itemInfo));
}
}
}
It's important to note that GameOnRestoredFromPersistedState()
is only called on the server, so if it mutates non-serialized members, those mutations won't be seen on the client.
Note also that despite its name, GameOnRestoredFromPersistedState()
is also called in the following circumstances on the server, in addition to when loading from the database:
When a player logs in, the playerModel.GameOnSessionStarted()
method is called. Note that this is only executed on the server, but before sending the state to the client, so the client will see the updated state.
For example, let's say your game has daily login rewards. You can track the logins in GameOnSessionStarted()
.
...
public class PlayerModel : PlayerModelBase<...>
{
...
[MetaMember(100)] public MetaTime LastLoginRewardCheckAt;
protected override void GameOnSessionStarted()
{
// Note that for simplicity, this example deals only with
// UTC dates (as produced by `MetaTime.ToDateTime()`).
if (CurrentTime.ToDateTime().Date > LastLoginRewardCheckAt.ToDateTime().Date)
AddPendingLoginReward();
LastLoginRewardCheckAt = CurrentTime;
}
void AddPendingLoginReward()
{
...
}
}
As noted, GameOnSessionStarted()
is run on the server just before the state is sent to the client. As such, you should not rely on ClientListener
s in this method, as they will do nothing on the server. The above example just records a pending login reward, and it is assumed that the client will visualize that reward and invoke an action to claim it.
When a player logs in for the first time and a new player account is created, you usually want to initialize the player model to a good state. There are a few different ways to do this.
The simplest way is to simply initialize the members in the PlayerModel
's constructor. This can be done either with direct member initializers or an explicit public PlayerModel()
constructor.
...
public class PlayerModel : PlayerModelBase<...>
{
// We initialize Weapons to an empty list instead of null.
[MetaMember(100)] public List<WeaponModel> Weapons = new();
}
However, keep in mind that the member initializers and the constructor are run not only for new players, but whenever a PlayerModel
object is created. When an existing player is deserialized from the database, a PlayerModel
object is constructed, and then the members get potentially overwritten with the deserialized values. For this reason, particularly costly computations or memory allocations are best done by other means, as described below.
The GameOnInitialLogin()
method is a dedicated place to set up state for new players. This is run on the server when the player's first session is about to start. Here, you can run more costly or complex setup code, and can also use the game config.
...
public class PlayerModel : PlayerModelBase<...>
{
...
[MetaMember(100)] public BoardItem[][] Board;
protected override void GameOnInitialLogin()
{
// Set up initial player state based on the game config.
Board = CreateInitialBoard(GameConfig);
}
}
Note that GameOnInitialLogin()
is run just before GameOnSessionStarted()
on the first login. Various SDK-side state initializations have already been run by this point: for example, the player can receive active broadcasts and join LiveOps Events before GameOnInitialLogin()
is run. If you need to set up state even before this point for any reason (perhaps your LiveOps Event implementation depends on the model being initialized), you can do so either in the constructor as described earlier, or in the GameInitializeNewPlayerModel()
method.
...
public class PlayerModel : PlayerModelBase<...>
{
...
[MetaMember(100)] public RandomPCG LiveOpsEventRandom;
protected override void GameInitializeNewPlayerModel(MetaTime now, ISharedGameConfig gameConfig, EntityId playerId, string name)
{
// Id and name should always be assigned here.
PlayerId = playerId;
PlayerName = name;
// Set up initial custom state.
LiveOpsEventRandom = RandomPCG.CreateFromSeed((ulong)now.MillisecondsSinceEpoch);
}
}
An important caveat about GameInitializeNewPlayerModel()
is that the game config at that point does not yet account for possible A/B experiments, because the player has not yet been added to any experiments. The game config in that method is always the baseline config. In contrast, in GameOnInitialLogin()
, the game config already reflects any experiments the player may be in. You should take this into account when using the game config in GameInitializeNewPlayerModel()
.
When you add new members to your PlayerModel
, existing players will not yet have any values for those members persisted in the database. You generally need to initialize those members to sensible values when the player is first loaded on the new server. The best way to do so depends on the specifics of your use case:
PlayerModel
object is created, for new and existing players alike.GameOnRestoredFromPersistedState()
, as described in Updating Data After Loading from the Database.