Appearance
Appearance
General understanding of Metaplay's programming model - This guide assumes you understand how models, actions, and game configs work. If you need a refresher, you can go through the Getting Started section of again.
Wordle Project Parts 1 & 2 - This guide continues the project developed in Tutorial: Game Logic and Tutorial: Game Configs. You can either build it following the guide or import the project from Samples/Wordle/
inside the SDK.
PlayerModel
.In this series' previous tutorials, we've been creating a Wordle clone using the Metaplay SDK. First, we set up the gameplay loop using models and actions systems and later added configs using a dynamic spreadsheet instead of hardcoded values. However, a significant limitation remained: having the answer validation be done by the client, making the answer vulnerable to hacking and cheating attempts.
In this guide, we'll modify the project to protect the game's validation steps and make it safe against dishonest play. It will also serve as an introduction to some new topics that will be very handy on your journey using Metaplay, such as:
Here's an overview of the steps we'll take to make the game cheat-proof.
ServerEvaluateGuess
function on the server.ServerEvaluateGuess
to the client using a Listener hook.PlayerSubmitGuess
to call ServerEvaluateGuess
.ServerUpdateGuess
server action that updates the shared PlayerModel
and a ServerRevealSolution
action to update and show the solution when a round is over.That's it! The rest of the code can be kept precisely as-is. Here's a nifty diagram of how it works:
The source code for this project, as well as Tutorial: Game Logic and Tutorial: Game Configs is available as a Unity project in the Samples/Wordle
directory in the SDK.
You can switch between this and the previous guides' version of the project using the Tutorial
button on Unity's menu.
Use the Part 2: Game Configs
version as a base to start this project and code along or take a look at Part 3: Cheat-Proof Gameplay
for the final result.
Believe it or not, we already did most of the work for this step in the previous tutorials! So our spreadsheet on Google Sheets can stay the same, and so can our SolutionInfo
and SolutionId
classes. The big difference here is that we'll switch out the SharedGameConfig
for ServerGameConfig
, as follows:
// Remember to switch SharedGameConfigBase to ServerGameConfigBase too.
public class ServerGameConfig : ServerGameConfigBase
{
[GameConfigEntry("Solutions")]
public GameConfigLibrary<int, SolutionInfo> Solutions { get; protected set; }
public string GetSolutionForRound(int roundIndex)
{
return Solutions[roundIndex % Solutions.Count].Word;
}
}
And just like that, the client won't be able to access the solution configs anymore. Instead, the config archive containing the data will be delivered only to the server, so any rogue calls to the Solutions
config from the client will be in vain.
Rebuilding Required
Note that for the server configs to work you must rebuild them in the editor's GameConfig Build menu, as seen in the previous guide.
The next step is to remove the guess evaluation logic from the SubmitGuess
action and move it to the server code. We'll split this logic into two main parts:
GuessResult
object.Evaluating the guess is the part that needs to be secured, so we'll add it as a method of the Player Actor, which is not shared code. You can find PlayerActor.cs
at Backend/Server/Player/PlayerActor.cs
. Since GuessResult
does not contain the actual solution, it's ok to send it to a shared server action that we'll call ServerUpdateGuess
. The issue is, we want to display the solution to the player after a game is over. For this, we'll create a separate server action called ServerRevealSolution
but only ever call it after a round is over.
Like in part 2, we want to safeguard the possibility of someone changing the configs mid-round, thus creating inconsistent results between guesses. Fore this reason, we'll store the Solution
in the PlayerActor
whenever the first guess for a round is sent, and update it after each round.
public sealed class PlayerActor : PlayerActorBase<PlayerModel>, IPlayerModelServerListener
{
public PlayerActor(EntityId playerId) : base(playerId)
{
}
protected override void OnSwitchedToModel(PlayerModel model)
{
model.ServerListener = this;
}
protected override string RandomNewPlayerName()
{
return Invariant($"Guest {new Random().Next(100_000)}");
}
public void ServerEvaluateGuess()
{
bool isLastRound = (Model.GuessedWords.Count == (PlayerModel.MaxGuesses - 1));
if (Model.GuessedWords.Count == 0)
{
ServerGameConfig serverConfig = (ServerGameConfig)_specializedGameConfig.ServerConfig;
Model.Solution = serverConfig.GetSolutionForRound(Model.RoundIndex);
}
GuessResult[] result = EvaluateWordGuess(Model.CurrentWord, Model.Solution);
bool isSolved = (Model.CurrentWord == Model.Solution);
if (isLastRound || isSolved)
EnqueueServerAction(new ServerRevealSolution(Model.Solution, result));
else
EnqueueServerAction(new ServerUpdateGuess(result));
}
private GuessResult[] EvaluateWordGuess(string guess, string solution)
{
char[] remaining = solution.ToCharArray();
GuessResult[] results = new GuessResult[guess.Length];
// Find all correct letters in correct place
for (int i = 0; i < guess.Length; i++)
{
if (guess[i] == remaining[i])
{
results[i] = GuessResult.Correct;
remaining[i] = '\0';
}
}
// Find all correct letters in incorrect places
for (int i = 0; i < guess.Length; i++)
{
// Only handle non-matched letters
if (results[i] == GuessResult.Empty)
{
int foundNdx = Array.IndexOf(remaining, guess[i]);
if (foundNdx != -1)
{
results[i] = GuessResult.Partial;
remaining[foundNdx] = '\0';
}
else
results[i] = GuessResult.Wrong;
}
}
return results;
}
}
The player model uses server and client Listeners to communicate things from the shared game logic code into the outside world. For this project, we'll use a server Listener to trigger ServerEvaluateGuess
on the Player Actor from inside the PlayerSubmitGuess
action.
// The first step is to add `ServerEvaluateGuess` to both the `IPlayerModelServerListener`
// interface and the `EmptyPlayerModelServerListener` class.
public interface IPlayerModelServerListener
{
void ServerEvaluateGuess();
}
public class EmptyPlayerModelServerListener : IPlayerModelServerListener
{
public static readonly EmptyPlayerModelServerListener Instance = new EmptyPlayerModelServerListener();
public void ServerEvaluateGuess() { }
}
// It's also necessary to assign an instance of the EmptyPlayerModelServerListener
// to the ServerListener member of the player model.
[MetaSerializableDerived(1)]
[SupportedSchemaVersions(1, 1)]
public class PlayerModel :
PlayerModelBase<
PlayerModel,
PlayerStatisticsCore
>
{
...
public IPlayerModelServerListener ServerListener = EmptyPlayerModelServerListener.Instance;
...
}
In the Player Actor itself, we need to indicate that, as the game starts, it should be assigned as the model's server listener.
public sealed class PlayerActor : PlayerActorBase<PlayerModel>, IPlayerModelServerListener
{
...
protected override void OnSwitchedToModel(PlayerModel model)
{
model.ServerListener = this;
}
...
}
Lastly, we will remove all of the logic from the PlayerSubmitGuess
action and replace it with a call to the server listener. We'll also add a ServerEvaluatePending
member to the player model to stop a player from double-sending a guess before the server responds with the solution, in case the connection is slow.
public class PlayerModel : ...
{
...
[MetaMember(206)] public bool ServerEvaluatePending { get; set; } = false;
}
Database Reset Required
Since we're changing the MetaMember
numbering between this and the last guide, it's important that you delete the local database before trying to run the game to avoid the conflict between the new and old player models. You can to this by selecting Metaplay > Local Server > Delete Local Database in the toolbar.
Now we can safely add the call to PlayerSubmitGuess
, and check if the client is waiting for a server response before allowing the player to submit a guess.
[ModelAction(ActionCodes.PlayerSubmitGuess)]
public class PlayerSubmitGuess : PlayerAction
{
public PlayerSubmitGuess() { }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
if (player.CurrentWord.Length != PlayerModel.WordSize)
return ActionResult.WordIncomplete;
if (player.GuessedWords.Count == PlayerModel.MaxGuesses)
return ActionResult.TooManyGuesses;
if (player.ServerEvaluatePending)
return ActionResult.AwaitingServerResponse;
if (commit)
{
// Instead of running the evaluating logic locally, we're sending it off ot the
// server where the solution won't be visible to the client.
player.ServerEvaluatePending = true;
player.ServerListener.ServerEvaluateGuess();
}
return ActionResult.Success;
}
}
We'll also want to add this check to PlayerAddLetter
and PlayerDeleteLetter
, to prevent the player from editing the guess before the server answer arrives, thus breaking the game state.
[ModelAction(ActionCodes.PlayerAddLetter)]
public class PlayerAddLetter : PlayerAction
{
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
if (player.ServerEvaluatePending)
return ActionResult.AwaitingServerResponse;
...
}
}
[ModelAction(ActionCodes.PlayerDeleteLetter)]
public class PlayerDeleteLetter : PlayerAction
{
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
if (player.ServerEvaluatePending)
return ActionResult.AwaitingServerResponse;
...
}
}
Don't forget to also add AwaitingServerResponse
to your ActionResult
.
The server action we'll add to update the PlayerModel
with the evaluated guess results needs to be part of the shared code. This does not affect our concerns with making the solution checking server-only, since the actual processing is still done on the Server Actor. For ServerUpdateGuess
we'll only pass GuessResult
as the argument, i.e., what squares are to be colored green or yellow. For ServerRevealSolution
we will send over the actual solution in text format, but that action is only ever called after a game is over. Either if the player has used up the maximum amount of guesses or has guessed correctly. It's also important to set the player's ServerEvaluatePending
to false so the player can continue playing after receiving the server response.
[ModelAction(5004)]
public class ServerUpdateGuess : PlayerSynchronizedServerActionCore<PlayerModel>
{
public GuessResult[] Result { get; private set; }
public ServerUpdateGuess() { }
public ServerUpdateGuess(GuessResult[] result) { Result = result; }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
if (commit)
{
player.GuessedWords.Add(player.CurrentWord);
player.GuessResults.Add(Result);
player.CurrentWord = "";
player.ServerEvaluatePending = false;
}
return ActionResult.Success;
}
}
[ModelAction(5005)]
public class ServerRevealSolution : PlayerSynchronizedServerActionCore<PlayerModel>
{
public string Solution { get; private set; }
public GuessResult[] Result { get; private set; }
public ServerRevealSolution() { }
public ServerRevealSolution(string solution, GuessResult[] result) { Solution = solution; Result = result; }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
bool isSolved = player.CurrentWord == Solution;
if (commit)
{
player.GuessedWords.Add(player.CurrentWord);
player.GuessResults.Add(Result);
player.CurrentWord = "";
if (isSolved || (player.GuessedWords.Count == PlayerModel.MaxGuesses))
{
player.RoundStatus = isSolved ? RoundStatus.Win : RoundStatus.Loss;
if (isSolved)
{
player.RoundsWon++;
player.WinStreak++;
player.MaxWinStreak = Math.Max(player.MaxWinStreak, player.WinStreak);
}
else
{
player.WinStreak = 0;
}
player.CurrentSolution = Solution;
player.RoundsPlayed++;
}
player.ServerEvaluatePending = false;
}
return ActionResult.Success;
}
}
Now that all of the player stats have been updated on the player model by ServerUpdateServer
, all there is left to do is update GameLogic.cs
. Hence, it gets the values from the player model and not the configs directly to update the results popup.
public GameObject ResultDialog;
void Update()
{
...
// Show stats (when round is not active)
if (player.RoundStatus != RoundStatus.Playing)
{
PlayerStats playerStats = ResultDialog.GetComponent<PlayerStats>();
bool isWin = player.RoundStatus == RoundStatus.Win;
playerStats.ResultLabel.text = isWin ? "Congratulations!" : $"Solution: {player.SolutionDialog}";
playerStats.PlayedLabel.text = player.RoundsPlayed.ToString();
playerStats.WinPercentLabel.text = (player.RoundsWon * 100 / player.RoundsPlayed).ToString();
playerStats.CurrentStreakLabel.text = player.WinStreak.ToString();
playerStats.MaxStreakLabel.text = player.MaxWinStreak.ToString();
ResultDialog.SetActive(true);
}
else
{
ResultDialog.SetActive(false);
}
}
And that's it! We can finally see it in action:
It might look the same, but our gameplay loop is secured, and the solution is being checked server-side. You could now safely release it for real players, without worrying about cheaters.
After going through this tutorial series, you should have a good grasp of the main parts you need to bring your own game to Metaplay. Now is a good time to explore the documentation on your own terms and go after whatever catches your eye. You can also go a bit further into some features used for this guide, such as: