Appearance
Appearance
To showcase how to implement custom Entities, we'll create a system that tells each player whether they were within the first ten players to log in to the game that day. Implementing this system involves tracking persistent state on the custom Entity as well as communicating with the PlayerActor
. Here's an overview of the steps ahead:
EntityKind
.PlayerActor
Finally, we can test the project and see that everything works correctly.
To add a custom Entity, we must appropriately declare and configure the necessary components. We'll start by defining an EntityKind
for the new Entity. EntityKind
is a unique name given for each new Entity
behavior. We'll call the new entity DailyTenTracker
and choose a new unique value. We'll add this new EntityKind
in the EntityKindGame
, a registry of game-specific EntityKind
values.
// Declare a registry for game-specific entities.
// Use range [100, 300) for the game-specific EntityKind values.
[EntityKindRegistry(100, 300)]
public static class EntityKindGame
{
// Define the new DailyTenTracker EntityKind with value 100.
public static readonly EntityKind DailyTenTracker = EntityKind.FromValue(100);
}
Next, we'll define the data model for the new Entity. To know whether a player is within the first ten to log in for that day, we need to store a set of up to 10 players and the timestamps for when they are valid.
namespace Game.Server;
[MetaSerializable]
[SupportedSchemaVersions(1, 1)]
public class DailyTenModel : ISchemaMigratable
{
// The set of up-to 10 first players.
[MetaMember(1)] public OrderedSet<EntityId> DailyTen = new OrderedSet<EntityId>();
// The timestamp for when the first player was added into DailyTen.
[MetaMember(2)] public MetaTime LastClearedAt;
/// <summary>
/// Adds player to the todays first 10 players if there is room.
/// Returns true if player is in today's first 10 players set
/// </summary>
public bool TryAddPlayer(MetaTime now, EntityId playerId)
{
// If day has changed, reset list
if (LastClearedAt.ToDateTime().Date != now.ToDateTime().Date)
{
DailyTen.Clear();
LastClearedAt = now;
}
// Add to the set if it's not full. Note that the player may already be in the set.
if (DailyTen.Count < 10)
DailyTen.Add(playerId);
return DailyTen.Contains(playerId);
}
}
After defining the data model, we must determine how to save it on the persisted storage. For this, we'll define a PersistedDailyTen
persisted model for the data. This model defines an SQL table schema, such that we don't need to take care of the details other than to choose a convenient Table
attribute:
namespace Game.Server;
[Table("DailyTens")]
public class PersistedDailyTen : IPersistedEntity
{
[Key]
[PartitionKey]
[Required]
[MaxLength(64)]
[Column(TypeName = "varchar(64)")]
public string EntityId { get; set; }
[Required]
[Column(TypeName = "DateTime")]
public DateTime PersistedAt { get; set; }
[Required]
public byte[] Payload { get; set; }
[Required]
public int SchemaVersion { get; set; }
[Required]
public bool IsFinal { get; set; }
}
Next, we can bake the class into an SQL schema by running the following command while in the project's Backend/Server/
folder:
dotnet ef migrations add AddPersistedDailyTen
The AddPersistedDailyTen
is a human-friendly name for the change. After running the migration generator, remember to commit the generated and updated files:
Migrations/GameDbContextModelSnapshot.cs
Migrations/<datetime>_AddPersistedDailyTen.cs
Migrations/<datetime>_AddPersistedDailyTen.Designer.cs
Now that the data models are set, we need to define the Entity actor class for the declared EntityKind
. At this point, it merely maintains an instance of the data model.
namespace Game.Server;
public class DailyTenActor : PersistedEntityActor<PersistedDailyTen, DailyTenModel>
{
// Here we declare the lifetime characteristics of the actor: we set `SnapshotInterval` to one minute
// to make the actor save its state automatically every minute.
protected override TimeSpan SnapshotInterval => TimeSpan.FromMinutes(1);
// We set `ShutdownPolicy` to never automatically shut down. While having the actor shut down is harmless, (as it
// would automatically wake up on demand) it's not necessary as there will be at most one instance of this type
// *Entity* in server memory at any given time. By never sleeping, it helps login latency as the entity is read from
// database only once.
protected override AutoShutdownPolicy ShutdownPolicy => AutoShutdownPolicy.ShutdownNever();
DailyTenModel _state;
public DailyTenActor(EntityId entityId) : base(entityId)
{
}
// Fetching the persisted data from the base class.
protected override async Task Initialize()
{
PersistedDailyTen persisted = await MetaDatabase.Get().TryGetAsync<PersistedDailyTen>(_entityId.ToString());
await InitializePersisted(persisted);
}
// Creates a new model upon being initialized for the first time.
protected override Task<DailyTenModel> InitializeNew()
{
return Task.FromResult(new DailyTenModel());
}
// Saving current state to persistent storage upon being requested.
protected override async Task PersistStateImpl(bool isInitial, bool isFinal)
{
byte[] serialized = SerializeToPersistedPayload(_state, resolver: null, logicVersion: null);
PersistedDailyTen persisted = new PersistedDailyTen
{
EntityId = _entityId.ToString(),
PersistedAt = DateTime.UtcNow,
Payload = serialized,
SchemaVersion = _entityConfig.CurrentSchemaVersion,
IsFinal = isFinal,
};
if (isInitial)
await MetaDatabase.Get().InsertAsync(persisted).ConfigureAwait(false);
else
await MetaDatabase.Get().UpdateAsync(persisted).ConfigureAwait(false);
}
// Decodes the saved model being retrieved from persisted state.
protected override Task<DailyTenModel> RestoreFromPersisted(PersistedDailyTen persisted)
{
// Decode from Payload field
DailyTenModel state = DeserializePersistedPayload<DailyTenModel>(persisted.Payload, resolver: null, logicVersion: null);
return Task.FromResult(state);
}
// Finally, we load the retrieved or created state into the actor.
protected override Task PostLoad(DailyTenModel payload, DateTime persistedAt, TimeSpan elapsedTime)
{
// Load actor state from persisted.
_state = payload;
return Task.CompletedTask;
}
}
While Model
defines the data model, Persisted-
the persisted data model, and actor the runtime behavior, we have one final configuration parameter left: the Entity configuration. Entity configuration defines the runtime environment in which the Entity's runtime representation, the Actor, runs. Specifically, it chooses the Actor class for the EntityKind
. For other fields, we'll declare the default values:
namespace Game.Server;
[EntityConfig]
class DailyTenEntityConfig : PersistedEntityConfig
{
// For the EntityKind DailyTenTracker..
public override EntityKind EntityKind => EntityKindGame.DailyTenTracker;
// ..use the actor class DailyTenActor.
public override Type EntityActorType => typeof(DailyTenActor);
// Initialize as part of the Workloads group, after all Metaplay core services are available.
// EntityShardGroups.Workloads is the default value so this is only needed when the entity needs
// to be initialized earlier.
public override EntityShardGroup EntityShardGroup => EntityShardGroup.Workloads;
// Evenly distribute the given entities on all NodeSets in the cluster that are configured
// to contain the DailyTenTracker entities.
public override IShardingStrategy ShardingStrategy => ShardingStrategies.CreateStaticSharded();
// By default, we want to place this entity on the `service` NodeSet in the cluster. This NodeSet
// has a single node that is responsible for running various singleton services.
public override NodeSetPlacement NodeSetPlacement => NodeSetPlacement.Service;
// Give the entities 10 seconds time to shutdown when the server is terminated.
public override TimeSpan ShardShutdownTimeout => TimeSpan.FromSeconds(10);
}
The EntityShardGroup
specifies the order in which the Entities in question are initialized, relative to the other Entities. In most cases, EntityShardGroup.Workloads
is the correct group choice and it is the default value. The other values are intended for core services that need to be initialized before everything else. The options are:
EntityShardGroup.BaseServices
: Initial core services like GlobalStateManager
and DiagnosticTool
that other services depend onEntityShardGroup.ServicesProxies
: Local proxy actors for the base services.EntityShardGroup.Workloads
: Everything else. Usually the correct choice.The ShardingStrategy
specifies how the Entities of a given type are placed across the cluster when multiple nodes are handling the same kind of Entity. Here are the possible options:
ShardingStrategies.CreateSingletonService()
for service Entities that should only have one copy running. The service Entity will automatically spawn when the server starts.ShardingStrategies.CreateStaticService()
for service Entities that should have a copy on every static node responsible for this Entity kind. The service Entities are automatically started when the server starts.ShardingStrategies.CreateDynamicService()
for service Entities that should live on both static and dynamically scaling nodes. The service Entities are automatically started when the server starts.ShardingStrategies.CreateMultiService()
for service Entities that should have a predetermined number of copies running, regardless of the number of nodes in the cluster. The service Entities are automatically distributed between the nodes that are responsible for this Entity kind when the server starts.ShardingStragies.CreateStaticSharded()
for Entities that should be sharded across a static set of nodes. Use this for Entities that should scale across multiple nodes in the cluster. It is used by Entities such as players, guilds, and guild divisions that need to be scalable.ShardingStrategies.CreateManual()
for Entities that get their EntityId
s assigned manually. This can be used to encode the location of ephemeral Entities into the EntityId
and therefore allow more dynamic placement of Entities than the static sharding strategy allows.The NodeSetPlacement
specifies the default nodes in the cluster on which the entities should be placed. See Configuring Cluster Topology to better understand how clustering works in Metaplay as well as how to override these placements for non-standard cluster configurations. The valid options are:
NodeSetPlacement.Service
should be used for singleton entities where only one copy of the entity exists and other service-style entities that don't need to scale across the cluster.NodeSetPlacement.Logic
should be used for entities that need to scale across multiple cluster nodes.NodeSetPlacement.All
should be used for entities that need to be located on all nodes in the cluster. These are usually various daemon-style service entities.We have now implemented an Entity that can execute on the backend, run game logic, and save state in persisted storage. However, it does not affect the players in any way as it lacks a way to communicate with them. For that to change, each player that logs in will need to know whether they were part of the first ten users to log in that day. We'll implement this with a single EntityAsk
call. We start by declaring the messages:
namespace Game.Server;
public static class MessageCodes
{
public const int DailyTenPlayerLoginRequest = 10_001;
public const int DailyTenPlayerLoginResponse = 10_002;
}
namespace Game.Server;
[MetaMessage(MessageCodes.DailyTenPlayerLoginRequest, MessageDirection.ServerInternal)]
class DailyTenPlayerLoginRequest : MetaMessage
{
// no arguments yet
}
[MetaMessage(MessageCodes.DailyTenPlayerLoginResponse, MessageDirection.ServerInternal)]
class DailyTenPlayerLoginResponse : MetaMessage
{
public bool IsInDailyTen { get; private set; }
DailyTenPlayerLoginResponse() { }
public DailyTenPlayerLoginResponse(bool isInDailyTen)
{
IsInDailyTen = isInDailyTen;
}
}
In the first step, we declared a message number registry. This acts as a common location to place the constant integers and avoid littering the implementation with hard-to-read numbers. We then declare a message pair for the request and response parts of the EntityAsk
. After that, we'll add a message handler to the DailyTenActor
to process the request:
public class DailyTenActor
{
...
[EntityAskHandler]
DailyTenPlayerLoginResponse HandleDailyTenPlayerLoginRequest(EntityId sender, DailyTenPlayerLoginRequest request)
{
bool isInDailyTen = _state.TryAddPlayer(now: MetaTime.Now, playerId: sender);
return new DailyTenPlayerLoginResponse(isInDailyTen);
}
}
Finally, we'll use this implemented request in the player actor's login sequence:
public class PlayerActor
{
...
protected override async Task OnSessionStartAsync(PlayerSessionParamsBase start, bool isFirstLogin)
{
EntityId targetEntity = EntityId.Create(EntityKindGame.DailyTenTracker, value: 0);
DailyTenPlayerLoginRequest request = new DailyTenPlayerLoginRequest();
DailyTenPlayerLoginResponse response = await EntityAskAsync<DailyTenPlayerLoginResponse>(targetEntity, request);
Model.IsInDailyTen = response.IsInDailyTen;
}
}
Notably, the targetEntity
here is just a DailyTenTracker
entity. We have arbitrarily chosen the ID number 0 but could have chosen any different constant value. In different scenarios, we could also add multiple independent tracker Entities, each with a different ID number.
To communicate the result of the operation to the client, we'll update the Model.IsInDailyTen
member of the player model:
public class PlayerModel
{
...
[MetaMember(201), Transient] public bool IsInDailyTen { get; set; } = false;
}
In this implementation IsInDailyTen
is refreshed for this implementation in every login. As such, we don't need to store it in persistent storage, and it has been marked as Transient
. This has the benefit that IsInDailyTen
is automatically reset whenever the player is no longer online and the player entity has been persisted into the database.
Now, any player within the first 10 to log in on a given day should have their corresponding player model's IsInDailyTen
set to true. While running the project, you can check that everything works by printing this value to the console or by inspecting the PlayerModel.IsInDailyTen
with Model Inspector. Remember to have the server running!