Multiplayer 9. We're going to score one more than you

Scoring. Ball in goal. Simple word association, really.

Pretty much all games (apart from weird arty ones where the point of the game is the "experience") have some way of keeping score of how well you've done, especially multiplayer games. Without a score when playing with others, how else will you be able to laud you awesome skills over them? Other than teabagging their corpse, I mean.

We've already got a nice screen at the end of the game that's ripe for a scoreboard, so let's add one in. I'm not going to worry too much about the method for keeping score, just the act of actually doing in. As such, for this example we're going to track which player is furthest to the right every quarter of a second, and give them the points.

Score one for the players

So far, all of the shared data we passed between client and server has been driven by the client. This time though, we don't want the client to control how it scores. The server will take a look at the positions of players and assign the score purely on how it views the game.

We're also not going to persist the player's score between scenes, as our game uses a single scene for a round. If you wanted to use multiple scenes then you'd need to make sure the score is synchronised. With ours, on the other hand, we only need to update PlayerDataForClients in the synced data classes, and it also has some different methods than the rest of the synchronised variables.

// Player/SyncedData/PlayerDataForClients.cs

using UnityEngine;
using UnityEngine.Networking;

namespace Player.SyncedData {
    public class PlayerDataForClients : NetworkBehaviour {

        ...

        public delegate void ScoreUpdated (GameObject player, int score);
        public event ScoreUpdated OnScoreUpdated;
        
        ...

        [SyncVar(hook = "UpdateScore")]
        private int score;
        
        ...

        public override void OnStartClient()
        {
            if (!isLocalPlayer && !isServer) {
                UpdateName(playerName);
                UpdateTeam(team);
                UpdateScore(score);
                UpdateIsReadyFlag(isReadyFlag);
                UpdateIsServerFlag(isServerFlag);
            }
        }
        
        ...

        public int GetScore ()
        {
            return score;
        }

        [Server]
        public void ResetScore ()
        {
            score = 0;
        }

        [Server]
        public void ServerIncrementScore ()
        {
            score ++;
        }

        [Client]
        public void UpdateScore (int newScore)
        {
            score = newScore;
            if (this.OnScoreUpdated != null) {
                this.OnScoreUpdated(gameObject, newScore);
            }
        }

        ...
    }
}

We've setup the normal events you'd expect to see for this variable, but notice that the server controls resetting and incrementing the score. We've kept the method for updating the score so we could have so nice UI effects, but there's no way for the client to push the score from it's side back to the server (there's no score [Command]).

The core score code

Now we have somewhere to store our score, we need something to update the score on the server. This code's ok for a tutorial, but we'll replace it soon enough with a proper scoring system.

// Level/Scoring.cs

using UnityEngine;
using UnityEngine.Networking;
using GameState;
using Player.SyncedData;
using Player.Tracking;
using System.Collections;

namespace Level {
    public class Scoring : NetworkBehaviour {

        private bool keepScoring = false;

        public void Awake ()
        {
            if (State.GetInstance().Network() == State.NETWORK_SERVER) {
                SubscribeToServerPlaying();

                foreach (GameObject player in PlayerTracker.GetInstance().GetPlayers()) {
                    ResetPlayerScore(player);
                }
                PlayerTracker.GetInstance().OnPlayerAdded += ResetPlayerScore;
            }
        }

        [Server]
        private void ResetPlayerScore(GameObject player)
        {
            player.GetComponent<PlayerDataForClients>().ResetScore();
        }
        
        [Server]
        private void SubscribeToServerPlaying ()
        {
            State.GetInstance().Subscribe(
                new StateOption().LevelState(State.LEVEL_PLAYING),
                StartScoringTimer
            );
            State.GetInstance().Subscribe(
                new StateOption().LevelState(State.LEVEL_COMPLETE),
                () => {
                    keepScoring = false;
                }
            );
        }

        [Server]
        private void StartScoringTimer ()
        {
            if (this != null) {
                keepScoring = true;
                StartCoroutine(this.CalculateScore());
            }
        }

        [Server]
        private IEnumerator CalculateScore ()
        {
            while (keepScoring) {

                GameObject highestXPlayer = null;
                float highestX = float.MinValue;
                foreach (GameObject player in PlayerTracker.GetInstance().GetPlayers()) {
                    if (player.transform.position.x > highestX) {
                        highestXPlayer = player;
                        highestX = player.transform.position.x;
                    }
                }

                highestXPlayer.GetComponent<PlayerDataForClients>().ServerIncrementScore();

                yield return new WaitForSeconds(0.25f);
            }
        }
    }
}

