Skip to main content

Pong 2D: Points and match state

info

This is Elympics basics tutorial: part 3/3. In this part we'll be exploring concepts of synchronizing custom variables and match state. See: Part 1 and Part 2 for more info.

Point synchronization

We're now (after part 2) fully able to run the game online, where each and every player controls their own pad. However, we still need to take a look at the point scoring system, so that the results of each player are always synchronized and free of errors.

Let's take a look at the file ScoreManager.cs. The class ScoreManager is responsible for controlling the flow of the match, as well as storing the current game scores.

Each of the players has a separate variable which stores the amount of scored points, as well as a dedicated event, which is called upon the change of this score value. This event is used by classes controlling, for example, UI shown to the players, so that they can update their internal state when needed.

private int player0score = 0;
public event Action<int> Player0ScoreChanged = null;

Our main goal is to ensure that every player has the correct, up-to-date information about both their own and their opponent's scores. In order to do that, we'll change the classic int type to a synchronized integer value offered by Elympics – which is called (quite understandably) – ElympicsInt.

Let's start by adding the using Elympics;, and move on to changing the types:

private ElympicsInt player0score = new ElympicsInt(0);
public event Action<int> Player0ScoreChanged = null;

private ElympicsInt player1score = new ElympicsInt(0);
public event Action<int> Player1ScoreChanged = null;

The value of ElympicsInt is synchronized for all clients, ensuring consistency between current, local player state and the state that is present on the game server (in the cloud). Moreover – this type offers a ValueChanged event, which is called every time when a value of the variable is changed. It is really handy when programming all sorts of UI or views which have their internal state, but their value is derived from a synchronized variable.

When using ElympicsInt class we cannot directly modify it's internal value. In order to do that, we need access the Value property serving as both the getter and setter of the underlying integer. This means we need to change the code in our AddPoint() function from:

player0score++;
// [...]
player1score++;

To a new form which looks like this:

player0score.Value++;
// [...]
player1score.Value++;

Because ElympicsInt exposes the ValueChanged event, we can modify the rest of the code after changing the internal value for every player. We delete all the declarations and event usage of Player0ScoreChanged and Player1ScoreChanged in ScoreManager.cs.

We still need to allow external classes to access the underlying values, so we modify the variables:

public ElympicsInt player0score { get; private set; } = new ElympicsInt(0);
public ElympicsInt player1score { get; private set; } = new ElympicsInt(0);

A new, modified ScoreManager should look somewhat like this:

using System;
using UnityEngine;
using Elympics;

public class ScoreManager : MonoBehaviour
{
[SerializeField] private Vector3 ballSpawnPoint = Vector3.zero;
[SerializeField] private Ball ballReference = null;
[SerializeField] private int requiredScoreToWin = 5;

public static ScoreManager Instance = null;
public ElympicsInt player0score { get; private set; } = new ElympicsInt(0);
public ElympicsInt player1score { get; private set; } = new ElympicsInt(0);
public event Action<int> GameFinished = null;

private void Awake()
{
if (ScoreManager.Instance == null)
ScoreManager.Instance = this;
else
Destroy(this);
}

private void Start()
{
ResetGame();
}

public void AddPoint(int playerId)
{
if (playerId == 0)
{
player0score.Value++;
}
else if (playerId == 1)
{
player1score.Value++;
}

if (player0score == requiredScoreToWin || player1score == requiredScoreToWin)
FinishGame();
else
ResetGame();
}

// [...]

}

From now on every external class, that wants to be notified about the score value change, will be able to add its own callbacks to the ValueChanged event of ElympicsInt. In order to use it in practice, let's move on to ScoreManagerView.cs, which is responsible for displaying the current score on the screen for the players.

We should see some errors in the Start method. They are the result of our modifications to the ScoreManager class. Because Elympics ValueChanged events come with two arguments – the old and the new value, let's start by changing the number of arguments in the event handling/callback methods.

The resulting modified implementation should look like this:

public void UpdatePlayer0ScoreView(int oldScore, int newScore)
{
player0score.text = newScore.ToString();
}

public void UpdatePlayer1ScoreView(int oldScore, int newScore)
{
player1score.text = newScore.ToString();
}

