Appearance
Appearance
PlayerModel
represents the state of a player.PlayerAction
s modify the PlayerModel
.Programming F2P mobile games is substantially different than making premium or pure single-player games. For example, making games that have sensitive features like actual money transactions and cheat-proofing require a different architecture than regular games, where every step of gameplay can be validated on the server.
After this article, you will understand how to use Metaplay's Actions, Models, and Configs paradigm to develop secure, scalable, and efficient game logic.
For a more practical learning experience, you can also start with the Tutorial: Game Logic!
You can use Models to store everything needed to reconstruct your game state, such as a player’s stats, level, inventory, and even their whole base in a city-building or base-building game. The most common Model is the PlayerModel
class, which holds all of the state of a given player, and functions as the in-game equivalent of a game account. You can find PlayerModel.cs
as well as PlayerActions.cs
inside the Assets/SharedCode
directory of your Unity project.
// The PlayerModel class should contain all the state of the player.
public class PlayerModel : PlayerModelBase<...>
{
...
[MetaMember(209)] public PlayerStats Stats { get; private set; }
[MetaMember(210)] public PlayerInventory Inventory { get; private set; }
...
}
INFO
Check it out! The Metaplay SDK ships with a Model Inspector that displays your game's Models properties in real time while in Play Mode. Take a look at Model Inspector to learn how to use it.
Actions, on the other hand, are what you use to modify Models. As a gameplay programmer, you primarily develop new Actions.
[ModelAction(ActionCodes.ExampleAction)]
public class ExampleAction : PlayerAction
{
...
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
// At their simplest, actions modify the properties of your PlayerModel
if (commit)
player.Stats.SomeStat += someValue;
return ActionResult.Success;
}
}
INFO
Pro tip: Players are not the only game entities that benefit from the patterns of Models and Actions. For example, a guild has its own Model to manage state and respective Actions for modifying it.
Lastly, your Game Configs can hold any aspect of the game you want to make your data-driven. Configs can be imported from designer-friendly external tools like Google Sheets and updated without touching the game's code. Using Configs also enables advanced workflows such as over-the-air updates later in the project.
// After building the configs, you can access them via PlayerModel.GameConfig:
string configItem = player.GameConfig.Global.SomeConfig;
The C# code that defines Models and Actions is what we call “shared code” and exists both in the server and the client.
When the game starts up, the PlayerModel
associated with the device is loaded, and the game can start. Then, whenever the player or any client event triggers an Action, it is executed immediately on the client and sent to be remotely run on the server. This way, the clients can show immediate feedback to players without any network delays, while the server securely runs all Actions to prevent hacked clients from cheating in the game.
The Metaplay SDK automatically keeps track of the execution Ticks, or timestamps, to make sure the server and client models stay deterministically in sync. In the case of client and server de-syncs (for example, due to a hacked client or non-deterministic game logic), the server state keeps the game secure.
At this point, you might want to get your hands dirty and implement a PlayerModel
of your own as well as a few Actions to create a simple game. Feel free to jump out of this page and follow the guide at Tutorial: Game Logic for a more practical experience of these same fundamentals.
You can always come back to this page later to learn the more profound concepts!
PlayerModel
in Depth Even the simplest Models have quite a few things going on. Consider this example PlayerModel
:
[MetaSerializableDerived(1)] // Mark the class as serializable
public class PlayerModel : PlayerModelBase<PlayerModel, ...>
{
// Members which make up the player state
[MetaMember(100)] public string PlayerName = "Guest";
[MetaMember(101)] public int PlayerLevel = 1;
[MetaMember(102)] public int Experience = 0;
public PlayerModel() {}
public static readonly int TicksPerSecond = 10;
// The Tick() function executes X times per second. It can be used to
// advance the state of the player. Here, we just bump the Experience
// of the player on each tick.
public void Tick(IChecksumContext checksumCtx)
{
Experience++;
}
}
Let's break down the essential parts:
[MetaSerializable]
attribute marks the class as serializable by the Metaplay SDK. Any types (classes, struct, enums, etc.) persisted in the database or transferred over the wire between the client and server need to be tagged with the attribute. In this case, we use the [MetaSerializableDerived]
attribute to indicate that this class is derived from another class.PlayerModelBase
class, which provides various base features and services common to all players, such as name and level, information about an active session, language and location (if enabled), push notification tokens, in-app purchases (history and pending purchases), etc.[MetaMember(tagId)]
. The attribute informs the serializers that the given member should be serialized (and deserialized) based on its tagId
. The tagId
s between 0 and 99 are reserved for the base class, so you should use tags from 100 onwards. Members without the attribute are omitted when the object is persisted into the database or sent to the client.TicksPerSecond
defines how many tick updates happen per second.PlayerModel
which should happen over time without separate input from the player should go into the Tick()
method.In addition to Actions, you can update Models over time by attaching game logic to Ticks, as in the above PlayerModel
sample code. For example, you could use this to simulate timers or other game time-dependent logic while ensuring the client and server Models stay in sync.
For a deeper exploration of how the execution timeline of Ticks and Actions works, have a look at the Deep Dive: Game Logic Execution Model page.
PlayerModel
Listeners The PlayerModel
uses listeners to communicate things from the shared game logic code into the outside world: on the client, you can use them to update the visuals, and on the server, you can trigger server-only behavior. The listeners are game-specific and can be implemented with C# interfaces, events, or other mechanisms. Below is an example of how to implement them using interfaces:
// Game-specific interface for triggering client-side events.
public interface IPlayerModelClientListener
{
void OnProducerUnlocked(ProducerModel producer);
void OnProducerCollected(ProducerModel producer);
}
// Class with empty implementations for the events which can be
// used when events are not of interest (eg, the server doesn't
// need client-side events). Useful for avoiding null checks for
// each call to the event.
public class EmptyPlayerModelClientListener : IPlayerModelClientListener
{
public static readonly EmptyPlayerModelClientListener Instance = new EmptyPlayerModelClientListener();
public void OnProducerUnlocked(ProducerModel producer) { }
public void OnProducerCollected(ProducerModel producer) { }
}
// Skipped similar interface/class for server-side events.
// Add the listeners as members to PlayerModel:
[MetaSerializableDerived(1)]
public class PlayerModel : PlayerModelBase<PlayerModel, ...>
{
...
// Register listeners & default to empty implementations.
[IgnoreDataMember] public IPlayerModelServerListener ServerListener { get; set; } =
EmptyPlayerModelServerListener.Instance;
[IgnoreDataMember] public IPlayerModelClientListener ClientListener { get; set; } =
EmptyPlayerModelClientListener.Instance;
// Override the getter for PlayerModelRuntimeData.
public override IModelRuntimeData<PlayerModel> GetRuntimeData() =>
new PlayerModelRuntimeData(this);
}
The [IgnoreDataMember]
is used on the references to avoid them being serialized or getting included when using the PrettyPrint
class.
Let us consider a more complete example of a simple Action that consumes experience to give the player a level-up:
[ModelAction(ActionCodes.PlayerLevelUp)]
public class PlayerLevelUp : PlayerAction
{
public PlayerLevelUp() {}
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
// Get the experience requirement from the game configs
int expToLevelUp = player.GameConfig.Global.GetExpToLevelUp(player.Level);
// Check action validity so server catches any hack attempts
if (player.Experience < expToLevelUp)
return ActionResult.NotEnoughExperience;
if (player.Level == player.GameConfig.Global.PlayerMaxLevel)
return ActionResult.AlreadyAtMaxLevel;
// If commit is requested (ie, not a dry-run), apply changes
if (commit)
{
// Modify the PlayerModel
player.Experience -= expToLevelUp;
player.Level += 1;
// Log the operation
player.Log.Info("Leveled up to {NewLevel}", player.Level);
}
// Return success.
return ActionResult.Success;
}
}
Let's break down the key parts of the above example:
[ModelAction(actionId)]
attribute must be included in all Action classes. The actionId
must resolve to an integer value, and this value must be unique within the game as the SDK uses it to uniquely identify the Action in serialized form. The SDK will report errors at startup if duplicates are detected. To make the actionIds
more manageable, they are all defined in one place: ActionCodes.cs
.[ModelAction]
attribute implicitly includes the [ModelSerializable]
as well, so it doesn't need to be manually specified, even though the Action classes must be serializable.PlayerAction
, which is the base class for all Actions operating on PlayerModel
. A game may include multiple such domains, each with its own XxxModel
class and a corresponding XxxAction
base class for its Actions.Execute()
method is used either to actually execute the Action (when commit
is true) or to check whether the Action would complete successfully (when commit
is false).Execute()
must check the validity of the Action. If any validation attempts fail, a corresponding ActionResult
value should be returned (or ActionResult.Success
upon successfully executing the Action).MetaActionResult
type, and ActionResult
is a static class containing all the game-specific error codes.Execute()
method should only apply its modifications to the state if commit
is true. If commit
is false, the method should only do a dry-run to check whether the Action would execute successfully. The dry-run mechanism can be used to avoid duplicate code when the results of an Action need to be known before you run it: for example, you can use it to disable buttons for actions that the Player doesn't have the required resources for.DANGER
Hacker warning: Hacked clients can send any Actions with arbitrary arguments to the server, so it is imperative that the shared logic fully validates that the Actions are legit. For example, the player should have all the required resources in their inventory to perform an Action that consumes resources.
Actions may also contain member data. Below is a slightly more complicated example of Action:
[ModelAction(ActionCodes.PlayerUpgradeUnit)]
public class PlayerUpgradeUnit : PlayerAction
{
public int UnitId UnitId { get; private set; }
PlayerUpgradeUnit() {}
public PlayerUpgradeUnit(int unitId) { UnitId = unitId; }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
// Check validity so server catches any hack attempts
int goldCost = ComputeUnitUpgradeCost(..);
if (player.NumGold < goldCost)
return ActionResult.NotEnoughResources;
// If commit is requested (ie, not a dry-run), apply the change
if (commit)
{
player.Units[UnitId].Level += 1;
player.NumGold -= goldCost;
}
return ActionResult.Success;
}
}
The members of an Action (such as UnitId
here) do not need explicit [MetaMember]
as the [ModelAction]
attribute causes the members to receive the [MetaMember]
attribute implicitly.
The members should be declared as C# properties, with a public getter and private setter, and the members should be immutable, i.e., the members should not be modified after the creation of the Action.
For Actions with members, it is good practice to include another constructor in addition to the required empty one. This constructor should take the values for all the Action's members as inputs and then initialize them.
The Metaplay SDK supports server Actions that can only the server can trigger. They are convenient when writing game logic that the client should not be aware of, such as generating random rewards so that the client cannot predict the results in any way.
Server Actions are discussed on the PlayerModel Server Updates page.
While all games will have a Model and Actions for players to handle their own state, you might have other gameplay needs that are best implemented using separate Models and corresponding Actions. This is absolutely in the happy path of the Metaplay SDK and a great way to implement persistent multiplayer features of your own!
While we do not have a dedicated guide for this yet, you can use our guilds framework as an end-to-end example of implementing your own custom Models and Actions to facilitate multiplayer gameplay. Have a look at the Preview: Guild Framework page to get started!
Now that you've got a nice mental model of how programming games in Metaplay works, you can get your hands dirty and implement some gameplay of your own!
Once you are ready to learn more, consider continuing with one of the following topics: