Multiplayer 11. The spectator

If you build it, they won't always come. This is just the average MK Dons game.

There are those that like to play games, and those that like to watch. We used to call those people who watched games get played younger siblings. Now they're called Twitch viewers. Either way, these people are out there and we can provide a service to them.

For this test, we're going to give our spectators a presence in the world, they just won't be able to touch anything, or score points. The reason behind this is simply that we can, and it's a good way to cover instantiating different players with different prefabs.

Later we can spiral this out to allow players to look entirely different to each other, and to allow them to move freely in death, but until we get this - this is just a nice aside.

Threesome

To make this easy to add in, we're going to create a new team called Spectators. Only those players in either the VIP or Inhumer teams will be eligible for scoring, but spectators will be able to exist in the game.

We'll start by adding a new the code behind a new button for the Lobby so that players to choose Spectators as a team. First we'll create our new team:

// Player/SyncedData/PlayerDataForClients.cs

namespace Player.SyncedData {
    public class PlayerDataForClients : NetworkBehaviour {

        public const int TEAM_SPECTATOR = 0;
        public const int TEAM_VIP = 1;
        public const int TEAM_INHUMER = 2;
        
        ...
    }
}

Next up is adding a change to the TeamTracker code so that it can tell the difference between a non-VIP and a spectator. The code will now properly calculate VIPs and Inhumers, without Spectators being included.

// Player/Tracking/TeamTracker.cs

namespace Player.Tracking {
    public class TeamTracker {

        ...

        private void CountTeams()
        {
            vips = inhumers = 0;
            foreach (GameObject player in PlayerTracker.GetInstance().GetPlayers()) {
                if (player.GetComponent<PlayerDataForClients>().GetTeam() == PlayerDataForClients.TEAM_VIP) {
                    vips++;
                }
                else if (player.GetComponent<PlayerDataForClients>().GetTeam() == PlayerDataForClients.TEAM_INHUMER) {
                    inhumers++;
                }
            }

            ...
        }

        ...
    }
}

This will keep the team counter working correctly on the lobby page, so we need to update how each entry (local and remote) works with a new spectator button.

// UI/Lobby/Player/RemoteEntryUI.cs

namespace UI.Lobby.Player
{
    public class RemoteEntryUI : MonoBehaviour, EntryInterface
    {
        ...

        public void UpdateTeamFromSettings(GameObject player, int teamId)
        {
            ...

            if (teamId == PlayerDataForClients.TEAM_SPECTATOR) {
                teamText.text = "Spectator";
            }
        }

        ...
    }
}

This code simply updates remote entries to flag themselves as spectators. The following code adds a new spectator button to the local entry.

// UI/Lobby/Player/LocalEntryUI.cs

namespace UI.Lobby.Player
{
    public class LocalEntryUI : MonoBehaviour, EntryInterface
    {
        ...

        public GameObject spectatorButton;

        ...

        private void UpdateTeamWithSettings(GameObject player, int teamId)
        {
            if (teamId == PlayerDataForClients.TEAM_VIP) {
                ...
                if (spectatorButton) spectatorButton.SetActive(false);
                ...
            }

            if (teamId == PlayerDataForClients.TEAM_INHUMER) {
                ...
                if (spectatorButton) spectatorButton.SetActive(false);
                ...
            }

            if (teamId == PlayerDataForClients.TEAM_SPECTATOR) {
                if (vipButton) vipButton.SetActive(false);
                if (inhumerButton) inhumerButton.SetActive(false);
                if (spectatorButton) spectatorButton.SetActive(true);
                teamText.text = "Spectator";
            }
        }

        public void UpdateReadyFlagFromSettings(GameObject player, bool isReady)
        {
            ...

            if (isReady) {
                ...
                spectatorButton.SetActive(false);
            }
            ...
        }
    }
}

Lastly, for lobby changes at least, we need to make a couple of changes to the lobby UI to add the new Spectator button.

The spectator button's just like the other 2. They've been included here as well to show the "On Click" value has now changed to form a loop including 0 for a spectator.

Spectatoring

Our players now have the ability to choose to spectate a game so it's time to decide what they're going to look like when in-game.

We don't want them to use the same player models as that'd get confusing, but it'd be nice to give them the opportunity for mischief, so we're going to turn them into glowing balls of light. They won't be able to interact with the world, but they will be able to pass through walls and glow menacingly.

// Player/Model.cs

namespace Player {
    public class Model : MonoBehaviour {

        ...

        public GameObject spectatorPrefab;

        ...

        private void ChooseModel (GameObject current, int team)
        {
            ...

            if (team == PlayerDataForClients.TEAM_SPECTATOR) {
                AddModelToPlayer(spectatorPrefab);
                isPlayerCharacter = true;

                return;
            }

            isPlayerCharacter = false;
        }

        ...
    }
}

This code sets the player model for the spectator team but, unlike the VIP and inhumer teams, doesn't bother setting a colour.

The prefab for the spectator is a small, shiny, floating, glowing ball. The reason is purely to bug those players actually trying to play and because it allows us to cover the lighting in Unity very briefly.

No-score draw

As a spectator is neither a VIP or inhumer, we need to make sure they can't influence the scoreboard (too much). To do this we'll stop them from gaining point with our rudimentary scoring system, and then hide them form the leaderboard at the end of the game.

// Level/Scoring.cs

namespace Level {
    public class Scoring : NetworkBehaviour {

        ...

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

                GameObject highestXPlayer = null;
                float highestX = float.MinValue;
                foreach (GameObject player in PlayerTracker.GetInstance().GetPlayers()) {
                    int team = player.GetComponent<PlayerDataForClients>().GetTeam();
                    if (player.transform.position.x > highestX && team != PlayerDataForClients.TEAM_SPECTATOR) {
                        highestXPlayer = player;
                        highestX = player.transform.position.x;
                    }
                }

                if (highestXPlayer) {
                    highestXPlayer.GetComponent<PlayerDataForClients>().ServerIncrementScore();
                }

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

The changes to this codebase prevent a spectator from attaining a score. The next code block updates the LevelCompleteUI file to prevent a spectator from even appearing.

// UI/Level/LevelCompleteUI.cs

namespace UI.Level {
    ...

    class LevelCompleteUI : MonoBehaviour {

        ...

        private void UpdateScoreboard ()
        {
            ...

            foreach (GameObject player in PlayerTracker.GetInstance().GetPlayers()) {
                if (player ==  null || player.GetComponent<PlayerDataForClients>() == null) {
                    continue;
                }

                int team = player.GetComponent<PlayerDataForClients>().GetTeam();
                if (team == PlayerDataForClients.TEAM_SPECTATOR) {
                    continue;
                }

                ...
            }

            ...
        }
    }
}

Lights, camera, ...

This would be enough changes if we were simply changing the model of the spectator to something more interesting, but we've changed it a glowing ball. To make the ball glow correctly when we've compiled the code we need to update some scene settings.

This shows the changes we need to make to both of our scenes. Set the background of the camera to a flat colour and then go to Window > Lighting > Scene settings from the main menu to view the right-hand column. This stops additional lighting being applied when we're not expecting it.

Now we have our lighting set up correctly, we can see what it looks like on camera:

If you want to view the source code behind this and all steps so far, go to github.com/dittto/unity-multiplayer-11.