Now, with the new method signatures, we're able to use them in ValueChanged events from Elympics:

private void Start()
{
ScoreManager.Instance.player0score.ValueChanged += UpdatePlayer0ScoreView;
ScoreManager.Instance.player1score.ValueChanged += UpdatePlayer1ScoreView;
}

The whole ScoreManagerView.cs after modifications will look like this:

using UnityEngine;
using UnityEngine.UI;

public class ScoreManagerView : MonoBehaviour
{
[SerializeField] private Text player0score = null;
[SerializeField] private Text player1score = null;

private void Start()
{
ScoreManager.Instance.player0score.ValueChanged += UpdatePlayer0ScoreView;
ScoreManager.Instance.player1score.ValueChanged += UpdatePlayer1ScoreView;
}

public void UpdatePlayer0ScoreView(int oldScore, int newScore)
{
player0score.text = newScore.ToString();
}

public void UpdatePlayer1ScoreView(int oldScore, int newScore)
{
player1score.text = newScore.ToString();
}
}

Using ElympicsInt means that ScoreManagerAudio.cs will also require some adjustments. This class used to rely on previous events to play sound effects when the score were changed. The changes are very similar to those in ScoreManagerView.cs – a modification to method arguments for event functions. Only the Start and PlayMatchPointAudioClip methods should be modified, and the new implementation will looks like this:

private void Start()
{
audioSource = GetComponent<AudioSource>();

ScoreManager.Instance.player0score.ValueChanged += PlayMatchPointAudioClip;
ScoreManager.Instance.player1score.ValueChanged += PlayMatchPointAudioClip;

ScoreManager.Instance.GameFinished += PlayMatchVictoryAudioClip;
}

private void PlayMatchPointAudioClip(int oldPointsValue, int newPointsValue)
{
audioSource.clip = matchPointClip;
audioSource.Play();
}

After all those code changes, we need to do one more crucial thing. As I'm sure you remember, every object that wants to benefit from network communication has to have ElympicsBehaviour component. We have to add this exact component to the object containing the ScoreManager script.

Let's open the ScoreManager prefab, and add this ElympicsBehaviour.

Open prefab

Add Elympics Behaviour

As I'm sure you've noticed, there's no ScoreManager on the list of Observed MonoBehaviours in the Elympics Behaviour component. In order to change that, the ScoreManager class needs to implement at least one of the interfaces provided by Elympics. Because we don't actually need any additional methods in our class, the perfect interface will be IObservable. This interface is there only to inform Elympics that this particular class should also be processed and synchronized over the network.

public class ScoreManager : MonoBehaviour, IObservable

From now on, counting scores should be fully synchronized! 🎉 After joining the match, players will automatically get their current score values.

Score synchronized

Controlling the match by the server

Our points are now synchronized over the network, but the overall control over the game is executed by each player separately. Purely hypothetically there is a possible scenario where a ball in one player's simulation hits the score area before its position is corrected (reconciled) by the server, and the score value will be updated to a game-ending result before the server is able to correct that. This scenario could possibly lead to one player displaying an end game screen, when the actual game is still being played on the server and the other client.

In such cases, it's best to move the whole match state synchronization to the server and just inform the players (in an RPC-style) when the game has been finished.

To change that, we'll start with the ScoreManager class. From now on, the server will decide whether the game is finished, so we'll start the modification from the if-else block of the AddPoint method. We'll only call the FinishGame method on the server instance, and the ResetGame will be called as before. In order to call ResetGame we'll also verify that the ball reference is present in a case of it being destroyed. The modified code looks as following:

public void AddPoint(int playerId)
{
if (playerId == 0)
{
player0score.Value++;
}
else if (playerId == 1)
{
player1score.Value++;
}

ResetGame();

if (player0score == requiredScoreToWin || player1score == requiredScoreToWin)
FinishGame();
}

private void ResetGame()
{
if (ballReference != null)
{
ballReference.transform.position = ballSpawnPoint;
ballReference.ResetMovement();
}
}

