Appearance
Implementing Cheat-Proof Randomization
This guide explains why randomization of gameplay elements needs to be cheat-proof and how to easily implement this with the Metaplay SDK.
Appearance
This guide explains why randomization of gameplay elements needs to be cheat-proof and how to easily implement this with the Metaplay SDK.
PlayerModel.Many games rely on randomization for loot boxes or gacha, item/unit upgrades with randomized outcomes, opening card packs, or other similar features.
The most straightforward way to implement randomization is to store a RandomPCG random number generator in the PlayerModel and directly pull values from it within a PlayerAction. While this approach is simple, it is prone to various hacking attempts by players.
With client-driven randomization, a common hack is to switch the device into airplane mode, then trigger the randomization to see the outcome. This way, the server isn't aware of the operation, and it can be effectively reverted by keeping the device in airplane mode. If the outcome was a desired one, the airplane mode can be disabled, and the client will then commit the operation to the server.
The way to fix this is to perform the randomization on the server, such that the client is only informed of the results after the randomization outcome has been determined.
The cheat-proof randomization has three steps:
PlayerModel.In the request phase, client pays any resources needed for the operation, and submits any parameters to the server. So far, the execution model is identical and deterministic on both server and client. By using a ServerListener, the action then triggers the Server-only computation where the actual randomization happens.
In the response phase, server generates the response completely hidden from the client. This execution does not need to be deterministic logic and can use any data sources available, or even enqueue async operations. The response is sent in the form of a ServerAction enqueued by the server. In the most general case, this ServerAction then places the response into a pending state.
Finally, in the claiming phase, client uses an action to claim generated resources from the pending state into their proper state. This action can then trigger necessary client animations, showing the user what items they randomized.
The client should periodically check for the existence of a pending resource and invoke the claiming Action when there is a pending result. The reason to use polling (instead of client listener event) is that there may be a pending result when the client connects to the server. This can happen when the opening process was interrupted by a client crash or network disconnect. By controlling when and how the claim is executed, game can show proper animation in the happy path, but in a login after a disconnect show only a popup message telling what they randomized.
💡 Simplified Design
In the above design, we place the resources to a separate pending state and then use a separate action to Claim these resources. This allows client to control exactly when to Claim resources. If this level of control is not needed, the ServerAction can grant the resources to the player directly, and trigger a ClientListener to allow UI to display the granted resources. This avoids the need for the pending state and the Claim phase. However in the case of a disconnect, the resources are still granted but client cannot show the user what those resources were.
First, we implement the client-issued request action:
[ModelAction(ActionCodes.PlayerPurchaseCardPack)]
public class PlayerPurchaseCardPack : PlayerAction
{
PlayerPurchaseCardPack() { }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
if (player.NumGold < player.GameConfig.CardConfig.CardPackOpenCost)
return ActionResult.NotEnoughGold;
if (commit)
{
// Spend currency.
player.NumGold -= player.GameConfig.CardConfig.CardPackOpenCost;
// Trigger event on server to handle the opening.
player.ServerListener.OnCardPackOpened();
}
return ActionResult.Success;
}
}Then, we add a callback mechanism by adding a Listener to the PlayerModel. We also create an empty implementation to be used when not executing in a server environment.
public interface IPlayerModelServerListener
{
void OnCardPackOpened();
}
public class EmptyPlayerModelServerListener : IPlayerModelServerListener
{
public static readonly EmptyPlayerModelServerListener Instance = new EmptyPlayerModelServerListener();
public void OnCardPackOpened() {}
}
public class PlayerModel
{
// Server listener for handling server-side callbacks.
[IgnoreDataMember]
public IPlayerModelServerListener ServerListener { get; set; }
= EmptyPlayerModelServerListener.Instance;
}The callback must be added to the IPlayerModelServerListener interface and implemented in PlayerActor and OfflineServer.
Register listener. And in the listener callback, enqueue the followup action:
public class PlayerActor :
PlayerActorBase<PlayerModel>,
IPlayerModelServerListener
{
protected override void OnSwitchedToModel(PlayerModel model)
{
// Register this actor as the server listener on the model.
model.ServerListener = this;
}
void IPlayerModelServerListener.OnCardPackOpened()
{
// Randomize the contents of the card pack.
CardId[] cards = RandomizeCardPackResult();
// Store the result in the player's state as pending.
EnqueueServerAction(new PlayerCardPackResult(cards));
}
CardId[] RandomizeCardPackResult()
{
// Initialize a new RNG on the server. The client cannot predict
// the results of this.
RandomPCG rng = RandomPCG.CreateNew();
// Use the RNG to pick a few random cards
var allCardIds = Model.GameConfig.CardConfig.AllCards.Keys.ToArray();
return new CardId[]
{
allCardIds[rng.NextInt(allCardIds.Length)],
allCardIds[rng.NextInt(allCardIds.Length)]
};
}
}Register listener. And in the listener callback, enqueue the followup action:
public class OfflineServer :
DefaultOfflineServer,
IPlayerModelServerListener
{
protected override void SetSelfAsPlayerListener(IPlayerModelBase playerBase)
{
// Register this object as the server listener on the model.
((PlayerModel)playerBase).ServerListener = this;
}
void IPlayerModelServerListener.OnCardPackOpened()
{
// Randomize the contents of the card pack.
CardId[] cards = RandomizeCardPackResult();
// Store the result in the player's state as pending.
EnqueueServerAction(new PlayerCardPackResult(cards));
}
CardId[] RandomizeCardPackResult()
{
// .. simulate server implementation
}
}The pending result from opening the card pack is stored as PlayerModel.PendingCardPackResult, and is written by the PlayerCardPackResult ServerAction:
public class PlayerModel
{
// List of cards from the previously opened card pack.
// Null when no opened card pack is pending.
[MetaMember(40)] public IReadOnlyList<CardId> PendingCardPackResult = null;
}[ModelAction(ActionCodes.PlayerCardPackResult)]
public class PlayerCardPackResult : PlayerSynchronizedServerActionCore<PlayerModel>
{
public IReadOnlyList<CardId> Cards { get; private set; }
PlayerCardPackResult() { }
public PlayerCardPackResult(IReadOnlyList<CardId> cards) { Cards = cards; }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
if (commit)
{
// Append the new cards to the pending state
var pending = (player.PendingCardPackResult ?? []).Concat(Cards).ToArray();
player.PendingCardPackResult = pending;
}
return ActionResult.Success;
}
}The final step is to claim the cards from the client using another Action:
[ModelAction(ActionCodes.PlayerClaimPendingCardPack)]
public class PlayerClaimPendingCardPack : PlayerAction
{
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
// Ensure operation is valid (a card pack result must be pending).
if (player.PendingCardPackResult == null)
return ActionResult.NoPendingCardPack;
if (commit)
{
// Add pending cards to inventory & clear pending.
player.Inventory.AddCards(player.PendingCardPackResult);
player.PendingCardPackResult = null;
}
return ActionResult.Success;
}
}