Appearance
Appearance
The Metaplay SDK multiplayer features such as Guilds (See: Preview: Guild Framework) and Leagues (See: Customizing Leagues) are based on a generic framework for shared state and interactions between groups of players called Multiplayer Entities. This framework can be used for implementing various kinds of multiplayer game features that don't fit into the more opinionated SDK systems.
The basis of the framework is the Multiplayer Entity which resembles the Player
entity in many regards: it will have a data model similar to the PlayerModel
and a set of Actions that govern the allowed mutations to the model. As opposed to the player entity, the Multiplayer Entity will be owned by the server. The server will be responsible for advancing the time by ticking the model and any actions against the model must be executed by the server. Game clients can register to observe the state and mutations of a Multiplayer Entity in exactly the same fashion as the server observes the player entity owned by the client, but with the roles of client and server reversed.
The framework consists of the following basic building blocks:
MultiplayerModelBase
base class for the model.EphemeralMultiplayerEntityActorBase
for entities that don't require persisting their state into the databasePersistedMultiplayerEntityActorBase
for the types of entities that do.MultiplayerEntityClientBase
base class for integrating into the game client.There are more integration points available for customizing various aspects of the framework but this set of basic blocks will get us started!
For the purpose of demonstrating the use of the Multiplayer Entities framework we will implement a multiplayer feature that aims to be as simple as it gets. We will call this feature a player Party
. A party will be a group of online players (henceforth: members) with some limited information about the members shared to the party and basic party chat functionality.
For simplicity, our party will be transient in nature. Any player can create a new party and any other player can join the party simply by knowing the identifier of the party. A party will exist for as long as there's a single member online. Once the last player has left, the party will disappear, and there will be no history of it having ever existed.
Let's drill down on some of the more detailed design choices:
This party feature is fully implemented and usable in the Idler
sample project, with minimal developer GUI for joining parties and sending chat messages.
🚨 Not for production use!
While you are welcome to use the code here as the basis of your own party system, it is by no means intended to be feature complete for production use. Some of the immediately obvious shortcomings of this sample system include:
We'll start off with introducing a data model and a new EntityKind
value for the Party
entity as described in Custom Server Entities. As opposed to the Daily Ten example, our intention is to expose the data model and associated actions for the Party
to all clients representing members of the party. Therefore we'll add the Model class into shared (i.e. client-visible) code. Also we'll make use of the SDK provided specialized MultiplayerModelBase
base class.
Our PartyModel
class will contain the name and online status of its members keyed by the player EntityId
's and a bounded queue of the most recent chat messages sent to the party.
namespace Game.Logic;
[MetaSerializableDerived(5)]
[SupportedSchemaVersions(1, 1)]
public class PartyModel : MultiplayerModelBase<PartyModel>
{
// The EntityId of the Player Entity that created this Party.
[MetaMember(1)]
public EntityId Creator { get; set; }
// Public information about party members.
[MetaMember(2)]
public OrderedDictionary<EntityId, PartyMember> Members { get; set; }
// A bounded set of previous chat messages.
[MetaMember(3)]
public Queue<PartyChatMessage> ChatLog { get; set; }
// Client-side side effects interface for UI updates.
[IgnoreDataMember] public IPartyModelClientListener ClientListener { get; set; }
// We aren't interested in ticking functionality for the Party model in this
// implementation, as all mutations will happen through actions.
public override int TicksPerSecond => 1;
public override void OnTick() {}
public override void OnFastForwardTime(MetaDuration elapsedTime) {}
}
[MetaSerializable]
public class PartyMember
{
// The PlayerName property of the PlayerModel of the member, kept up-to-date for online players.
// Any other PlayerModel data that we'd like to expose in the party would be shadowed in a similar manner.
[MetaMember(1)] public string Name;
// Whether this PartyMember represents an active member of the party or a historical entry. Note that
// players can re-join a party so a historical entry can become an active member again.
[MetaMember(2)] public bool IsOnline;
[MetaDeserializationConstructor]
public PartyMember(string name, bool isOnline)
{
Name = name;
IsOnline = isOnline;
}
// Helper method for cloning a PartyMember instance and setting online status.
public PartyMember WithOnlineStatus(bool isOnline)
{
return new PartyMember(Name, isOnline);
}
}
[MetaSerializable]
public class PartyChatMessage
{
// The Id of the player that sent the message. Only members can send messages so information about the sender
// can be found in the `Members` collection.
[MetaMember(1)] public EntityId FromPlayerId { get; }
// The payload of the message.
[MetaMember(2)] public string Message { get; }
// The time of sending the message, added by the server.
[MetaMember(3)] public MetaTime Timestamp { get; }
[MetaDeserializationConstructor]
public PartyChatMessage(EntityId fromPlayerId, string message, MetaTime timestamp)
{
FromPlayerId = fromPlayerId;
Message = message;
Timestamp = timestamp;
}
}
Then, let's allocate the EntityKind
value for Party
in EntityKindGame
:
// Declare a registry for game-specific entities.
// Use range [100, 300) for the game-specific EntityKind values.
[EntityKindRegistry(100, 300)]
public static class EntityKindGame
{
public static readonly EntityKind Party = EntityKind.FromValue(100);
}
Finally, we'll introduce action base classes for our party. We'll have need both for client-originating actions that are of type FollowerSynchronized
and for server-originating actions of type LeaderSynchronized
.
[MetaSerializable]
public abstract class PartyAction : ModelAction<PartyModel> {}
[ModelActionExecuteFlags(ModelActionExecuteFlags.LeaderSynchronized)]
public abstract class PartyServerAction : PartyAction
{
}
[ModelActionExecuteFlags(ModelActionExecuteFlags.FollowerSynchronized)]
public abstract class PartyClientAction : PartyAction
{
// A hook for validating and preprocessing client-originated actions on server,
// run before the `Execute` method is called.
public virtual bool ValidateOnServer(EntityId issuer) => true;
}
The design for our party feature doesn't require persisting the party state in the database, so we'll skip the declaration of the persisted entity schema in the Custom Server Entities guide and move to declaring the associated Party Actor.
Non-persistent entities are called "ephemeral" entities in the Metaplay SDK. Our EntityConfig
and actor implementations will use the ephemeral variants of the corresponding base classes.
We will also need a way to explicitly initialize our party state when one is created. For ephemeral entities, this happens by sending an InternalEntitySetupRequest
message to the entity actor responsible for managing the party entity. The initialization protocol allows adding our own construction parameters in a class implementing the IMultiplayerEntitySetupParams
interface. For now, we'll communicate the EntityId
and name of the player that created the party and initialize the party to have the owner as the single member.
[MetaSerializableDerived(1)]
public class PartySetupParams : IMultiplayerEntitySetupParams
{
public EntityId Owner;
public string OwnerName;
[MetaDeserializationConstructor]
public PartySetupParams(EntityId owner, string ownerName)
{
Owner = owner;
OwnerName = ownerName;
}
}
public class PartyActor : EphemeralMultiplayerEntityActorBase<PartyModel, PartyAction>
{
public PartyActor(EntityId entityId) : base(entityId) {}
// We don't have any time-based logic in our party, so disable the ticking functionality here.
protected override bool IsTicking => false;
// Initialize the PartyModel from setup parameters by adding the creator as the single member of the party,
// and remembering who the creator was.
protected override Task SetUpModelAsync(PartyModel model, IMultiplayerEntitySetupParams setupParams)
{
PartySetupParams partySetupParams = (PartySetupParams)setupParams;
model.Creator = partySetupParams.Owner;
model.Members[partySetupParams.Owner] = new PartyMember(partySetupParams.OwnerName, isOnline: false);
return Task.CompletedTask;
}
}
All that remains for the server to manage the party entities is to add the entity configuration class. Again, we'll use the ephemeral variant of the SDK-provided base class.
[EntityConfig]
public class PartyConfig : EphemeralEntityConfig
{
public override EntityKind EntityKind => EntityKindGame.Party;
public override Type EntityActorType => typeof(PartyActor);
// We will want to place the entity on the `logic` NodeSet in the cluster, along with other game logic
// entities such as Player entities. We expect the number of active party entities to scale linearly
// with number of players.
public override NodeSetPlacement NodeSetPlacement => NodeSetPlacement.Logic;
// Evenly distribute the given entities on all NodeSets in the cluster that are configured
// to contain the PartyActor entities.
public override IShardingStrategy ShardingStrategy => ShardingStrategies.CreateStaticSharded();
public override TimeSpan ShardShutdownTimeout => TimeSpan.FromSeconds(5);
}
Now that we have the party Multiplayer Entity boilerplate in place, we can start building the player interactions with the party. Before going into the details of creation and joining, we'll lay out the basics of party membership on the player side and exposing access to the party that the player is currently a member of to the game client.
Firstly, let's track the party that the player is currently a member of in the persisted PlayerModel
.
public class PlayerModel : ...
{
...
[MetaMember(117), ServerOnly] public EntityId CurrentParty { get; set; } = EntityId.None;
...
}
The party is identified by its EntityId
and initialized to EntityId.None
. We'll implement all interactions from the server PlayerActor
and there's no need to expose this state to the client so we tag the member ServerOnly
. Note that unlike our PartyModel
, the state of the PlayerModel
is persisted: if the player goes offline and later comes back again we'll find the value of CurrentParty
in the model so that we can attempt to re-associate the player with the old party, provided that it still exists.
The interaction with the party from the client side will happen using the entity session association mechanism. The SDK will take care of maintaining a subscription from the session actor to the associated party and delivering party state updates to the client. For this purpose, we'll need to allocate a ClientSlot
to distinguish our party association from other multiplayer entity associations.
namespace Game.Logic.TypeCodes;
[MetaSerializable]
public class ClientSlotGame : ClientSlot
{
public ClientSlotGame(int id, string name) : base(id, name) { }
public static readonly ClientSlot Party = new ClientSlotGame(14, nameof(Party));
}
With this out of the way, let's implement a PlayerActor
method for carrying out the entity association based on our CurrentParty
state. We will use the AddEntityAssociation()
API that does all of the heavy lifting of creating an entity subscription from our current SessionActor
to the target entity and setting the connected game client up to communicate with the entity. Note the use of removeOnSessionEnd: true
that will cause the association to be automatically removed on session end, as this is how we want our party membership to work.
/// Update the association in client slot `Party` to the party entity
/// identified by the `CurrentParty` in the model.
void UpdateCurrentPartyAssociation()
{
AddEntityAssociation(
new AssociatedEntityRefBase.Default(
ClientSlotGame.Party,
_entityId,
Model.CurrentParty),
removeOnSessionEnd: true);
}
We will be using this method for updating the association after creating or joining parties when we get around to implementing that functionality. For now let's hook it up to player session start so that we attempt to re-join our old party when having been offline:
protected override async Task OnSessionStartAsync(
PlayerSessionParamsBase sessionParams,
bool isFirstLogin)
{
// Re-attach existing party, if it did exist
if (Model.CurrentParty != EntityId.None)
UpdateCurrentPartyAssociation();
}
When AddEntityAssociation
is called as part of the session start, the association is established and communicated to the client before the actual session starts. This means that the client will conveniently have access to the current party state immediately after establishing the session, and doesn't need to do anything additional.
We'll also need to handle the case where the party entity no longer exists. This is communicated back to the PlayerActor
via the OnAssociatedEntityRefusalAsync()
callback. We'll clear the remembered CurrentParty
state when this happens.
protected override Task<bool> OnAssociatedEntityRefusalAsync(
AssociatedEntityRefBase association,
InternalEntitySubscribeRefusedBase refusal)
{
if (association.GetClientSlot() == ClientSlotGame.Party &&
refusal is InternalEntitySubscribeRefusedBase.Builtins.EntityNotSetUp)
{
// Party no longer exists
Model.CurrentParty = EntityId.None;
return Task.FromResult(true);
}
return base.OnAssociatedEntityRefusalAsync(association, refusal);
}
Now we're finally ready to implement the logic for creating a new party. We'll add a server listener to the IPlayerModelServerListener
interface to have the ability to trigger party creation from a player action, and an associated player action to exemplify the triggering. In this simplest possible implementation we don't have any other interaction with the player model, so the action only serves the purpose of invoking the server listener. We could trigger the operation without an action by just sending a MetaMessage
to the PlayerActor
outside of an action, but we expect that in a more realistic use case we'll want to be able to interleave the creation with other game logic.
public interface IPlayerModelServerListener
{
void CreateNewParty();
}
[ModelAction(ActionCodes.PlayerCreateParty)]
public class PlayerCreateParty : PlayerAction
{
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
// If our design involved any preconditions for creating a party, they
// would be checked here. For example, it might be a good idea to limit
// the rate at which players are allowed to create parties.
if (commit)
player.ServerListener.CreateNewParty();
return MetaActionResult.Success;
}
}
The actual implementation of creating the party will be carried out in the player actor CreateNewParty()
function and involves:
EntityId
. We could have a central registry for these, but we'll go with simply generating a random id and checking that it isn't already in use.InternalEntitySetupRequest
EntityAsk
to the target party for setting it up, and retrying if it was already in use.EntityId
of the new part as PlayerModel.CurrentParty
and updating the session association.public void CreateNewParty()
{
ExecuteOnActorContextAsync(() => CreateNewPartyInternalAsync(numRetries: 3));
}
async Task CreateNewPartyInternalAsync(int numRetries)
{
EntityId partyId = EntityId.CreateRandom(EntityKindGame.Party);
InternalEntitySetupRequest setupRequest = new InternalEntitySetupRequest(
new PartySetupParams(owner: _entityId, ownerName: Model.PlayerName));
try
{
await EntityAskAsync<InternalEntitySetupResponse>(partyId, setupRequest);
}
catch (Exception ex)
{
if (ex is InternalEntitySetupRefusal && numRetries >= 0)
{
// Party EntityId was already taken, try again with a different EntityId.
_ = ExecuteOnActorContextAsync(() => CreateNewPartyInternalAsync(numRetries - 1));
}
else
{
_log.Error(ex, "Unexpected error in creating party after retries");
PublishMessage(EntityTopic.Owner, new PlayerPartyError("Party creation failed, try again later."));
}
return;
}
// On successful creation remember current party id and trigger update to association
Model.CurrentParty = partyId;
UpdateCurrentPartyAssociation();
}
We're using the ExecuteOnActorContextAsync()
helper here for delaying the actual operation in CreateNewPartyInternal()
to be executed in player actor context after the triggering action has been processed, as we can't do asynchronous operations in the context of the server listener. Retrying is implemented simply by scheduling the operation to be run again. In the unlikely event that our 3 retries don't manage to produce an ununsed EntityId
for the party we'll log an error and apologize to the player.
Note that since the operation of creating the party is done in a delayed fashion, it is possible that the player state has been mutated (by other actions, for example) in between the triggering action and the actual operation. It is even possible for another CreateNewParty
to be scheduled while the previous hasn't been carried out yet. In this implementation we make sure that the operation itself happens atomically in the player actor, including reading the current player state (Model.PlayerName
) and ending up with storing the correct party id in the model.
In our design, joining party is open to anyone simply by knowing the identifier of the party: i.e., the EntityId of the party entity.
From the party model's point of view, member "joining" is a simple action of adding a new entry member to our party model Members
dictionary. Mutating the state of the party model must be done via an action, so that the mutation gets executed both by the server and by any other members that are currently observing the state of the party. Let's add a simple action for inserting or updating the party member info for a member, identified by its PlayerId
.
We'll want to notify any other connected clients about member state updates immediately, so we've also added a client listener interface to our party model with a MemberUpdated()
call.
public interface IPartyModelClientListener
{
void MemberUpdated(EntityId member);
}
[ModelAction(2)]
public class UpdatePartyMember : PartyServerAction
{
private EntityId MemberId { get; }
private PartyMember State { get; }
[MetaDeserializationConstructor]
public UpdatePartyMember(EntityId memberId, PartyMember state)
{
MemberId = memberId;
State = state;
}
public override MetaActionResult InvokeExecute(PartyModel model, bool commit)
{
if (commit)
{
model.Members[MemberId] = State;
model.ClientListener?.MemberUpdated(MemberId);
}
return MetaActionResult.Success;
}
}
This will be our workhorse action for updating party member state. Note that we've inherited from PartyServerAction
and we inherit the ModelActionExecuteFlags.LeaderSynchronized
attribute from it, which, in the case of a multi-player entity, means that the action can only be issued by the server. Connected clients will be dutifully executing the same action to update their version of the state, but clients won't be able to directly invoke it.
For this purpose, we'll add another IPlayerModelServerListener
method similar to the CreateParty()
trigger on the PlayerModel
.
public interface IPlayerModelServerListener
{
void JoinParty(EntityId partyId);
}
[ModelAction(ActionCodes.PlayerJoinParty)]
public class PlayerJoinParty : PlayerAction
{
public EntityId PartyToJoin { get; set; }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
if (!PartyToJoin.IsOfKind(EntityKindGame.Party))
return MetaActionResult.InvalidPartyId;
if (commit)
player.ServerListener.JoinParty(PartyToJoin);
return MetaActionResult.Success;
}
}
As it was with party creation, these just serve the purpose of being able to trigger the operation via a player action from the client. We're additionally validating that the EntityId
of the party is of valid kind.
The actual implementation of JoinParty()
in the PlayerActor
is also very similar to CreateParty()
, except that for the join operation we'll need a custom EntityAsk
from the player to the target party for requesting the join. Let's start with introducing the request payload class for that EntityAsk
:
[MetaMessage(MessageCodes.PlayerJoinOrUpdatePartyRequest, MessageDirection.ServerInternal)]
public class PartyJoinOrUpdateRequest : MetaMessage
{
public string PlayerName;
}
Nice and simple, the message itself will indicate to the party that the sending PlayerActor
is requesting to have its membership status changed and the only additional info we want to communicate is that current PlayerName
. We'll be re-using this EntityAsk
for in-session player name updates, so we're calling the operation JoinOrUpdate
.
Next, we need to handle this request in PartyActor
and update the state of PartyModel
to reflect the joined (or updated) player using the UpdatePartyMember
action that we introduced for this purpose.
[EntityAskHandler]
public EntityAskOk HandlePlayerJoinOrUpdateRequest(EntityId player, PartyJoinOrUpdateRequest request)
{
// Refuse join if entity hasn't been setup
if (_journal == null)
throw new InternalEntityAskNotSetUpRefusal();
// Retain member online status. If member didn't exist this is a new join and therefore member
// is initially offline.
bool isOnline = Model.Members.TryGetValue(player, out PartyMember existingMember) &&
existingMember.IsOnline;
PartyMember memberData = new PartyMember(request.PlayerName, isOnline);
ExecuteAction(new UpdatePartyMember(player, memberData));
return EntityAskOk.Instance;
}
A couple of things worth noting here:
EntityAsk
will cause the actor to wake up if it wasn't alive, so we detect the case of party "existing" by checking if the _journal
member representing the current model has been setup and error out with a InternalEntityAskNotSetUpRefusal
exception is it wasn't.IsOnline
. We'll get to how that value is updated in a bit.PartyActor
we are the owner of the timeline, and it is safe to read the current state of the model here and even use it in the action parameters. The action gets executed on the model right away in our copy, so the whole operation is ensured to be atomic.Finally we are ready to implement JoinParty()
in the player actor:
public void JoinParty(EntityId partyId) => ExecuteOnActorContextAsync(() => JoinPartyInternalAsync(partyId));
async Task JoinPartyInternalAsync(EntityId partyId)
{
try
{
await EntityAskAsync<EntityAskOk>(partyId, new PartyJoinOrUpdateRequest() { PlayerName = Model.PlayerName });
}
catch (Exception ex)
{
if (ex is InternalEntityAskNotSetUpRefusal)
{
PublishMessage(EntityTopic.Owner, new PlayerPartyError("Party no longer exists."));
}
else
{
_log.Error(ex, "Unexpected error in joining party {PartyId}", partyId);
PublishMessage(EntityTopic.Owner, new PlayerPartyError("Joining party failed, try again later."));
}
return;
}
Model.CurrentParty = partyId;
UpdateCurrentPartyAssociation();
}
The association mechanism will automatically create the entity subscriptions from session actors to their respective party actors. This will be the mechanism that ensures that our PartyActor
remains alive while at least one member has an active session. Additionally we can use this directly to track the "is online" status for the party members in the party actor.
In our implementation of PartyActor
we will therefore override the methods OnClientSessionStart
and OnParticipantSessionEnded
for triggering the mutations to the member state representing the online status of the player. We'll again use the UpdatePartyMember
generic server action so that the state change is also observed by connected clients.
protected override Task<InternalEntitySubscribeResponseBase> OnClientSessionStart(
EntityId sessionId,
EntityId playerId,
InternalEntitySubscribeRequestBase requestBase,
List<AssociatedEntityRefBase> associatedEntities)
{
// Validate that the player is a member of this party
if (!Model.Members.TryGetValue(playerId, out PartyMember memberInfo))
throw new InternalEntitySubscribeRefusedBase.Builtins.NotAParticipant();
// Set online status
if (!memberInfo.IsOnline)
ExecuteAction(new UpdatePartyMember(playerId, memberInfo.WithOnlineStatus(isOnline: true)));
return base.OnClientSessionStart(sessionId, playerId, requestBase, associatedEntities);
}
protected override void OnParticipantSessionEnded(EntitySubscriber session)
{
// Get the PlayerId of the player who's session ended.
EntityId playerId = SessionIdUtil.ToPlayerId(session.EntityId);
// Validate that the player is a member of this party.
if (!Model.Members.TryGetValue(playerId, out PartyMember memberInfo))
{
_log.Warning("Participant session ended for a player not currently in party: {PlayerId}", playerId);
return;
}
// Clear online status
if (memberInfo.IsOnline)
ExecuteAction(new UpdatePartyMember(playerId, memberInfo.WithOnlineStatus(isOnline: false)));
}
From game client code our access to the current party will be managed by a sub-client of MetaplayClient
. Hooking it up requires a few steps:
Introduce the PartyClient
class. The MultiplayerEntityClientBase
base class allows overriding various aspects of the client, but for out party system we'll only need to point it to the correct Client Slot
introduced earlier.
public class PartyClient : MultiplayerEntityClientBase<PartyModel>
{
public override ClientSlot ClientSlot => ClientSlotGame.Party;
}
To conveniently access our PartyClient
, we'll introduce an accessor in our main MetaplayClient
implementation:
public class MetaplayClient : MetaplayClientBase<PlayerModel>
{
...
public static PartyClient PartyClient =>
ClientStore.TryGetClient<PartyClient>(ClientSlotGame.Party);
}
We'll pass in an instance of PartyClient
to the SDK initialization by adding it to MetaplayClientOptions.AdditionalClients
:
void Start()
{
MetaplayClient.Initialize(new MetaplayClientOptions
{
...
AdditionalClients = new IMetaplaySubClient[]
{
new PartyClient()
}
}
}
Our PartyModel
has a client listener member of type IPartyModelClientListener
that we can use to trigger client side-effects events from actions. In the Idler party sample we'll use ApplicationStateManager
as the listener, so we'll add IPartyModelClientListener
to the list of interfaces it implements and add implementations for the interface methods.
public class ApplicationStateManager :
...
IPartyModelClientListener
{
void IPartyModelClientListener.MemberUpdated(EntityId member)
{
}
}
Declare the ApplicationStateManager
instance as the listener on session start:
void IMetaplayLifecycleDelegate.OnSessionStarted()
{
MetaplayClient.PartyClient.SetClientListeners(model =>
{
((PartyModel)model).ClientListener = this;
});
}
That's it! We now have access to the current state of the party we are a member of as MetaplayClient.PartyClient.Model
and we can enqueue client actions to be run by the server-side PartyActor
via MetaplayClient.PartyClient.Context.EnqueueAction()
.
To keep things as simple as possible, let's introduce logic that will create a new party for our player on session start if the player wasn't a member of a party already:
void IMetaplayLifecycleDelegate.OnSessionStarted()
{
...
// Trigger party creation if one didn't already exist
if (MetaplayClient.PartyClient.Model == null)
MetaplayClient.PlayerContext.ExecuteAction(new PlayerCreateParty());
}
Similarly, the operation of joining an existing party is carried out be executing our triggering action PlayerJoinParty
with the target PartyId as input:
void JoinParty(string partyIdInput)
{
EntityId partyId = EntityId.ParseFromStringWithKind(EntityKindGame.Party, partyIdInput);
MetaplayClient.PlayerContext.ExecuteAction(new PlayerJoinParty() { PartyToJoin = partyId });
}
As a final exercise we'll add the functionality for sending chat messages to the party. We've so far only seen actions on the party model originating from the server and triggered in response to sessions starting and ending or from the PlayerActor via an EntityAsk. For chat messages, we'll want to enqueue messages to be executed by the PartyActor from the client directly, without needing to route through the PlayerActor. This is done simply by using the PartyClientAction
base class we introduced earlier:
[ModelAction(1)]
public class SendPartyMessage : PartyClientAction
{
private string Message { get; set; }
private EntityId FromPlayer { get; set; }
private MetaTime Timestamp { get; set; }
SendPartyMessage() {}
public SendPartyMessage(string message)
{
Message = message;
}
public override bool ValidateOnServer(EntityId issuer)
{
// Inject timestamp & sender on server, before action is executed on either end.
Timestamp = MetaTime.Now;
FromPlayer = issuer;
return true;
}
public override MetaActionResult InvokeExecute(PartyModel model, bool commit)
{
if (commit)
{
PartyChatMessage message = new PartyChatMessage(FromPlayer, Message, Timestamp);
model.ChatLog.Enqueue(message);
while (model.ChatLog.Count > 10)
model.ChatLog.Dequeue();
model.ClientListener?.NewChatMessage(message);
}
return MetaActionResult.Success;
}
}
The action will be created on client with our chat message as input and sent to the server PartyActor to be executed on the PartyModel timeline. Finally the action will be broadcast to all connected clients (including the one that sent it in the first place) to be executed in their versions of the model.
The actual operation is simply to add a new PartyChatMessage
into our message log and invoke the associated client listener. The slightly out of ordinary part of this action is our ValidateOnServer()
implementation where we augment the action with sender and timestamp information. While we could gather this information on the sending client, that would mean that a malicious client would be able to forge these values. The ValidateOnServer()
method on the base class was our own addition, so we'll need to actually invoke it in the appropriate place in PartyActor as well:
protected override bool ValidateClientOriginatingAction(
ClientPeerState client,
PartyAction action)
{
if (action is PartyClientAction clientAction)
return clientAction.ValidateOnServer(client.PlayerId);
return true;
}
For sending the chat message on the client, we'll wire up the text input dialog for entering a chat message to call into a function that creates and enqueues the action:
void SendChatMessage(string message)
{
MetaplayClient.PartyClient.Context.EnqueueAction(new SendPartyMessage(message));
}