Now, let's focus on the server side. We will make sure that the FinishGame method is only called on the server. In order to do that, we need our game to detect if it's currently running on the Game Engine (server) side. To do that, our class – ScoreManager – needs to inherit from a specific Elympics-provided type – ElympicsMonoBehaviour. This way, we'll get access to many new components and features – including checking whether the game is being run on the server, client or bot.

public class ScoreManager : ElympicsMonoBehaviour, IObservable
{

// [...]

public void AddPoint(int playerId)
{

// [...]

if (Elympics.IsServer && (player0score == requiredScoreToWin || player1score == requiredScoreToWin))
FinishGame();
}
}

Now, looking at the FinishGame() implementation, we can see that the game end will be handled and executed only on the server. We have to additionally modify the rest of the code, so that all connected clients are notified about the game finish. To do that, we need to transform the FinishGame() method to a synchronized ElympicsBool, which will inform all players about the finish game event. So, let's add the appropriate entry at the beginning of the class:

public ElympicsBool gameFinished { get; private set; } = new ElympicsBool(false);

While we're at it, let's also modify our if statement in the AddPoint method, swapping the FinishGame() method with the new gameFinished value:

if (Elympics.IsServer && (player0score == requiredScoreToWin || player1score == requiredScoreToWin))
gameFinished.Value = true;

Notice that the FinishGame() method is now never called. Moreover – the FinishGame() method was used for calling a GameFinished event, which informed other objects responsible for game view that the game had been finished. As I mentioned in the ElympicsInt part above – synchronized objects from Elympics (like ElympicsInt or ElympicsBool) have a ValueChanged callback event, which informs instances about... well... the value being changed. Using this event it's really easy to migrate our code and take advantage of this new feature.

First, let's modify the FinishGame() implementations (renaming it as OnGameFinished() by the way). We'll attach the appropriate callbacks in the Start() method:

private void Start()
{
ResetGame();

gameFinished.ValueChanged += OnGameFinished;
}

// [...]

private void OnGameFinished(bool oldValue, bool newValue)
{
if (ballReference != null)
Destroy(ballReference);

GameFinished?.Invoke(player0score > player1score ? 0 : 1);
}

Thanks to this implementation, the order of events will be as follows:

  • The server decides that the game has been finished when one of the players scores the required amount of points
  • The server changes the value of gameFinished variable to true
  • The new value of gameFinished is synchronized to every player with the state that is present on the server – so its value is changed to true in all instances
  • The callback to handle the match finish event (subscribed to ValueChanged event) is called on every instance – both server and client

Now we can be certain that the control of the match flow will only be controlled by the server. The whole class after changes described in this part should look like this:

using System;
using UnityEngine;
using Elympics;

public class ScoreManager : ElympicsMonoBehaviour, IObservable
{
[SerializeField] private Vector3 ballSpawnPoint = Vector3.zero;
[SerializeField] private Ball ballReference = null;
[SerializeField] private int requiredScoreToWin = 5;

public static ScoreManager Instance = null;

public ElympicsInt player0score { get; private set; } = new ElympicsInt(0);
public ElympicsInt player1score { get; private set; } = new ElympicsInt(0);
public ElympicsBool gameFinished { get; private set; } = new ElympicsBool(false);
public event Action<int> GameFinished = null;

private void Awake()
{
if (ScoreManager.Instance == null)
ScoreManager.Instance = this;
else
Destroy(this);
}

private void Start()
{
ResetGame();
gameFinished.ValueChanged += OnGameFinished;
}

public void AddPoint(int playerId)
{
if (playerId == 0)
{
player0score.Value++;
}
else if (playerId == 1)
{
player1score.Value++;
}

ResetGame();

if (Elympics.IsServer && (player0score == requiredScoreToWin || player1score == requiredScoreToWin))
gameFinished.Value = true;
}

private void ResetGame()
{
if (ballReference != null)
{
ballReference.transform.position = ballSpawnPoint;
ballReference.ResetMovement();
}
}

private void OnGameFinished(bool oldValue, bool newValue)
{
if (ballReference != null)
Destroy(ballReference);

GameFinished?.Invoke(player0score > player1score ? 0 : 1);
}
}

Summary

Is that it?! Yes! 🎉 The game is now fully ready for online, server authoritative gameplay using Elympics cloud! Great job! 💪