In the last part of the pong series, you'll be building a fully synchronized point scoring system. Ready to dive in?

Point synchronization

Now, after part II of this tutorial, you’re able to run your pong game online with each player controlling their own pad. However, you still need to take care of the point scoring system so that the results of each player are always synchronized and free of errors.

Take a look at the file called ScoreManager.cs. The class ScoreManager is responsible for controlling the flow of the match as well as storing the current game scores.

Each player has a separate variable storing the number of the scored points as well as a dedicated event that is called upon the change of this score value. This event is used by classes controlling e.g. the 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 make sure that every player has the correct, up-to-date information about both their own and their opponent’s scores. To do that, change the classic int type to a synchronized integer value offered by Elympics called (quite understandably) ElympicsInt.

First, add 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, which ensures consistency between the current, local player state and the state that is present on the game server (in the cloud). Moreover, this type offers a ValueChanged event that is called every time when a value of the variable is changed. It’s really handy when programming all sorts of UIs or views that have their internal state, but their value is derived from a synchronized variable.

Warning

Currently the ValueChanged event is called inside ElympicsUpdate at the exact time of the value change. It means that you cannot assume that all other values have been updated for this particular tick. This behaviour will be changed in a future release to ensure the synchronization of all variables before calling any ValueChanged events.

When using the ElympicsInt class, you cannot directly modify it’s internal value. To do that, you need to access the Value property serving both as 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++;

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

You still need to allow external classes to access the underlying values, so let's 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. To use it in practice, move on to ScoreManagerView.cs responsible for displaying the current score on the screen for the players.

You should see some errors in the Start method. That's the result of our modifications to the ScoreManager class. As Elympics ValueChanged events come with two arguments: the old and the new value, 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, you're able to use them in the 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 was 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.

The new implementation will look 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 these code changes, you need to do one more crucial thing. As you surely remember, every object that wants to benefit from network communication needs to have the ElympicsBehaviour component. We have to add this exact component to the object containing the ScoreManager script.

Open the ScoreManager prefab and add this ElympicsBehaviour.

Open prefab
Add Elympics Behaviour

As you’ve surely noticed, there’s no ScoreManager on the list of the Observed MonoBehaviours in the Elympics Behaviour component. To change that, the ScoreManager class needs to implement at least one of the interfaces provided by Elympics.

As we actually don't 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, score counting will be fully synchronized! 🎉 After joining the match, players will automatically get their current score values.

Score synchronized

Controlling the match by the server

Your points are now synchronized over the network, but the overall control over the game is executed by each player separately. Purely hypothetically, a ball in one player’s simulation could hit the score area before its position is corrected (reconciled) by the server, and the score value could 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 while the actual game is still being played on the server and the other client.

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

To change that, start with the ScoreManager class. From now on, the server will decide if the game is finished, so 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. To call ResetGame, verify that the ball reference is present in the 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. Let's make sure that the FinishGame method is only called on the server. To do that, we need our game to detect if it’s currently running on the Game Engine (server) side. Our class – ScoreManager – needs to inherit from a specific Elympics-provided type: ElympicsMonoBehaviour. This way, you’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, you 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 the connected clients are notified about the game finish. To do that, you need to transform the FinishGame() method to a synchronized ElympicsBool that will inform the 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 you’re here, let’s also modify the if statement in the AddPoint method by 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, you used it for calling a GameFinished event, which informed other objects responsible for the game view that the game has been finished. As I mentioned in the ElympicsInt part above, synchronized objects from Elympics (like ElympicsInt or ElympicsBool) have a ValueChanged callback event that informs instances about… well… the value being changed. It’s really easy to migrate our code and take advantage of this new feature by using this event.

First, modify the FinishGame() implementations (renaming it as OnGameFinished() by the way). You’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 the events will look as follows:

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

Now, you can rest assured that the control of the match flow will only be controlled by the server. After the changes described in this part, the whole class 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);
  }
}

That's all! 🎉 Your pong is now fully ready for online, server authoritative gameplay! Great job! 💪

If you have some additional questions, join our community where Elympics developers will help you with any technical issue.