As we don't persist the score between scenes, the first action of this class is to reset the score for every player. It should always be 0 at the start of a scene, but this is just a nice example of how you would do it if it was persisting.

Next up is to start a timer that ticks every quarter of a second, so we can calculate our score at that time. The score is a very simple calculation on whichever player has the highest value of x at that particular moment of time.

The scoreboard

Our players now have a secret score value. Secret being the operative word here since we currently have no UI to retrieve it. We're going to use the same UI as the lobby did for listing players, as it handles different screen sizes well.

To make things easy for this blog post, we're also going to ignore the team selection choices and base the winner purely on individual skill of going right (Zoolander would approve).

The code is going to be made up of two sections. One for managing the scoreboard itself, and one for the scoreboard entries. We'll start with the entry as it's so simple. It simply updates the scoreboard entry it's attached to, taking care to flag the local player.

// UI/Level/ScoreboardEntryUI.cs

using UnityEngine;
using UnityEngine.UI;

namespace UI.Level {
    public class ScoreboardEntryUI : MonoBehaviour {

        public GameObject currentPlayerBackground;
        public Text nameText;
        public Text scoreText;

        public void SetName (string name)
        {
            nameText.text = name;
        }

        public void SetScore (int score)
        {
            scoreText.text = score.ToString();
        }

        public void FlagCurrentPlayer ()
        {
            currentPlayerBackground.SetActive(true);
        }
    }
}

Lastly then, let's update the LevelCompleteUI to include a working scoreboard. It finds all current players, creates a scoreboard entry for them, and then fills it with their details. It then orders the scoreboard by score (and it's here you'd alter it to split the results by team), and finally alters the height of the scroll view containing these entries.

// UI/Level/LevelCompleteUI.cs

using GameState;
using Player.SyncedData;
using Player.Tracking;
using UI.Level;
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;

namespace UI.Level {
    class DescendingComparer<T> : IComparer<T> where T : IComparable<T> {
        public int Compare (T x, T y)
        {
            return y.CompareTo(x);
        }
    }

    class LevelCompleteUI : MonoBehaviour {

        ...

        public GameObject scoreboard;
        public GameObject scoreboardViewport;
        public GameObject scoreboardEntryPrefab;

        public int entryPrefabHeight = 80;

        ...

        private IEnumerator EnableScoreboard ()
        {
            ...

            UpdateScoreboard();
        }

        ...

        private void UpdateScoreboard ()
        {
            SortedDictionary<int, List<GameObject>> scores = new SortedDictionary<int, List<GameObject>>(new DescendingComparer<int>());

            foreach (GameObject player in PlayerTracker.GetInstance().GetPlayers()) {
                GameObject entry = Instantiate(scoreboardEntryPrefab);
                ScoreboardEntryUI ui = entry.GetComponent<ScoreboardEntryUI>();
                int score = player.GetComponent<PlayerDataForClients>().GetScore();
                ui.SetName(player.GetComponent<PlayerDataForClients>().GetName());
                ui.SetScore(score);
                if (player == PlayerTracker.GetInstance().GetLocalPlayer()) {
                    ui.FlagCurrentPlayer();
                }

                entry.transform.SetParent(scoreboardViewport.transform, false);

                if (!scores.ContainsKey(score)) {
                    scores.Add(score, new List<GameObject>());
                }
                scores[score].Add(entry);
            }

            int counter = 0;
            foreach (KeyValuePair<int, List<GameObject>> values in scores) {
                foreach (GameObject player in values.Value) {
                    Vector3 localPos = player.GetComponent<RectTransform>().localPosition;
                    player.GetComponent<RectTransform>().localPosition = new Vector3(localPos.x, -(entryPrefabHeight / 2) + (-(entryPrefabHeight + 2) * counter), localPos.z);
                    counter++;
                }
            }

            RectTransform transform = scoreboardViewport.GetComponent<RectTransform>();
            transform.sizeDelta = new Vector2(transform.sizeDelta.x, counter * (entryPrefabHeight + 2));
        }
    }
}

With all of the code laid out above, it's time to quickly run through the UI.

This is what our new OnlineUI prefab looks like. The scoring system and the leaderboard are all controlled from here.
The UI components for an entry into the scoreboard at the end of the game. By now, there's nothing new here.

The end result should be something like this video:

If you want to get the code, then go to github.com/dittto/unity-multiplayer-9.