Appearance
Appearance
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 process has three steps:
PlayerModel
.[ModelAction(ActionCodes.PlayerPurchaseCardPack)]
public class PlayerPurchaseCardPack : PlayerAction
{
public PlayerPurchaseCardPack() { }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
if (player.NumGold < CardPackOpenCost)
return ActionResult.NotEnoughGold;
if (commit)
{
// Spend currency.
player.NumGold -= CardPackOpenCost;
// Trigger event on server to handle the opening.
player.ServerListener.OnCardPackOpened();
}
return ActionResult.Success;
}
}
The callback must be added to IPlayerModelServerListener
interface and implemented in PlayerActor
and OfflineServer
.
public interface IPlayerModelServerListener
{
void OnCardPackOpened();
}
public class PlayerActor : IPlayerModelServerListener
{
void IPlayerModelServerListener.OnCardPackOpened()
{
// Randomize the contents of the card pack.
List<CardId> cards = RandomizeCardPackResult();
// Store the result in the player's state as pending.
EnqueueServerAction(new PlayerCardPackResult(cards));
}
List<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 from a global array.
return new List<CardId>
{
AllCardIds[rng.NextInt() % AllCardsIds.Length],
AllCardIds[rng.NextInt() % AllCardsIds.Length]
};
}
}
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 List<CardId> PendingCardPackResult = null;
}
[ModelAction(ActionCodes.PlayerCardPackResult)]
public class PlayerCardPackResult : PlayerSynchronizedServerActionCore<PlayerModel>
{
public List<CardId> Cards { get; private set; }
PlayerCardPackResults() { }
public PlayerCardPackResults(List<CardId> cards) { Cards = cards; }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
if (commit)
{
// Store the pending result in player's state.
player.PendingCardPackResult = Cards;
}
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;
}
}
💡 Note
The client should periodically check for the existence of PlayerModel.PendingCardPackResult
and invoke the PlayerClaimPendingCardPack
Action when there is a pending result. The reason to use polling (instead of events) 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.