CardConquest GameDev Blog #16: Making the Unit Placement Phase Multiplayer using Mirror – Part 2

Disclaimer: I am neither a professional game developer nor am I a professional programmer. I doubt I am following best practices. This is just what worked for me at the time. Hopefully it works for you if you’re playing along while reading!

Version of Unity used: 2019.4.13f1 Personal

The previous posts in this series can be found under the CardConquest category on this blog. The github for the project can be found here. Zip archives for specific posts can be found here.

In Part 1 of migrating Unit Placement to multiplayer/Mirror, the following was completed:

  • Spawning units for players
  • Spawning cards for players
  • Determining player bases and locally spawning cannotPlaceHereMarkers

In this post, I hope to get the following done:

  • Update the PutUnitsInUnitBox function in GameplayManager
  • Update unit placement/movement so it is verified by the server
  • Create a “Ready Up” system that will have the server advance the phase after all players are ready

Let’s get started!

Updating PlaceUnitsInUnitBox

Before, the PutUnitsInUnitBox in GameplayManager.cs looked for the one object tagged PlayerUnitHolder and then moved all of its child objects to specified coordinates. To update this to support multiplayer, the main difference will be that PutUnitsInUnitBox will find all objects with the PlayerUnitHolder tag, and then determine which one belongs to the local player. Then, it will only move those child objects.

First, make sure that the function is a public function.

Next, the line that finds the PlayerUnitHolder will be changed from this:

GameObject unitHolder = GameObject.FindGameObjectWithTag("PlayerUnitHolder");

To this:

GameObject[] PlayerUnitHolders = GameObject.FindGameObjectsWithTag("PlayerUnitHolder");

This will create an array of objects called PlayerUnitHolders that holds all objects tagged PlayerUnitHolder.

Next, a new foreach loop will be created that iterates through all unitHolders in the PlayerUnitHolders array.

foreach (GameObject unitHolder in PlayerUnitHolders)
{ 
}

Within that foreach loop, the PlayerUnitHolder script of unitHolder will be retrieved, and then an if statement will be created to check if the owner of the unitHolder matches the LocalGamePlayer.

PlayerUnitHolder unitHolderScript = unitHolder.GetComponent<PlayerUnitHolder>();
if (unitHolderScript.ownerConnectionId == LocalGamePlayerScript.ConnectionId)
{ 
}

Then, all you have to do is copy and paste the remaining old code of PutUnitsInUnitBox within the if statement. I also added a break; line so that the foreach loop is broken out of when the match is found.

The fully updated PutUnitsInUnitBox function is shown below:

public void PutUnitsInUnitBox()
{
	GameObject[] PlayerUnitHolders = GameObject.FindGameObjectsWithTag("PlayerUnitHolder");

	foreach (GameObject unitHolder in PlayerUnitHolders)
	{
		PlayerUnitHolder unitHolderScript = unitHolder.GetComponent<PlayerUnitHolder>();
		if (unitHolderScript.ownerConnectionId == LocalGamePlayerScript.ConnectionId)
		{
			foreach (Transform unitChild in unitHolder.transform)
			{
				if (unitChild.transform.tag == "infantry")
				{
					infToPlace.Add(unitChild.gameObject);
				}
				else if (unitChild.transform.tag == "tank")
				{
					tanksToPlace.Add(unitChild.gameObject);
				}

			}
			//Begin moving the units into the unit box
			for (int i = 0; i < tanksToPlace.Count; i++)
			{
				if (i == 0)
				{
					Vector3 temp = new Vector3(-14.0f, 8.25f, 0f);
					tanksToPlace[i].transform.position = temp;
				}
				else
				{
					int previousTank = i - 1;
					Vector3 temp = tanksToPlace[previousTank].transform.position;
					temp.x += 1.0f;
					tanksToPlace[i].transform.position = temp;
				}
			}
			for (int i = 0; i < infToPlace.Count; i++)
			{
				if (i == 0)
				{
					Vector3 temp = new Vector3(-14.25f, 7.25f, 0f);
					infToPlace[i].transform.position = temp;
				}
				else
				{
					int previousInf = i - 1;
					Vector3 temp = infToPlace[previousInf].transform.position;
					temp.x += 0.8f;
					infToPlace[i].transform.position = temp;
				}
			}
			//end moving units into unit box
			break;
		}
	}
}

All that’s left now is to actually call the PutUnitsInUnitBox. Back in GamePlayer.cs’s RpcShowSpawnedPlayerUnits function, a call to GameplayManager.instance.PutUnitsInUnitBox(); was commented out (line 210 on mine right now)

Remove the two slashes to uncomment the code, and now when the local gameplayer gets their units spawned by the server, they should (locally) place the units in the “Units box.”

Save the GameplayManager.cs and GamePlayer.cs scripts. Go back to Unity, build and run the game, and test it out. You should see that the units have been moved to the units box

If you try and “play” the game right now, you will see that you can place the units as you could before. However, all the unit movement and the checks on if you are allowed to move is all done client side. I want to change it so the allow checks are all done on the server, to make sure that players/clients aren’t “approving” moves that shouldn’t be allowed by the game (or in simpler terms, to try and stop cheating).

Validating Unit Movement on the Server

A lot of the unit movement checks will be done from the UnitScript.cs script. To begin, add two new global variables to the top of the script.

[Header("Position on Map")]
[SyncVar] public Vector3 newPosition;
[SyncVar] public Vector3 startingPosition;

These two vector3 variables will be used to track what a units starting position is at the beginning of a phase, and what their “current” position is that they have moved to during that phase. These are SyncVars, so the values will be synced between the server and all clients.

During the Unit Placement phase, the value of startingPosition will be 0,0,0. This works fine for the Unit Placement phase as the only thing limiting movement/placement of a unit is how far a land tile base is from the player’s base. That has already been calculated by the server.

When a unit is placed, that new position’s coordinates will be saved by the server in newPosition. Then, when Unit Placement is complete and the phase ends, the newPosition value will be saved in startingPosition

During the Unit Movement phase, a unit can only move a certain “distance” from their starting tile. To determine on the server if a unit can move during unit movement phase, their new “newPosition” value will be compared to their startingPosition value. If the distance is within limits, the server approves the move.

To make sure that the server is able to do all of the validation on its end and rely as little as possible on information that the player/client sends it (or, at least be able to validate that the data it received is true), some changes are going to be made to the GamePlayer.cs and LandScript.cs scripts that will allow the server to track what units a player owns and what units are on a land object based on the unit’s network id.

Track the GamePlayer’s Units

After the player’s units are spawned, we will need a way for the server to track the network id’s of all those units. This will be done by adding the following variable to GamePlayer.cs:

public SyncList<uint> playerUnitNetIds = new SyncList<uint>();

playerUnitNetIds is a SyncList of all the network ids of their units. Using a SyncList for this allows for this information to be sync’d between all the clients and the server. SyncLists are also good for this because only the server can modify the SyncList. Clients cannot modify the value. This works great for our server validation needs.

The player’s units are spawned in the CmdSpawnPlayerUnits function which runs on the server. It will be in CmdSpawnPlayerUnits that the server will add the player’s unit network ids to playerUnitNetIds.

The following line will add the infantry ids to the player’s playerUnitNetIds:

requestingPlayer.playerUnitNetIds.Add(playerInfantry.GetComponent<NetworkIdentity>().netId);

And for adding the tanks to playerUnitNetIds

requestingPlayer.playerUnitNetIds.Add(playerTank.GetComponent<NetworkIdentity>().netId);

These will need to be added to the CmdSpawnPlayerUnits function after NetworkServer.Spawn() is called for the units. You can see where in the screenshot below.

If you save GamePlayer.cs and build and run the game, you should see the playerUnitNetIds list populated for each gameplayer.

Tracking Unit Network Ids on Land Objects

Currently, land objects store the gameobject of units on that land. However, this is only on the client side. Given the issues with syncing gameobjects between clients, I created a SyncList on LandScript.cs that is very similar to playerUnitNetIds but is specific to the units occupying that land object.

Open up LandScript.cs and add the following variable:

public SyncList<uint> UnitNetIdsOnLand = new SyncList<uint>();

UnitNetIdsOnLand will be used to track what units are on the land based on their network id. This will be used later by the server to check how many of a player’s units are on a land tile.

Validating the Player’s Movement

The “flow” or process of the player requesting to move their units and then the server validating that movement will be as follows:

  • Public function AskServerCanUnitsMove that will be called by MouseClickManager when the user clicks on a land tile with units selected
    • Has following arguments
      • landUserClicked – the gameobject of the land tile that was clicked
      • positionToMoveTo – Vector3 position of the land tile’s transform.position
      • unitsSelected – a list of the unitsSelected the player wants to move. This comes from MouseClickManager’s unitsSelected variable
  • A command CmdServerCanUnitsMove which will perform the validation
    • has the same arguments as AskServerCanUnitsMove
  • A TargetRpc TargetReturnCanUnitsMove
    • When CmdServerCanUnitsMove completes its validation, TargetReturnCanUnitsMove is called
    • TargetRpc’s are only returned to a specific client. This makes it so a move request is only sent back to the player who requested it. The other players don’t need to know when a player’s move request passes or fails
    • Takes the following arguments
      • target – this is the networkconnection of the player it is returned to. Basically this is saying which player/client this will be executed on
      • serverCanMove – boolean variable returned by the server. True = units can move. False = units cannot move
      • landUserClicked – the land object the player clicked, returned back by the server
      • positionToMoveTo – Vector3 position of the land object
    • If the server returns “True” in serverCanMove to TargetReturnCanUnitsMove, the next function is called
  • MoveAllUnits
    • Only called if the server allowed the unit movement
    • Will do two things:
      • tell the client to move the unit sprites locally
      • Will make a request to the server to update information on the units using the next function, CmdUpdateUnitNewPosition
  • Command CmdUpdateUnitNewPosition
    • This will run on the server and will be what updates the unit’s newPosition value as well as add/remove the unit’s network id from the land objects UnitNetIdsOnLand list as required

AskServerCanUnitsMove

As shown in the screenshot above, the AskServerCanUnitsMove function only checks if you have authority over the unit object, then makes the request for the CmdServerCanUnitsMove command function on the server. Both of those functions take the same arguments, which are the land the player clicked on, the Vector3 position of the land, and the units the player has selected. Code is shown below.

public void AskServerCanUnitsMove(GameObject landUserClicked, Vector3 positionToMoveTo, List<GameObject> unitsSelected)
{
	if (hasAuthority)
		CmdServerCanUnitsMove(landUserClicked, positionToMoveTo, unitsSelected);
}

CmdServerCanUnitsMove

CmdServerCanUnitsMove will be used by the server to validate if the “unit move” requested by the player is valid and allowed. The function starts by getting the requesting player’s GamePlayer script.

NetworkIdentity networkIdentity = connectionToClient.identity;
GamePlayer requestingPlayer = networkIdentity.GetComponent<GamePlayer>();

Next, the total number of units involved in the move will be calculated. The total number of units is the number of units selected plus any units that are already on the land tile.

int totalUnitsToMove = unitsSelected.Count;

LandScript landScript = landUserClicked.GetComponent<LandScript>();
foreach (uint unitId in landScript.UnitNetIdsOnLand)
{
	if (requestingPlayer.playerUnitNetIds.Contains(unitId))
	{
		totalUnitsToMove++;
	}
}

The total number of units a player has on a land tile is calculated by checking all of the unit network ids on the land tile through its UnitNetIdsOnLand list, and then checking if any ids in UnitNetIdsOnLand are contained in the requesting player’s list of unit ids they own, playerUnitNetIds. If there is a match, increment totalUnitsToMove.

The next bit of code will first perform a sanity check to make sure that the land tile’s transform.position value matches the value of the Vector3 positionToMoveTo value provided by the user. Then, the CmdServerCanUnitsMove function checks if the total number of units to move is greater than 5. If either of these checks fail, TargetReturnCanUnitsMove is called with serverCanMove set to false.

Debug.Log("Running CmdServerCanUnitsMove for: " + connectionToClient.ToString());
Debug.Log("Player is requesting to move this many units: " + totalUnitsToMove + " to this land object: " + landUserClicked + " located at: " + positionToMoveTo);
if (landUserClicked.transform.position != positionToMoveTo)
{
	Debug.Log("CmdServerCanUnitsMove: Position of landUserClicked and positionToMoveTo don't match. landUserClicked: " + landUserClicked.transform.position + " positionToMoveTo: " + positionToMoveTo);
	TargetReturnCanUnitsMove(connectionToClient, false, landUserClicked, positionToMoveTo);
	return;
}
if (totalUnitsToMove > 5)
{
	Debug.Log("CmdServerCanUnitsMove: Too many units to move: " + totalUnitsToMove);
	TargetReturnCanUnitsMove(connectionToClient, false, landUserClicked, positionToMoveTo);
	return;
}

Finally, CmdServerCanUnitsMove makes the checks to see if the units can be moved to the requested land tile during the Unit Placement phase. Earlier, the server set the land tile’s “PlayerCanPlaceHere” value to the player number that is allowed to place on that tile. If the requesting player’s name is not in PlayerCanPlaceHere, TargetReturnCanUnitsMove is called as false. Otherwise, it is returned as true.

if (GameplayManager.instance.currentGamePhase == "Unit Placement")
{
	if (landScript.PlayerCanPlaceHere != requestingPlayer.playerNumber)
	{
		Debug.Log("CmdServerCanUnitsMove: Player cannot place here. Too far from base: " + requestingPlayer.PlayerName);
		TargetReturnCanUnitsMove(connectionToClient, false, landUserClicked, positionToMoveTo);
		return;
	}
	else
	{
		Debug.Log("CmdServerCanUnitsMove: Player can move this unit!:  " + requestingPlayer.PlayerName + " " + this.gameObject);
		TargetReturnCanUnitsMove(connectionToClient, true, landUserClicked, positionToMoveTo);
		return;
	}
}
TargetReturnCanUnitsMove(connectionToClient, false, landUserClicked, positionToMoveTo);
return;

The last two lines outside of any of the if statements is just to make sure to return false to TargetReturnCanUnitsMove in the event that somehow now of the other calls to TargetReturnCanUnitsMove triggered.

After CmdServerCanUnitsMove executes on the server, the TargetRpc TargetReturnCanUnitsMove will be sent to the requesting player with either a true or false value to allow the unit movement.

TargetReturnCanUnitsMove

TargetReturnCanUnitsMove will check the value of serverCanMove returned by the server. If it returned true, the process of moving the units will continue. If it returns false, no movement will occur and the unit’s will all be unselected through MouseClickManager’s ClearUnitSelection function.

[TargetRpc]
public void TargetReturnCanUnitsMove(NetworkConnection target, bool serverCanMove, GameObject landUserClicked, Vector3 positionToMoveTo)
{
	Debug.Log("serverCanMoveInt received. Value: " + serverCanMove.ToString());
	if (serverCanMove)
	{
		MoveAllUnits(landUserClicked, positionToMoveTo);
	}
	else if (!serverCanMove)
	{
		MouseClickManager.instance.ClearUnitSelection();
	}
}

Moving the units will then be done locally to actually move the sprites, and then also on the server by updating values on the unit objects and the affected land objects as well.

MoveAllUnits

MoveAllUnits will take the land object the user clicked on and the land’s transform.position value as arguments. It will then make requests to the server to update the unit position information on the server, and make the local calls to move the sprite objects.

void MoveAllUnits(GameObject landUserClicked, Vector3 positionToMoveTo)
{
	if (hasAuthority)
	{
		Debug.Log("All units can move! Telling server to update newPosition value");
		foreach (GameObject unit in MouseClickManager.instance.unitsSelected)
		{
			CmdUpdateUnitNewPosition(unit, positionToMoveTo, landUserClicked);
		}
		MouseClickManager.instance.MoveAllUnits(landUserClicked);
		MouseClickManager.instance.ClearUnitSelection();
	}
}

The request to CmdUpdateUnitNewPosition on the server will be what actually “moves” the unit as far as the server/game is concerned. MouseClickManager’s MoveAllUnits takes care of moving the units locally and adjusting the local UI stuff as appropriate.

CmdUpdateUnitNewPosition

The CmdUpdateUnitNewPosition function will be responsible for updating the unit’s new position on the server, as well as adding the unit’s network id to the new land object and removing the unit’s network id from the previous land object. Code shown below:

[Command]
void CmdUpdateUnitNewPosition(GameObject unit, Vector3 newPosition, GameObject landClicked)
{
	UnitScript unitScript = unit.GetComponent<UnitScript>();
	unitScript.newPosition = newPosition;
	uint unitNetId = unit.GetComponent<NetworkIdentity>().netId;

	//check for unit's previous location on a land tile and remove its netid
	GameObject LandTileHolder = GameObject.FindGameObjectWithTag("LandHolder");
	foreach (Transform landObject in LandTileHolder.transform)
	{
		LandScript landChildScript = landObject.gameObject.GetComponent<LandScript>();
		if (landChildScript.UnitNetIdsOnLand.Count > 0)
		{
			if (landChildScript.UnitNetIdsOnLand.Contains(unitNetId))
			{
				Debug.Log("Unit network id: " + unitNetId + " found on land object: " + landObject);
				landChildScript.UnitNetIdsOnLand.RemoveAll(x => x.Equals(unitNetId));
				break;
			}
		}
	}

	LandScript landScript = landClicked.GetComponent<LandScript>();
	landScript.UnitNetIdsOnLand.Add(unitNetId);
}

The code does the following:

  • Gets the unit’s UnitScript
  • Sets the unit’s newPosition value to the newPosition value provided
  • Gets the value of the unit’s network id
  • Finds the LandTileHolder object by the LandHolder tag
  • iterates through every child land object:
    • Gets the LandScript of the land object
    • Checks if the UnitNetIdsOnLand list on the land object has at least one entry in it. If so:
      • Removes any entry in UnitNetIdsOnLand if it is equal to the value of the unit’s network id
  • The provided land object’s LandScript is obtained
  • The unit’s network id is added to the land object’s UnitNetIdsOnLand list

Calling AskServerCanUnitsMove

The last thing to do now is to make sure AskServerCanUnitsMove is called by MouseClickManager when the player tries to move a unit. Calling AskServerCanUnitsMove will start the whole process of determining if a player is allowed to move a unit to their desired land tile.

In MouseClickManager.cs, the original call to move the units can be found in the below screenshot, starting at line 71 in my local copy

This can be updated to just one line of code to start the server validation of the move request:

unitsSelected[0].GetComponent<UnitScript>().AskServerCanUnitsMove(rayHitLand.collider.gameObject, rayHitLand.collider.gameObject.transform.position, unitsSelected);

Save all the scripts and then build and run the game. As you move units, you should observe that the land object’s UnitNetIdsOnLand value updates as units are added or remove

Additionally, if you view one of those unit’s newPosition value, you should see that it matches the land object’s transform.position value

So, phew, unit movement, at least in the Unit Placement phase, seems to work through server validation now!

Server Controlled Game Phases

The next thing I want to do is to implement the ready up system that will advance the game to the next phase. First, though, I want to make sure that the current “phase” of the game is something that is set by the server. Currently, the game phase is set by the clients locally. Having the server control when the game phase is set should make sure there aren’t sync issues where one player was able to advance to a different phase than another player.

Open up the NetworkManagerCC.cs script and add a new global variable that will be used to track the game phase.

public string CurrentGamePhase;

Then, scroll down to the ServerChangeScene function and add the following line after the for loop:

CurrentGamePhase = "Unit Placement";

This will make sure that the game phase is set to “Unit Placement” when the game is started by the server.

Next, open up GameplayManager.cs and create a new function called GetCurrentGamePhase.

void GetCurrentGamePhase()
{
	LocalGamePlayerScript.SetCurrentGamePhase();
}

GetCurrentGamePhase will make a call to SetCurrentGamePhase in the LocalGamePlayer’s GamePlayer script . SetCurrentGamePhase doesn’t exist yet, so it will now need to be created in GamePlayer.cs.

In GamePlayer.cs, three new functions will be created to get the current game phase from the server. The code for all three is shown below:

public void SetCurrentGamePhase()
{
	if (hasAuthority)
		CmdGetCurrentGamePhaseFromServer();
}
[Command]
void CmdGetCurrentGamePhaseFromServer()
{
	TargetSetCurrentGamePhase(connectionToClient, Game.CurrentGamePhase);
}
[TargetRpc]
void TargetSetCurrentGamePhase(NetworkConnection target, string serverGamePhase)
{
	Debug.Log("Current game phase fromthe server is: " + serverGamePhase);
	GameplayManager.instance.currentGamePhase = serverGamePhase;
	GameplayManager.instance.SetGamePhaseText();
}

SetCurrentGamePhase will check if the player has authority over the GamePlayer object. If they do have authority, a request for CmdGetCurrentGamePhaseFromServer is made on the server.

CmdGetCurrentGamePhaseFromServer simply returns the CurrentGamePhase string value from the network manager through a TargetRpc function TargetSetCurrentGamePhase back to the requesting player.

TargetSetCurrentGamePhase then sets the currentGamePhase text on GameplayerManager to the game phase string returned from the server. TargetSetCurrentGamePhase then makes a call to GameplayManager’s SetGamePhaseText function. You will notice in the screenshot that there is a red error underline. This is because SetGamePhaseText is not public.

Back in GameplayManager.cs, make sure to add “public” in front of the SetGamePhaseText. I also added an if statement to call ActivateUnitPlacementUI from SetGamePhaseText if it is the Unit Placement phase.

Adding the call to ActivateUnitPlacementUI in SetGamePhaseText will make sure that the UI for the Unit Placement phase isn’t activated until it is known that the current phase is the Unit Placement phase.

Finally, in the start function of GameplayManager, comment out where currentGamePhase is manually set to “Unit Placement” as well as the calls to SetGamePhaseText and ActivateUnitPlacementUI. Then, add the call to GetCurrentGamePhase after the call to GetLocalGamePlayer.

Save all the scripts, build and run, and you should see that you are in the Unit Placement phase! Just like before!

Ready Up

In the old version of the game, after a player placed all their units, the endUnitPlacementButton would appear. When the player pressed endUnitPlacementButton, they would advance to the Unit Movement phase.

In this new multiplayer version, instead of immediately advance the player to Unit Movement, pressing endUnitPlacementButton will set the player’s “ready status” to true indicating that they are ready to advance to the new phase. If all players in the game are ready, then all players will advance to the new phase.

To start, a new SyncVar boolean variable called ReadyForNextPhase will be added to GamePlayer.cs.

[SyncVar(hook = nameof(HandlePlayerReadyStatusUpdate))] public bool ReadyForNextPhase = false;

ReadyForNextPhase has a hook function called HandlePlayerReadyStatusUpdate that will execute when its value changes. We’ll get to that soon. First, let’s change what happens when endUnitPlacementButton is pressed. Currently, when the button is pressed, it will run the EndUnitPlacementPhase function in GameplayManager.

Instead, a new function will be added to GameplayManager.cs that will change the player’s ReadyForNextPhase value on the server. In GameplayManager.cs, add a new function called ChangePlayerReadyStatus.

public void ChangePlayerReadyStatus()
{
	if (!EscMenuManager.instance.IsMainMenuOpen)
		LocalGamePlayerScript.ChangeReadyForNextPhaseStatus();
}

ChangePlayerReadyStatus will call the ChangeReadyForNextPhaseStatus function from the LocalGamePlayer script so long as the escape menu isn’t open. ChangeReadyForNextPhaseStatus doesn’t exist yet, so that will need to be added to GamePlayer.cs.

ChangeReadyForNextPhaseStatus

ChangeReadyForNextPhaseStatus will be a simple function that just checks if the player has authority over the game object, then makes a request to a command function called CmdChangeReadyForNextPhaseStatus.

public void ChangeReadyForNextPhaseStatus()
{
	if (hasAuthority)
	{
		CmdChangeReadyForNextPhaseStatus();
	}
}

CmdChangeReadyForNextPhaseStatus

The command will then update the requesting player’s ReadyForNextPhase value by making it the inverse of its current status. As in, if ReadyForNextPhase is false, make it true. If it is true, make it false. After that, it will call a server only command to check if all the players are ready yet or not.

[Command]
void CmdChangeReadyForNextPhaseStatus()
{
	Debug.Log("Running CmdChangeReadyForNextPhaseStatus on the server.");
	NetworkIdentity networkIdentity = connectionToClient.identity;
	GamePlayer requestingPlayer = networkIdentity.GetComponent<GamePlayer>();
	requestingPlayer.ReadyForNextPhase = !requestingPlayer.ReadyForNextPhase;
	CheckIfAllPlayersAreReadyForNextPhase();
}

CheckIfAllPlayersAreReadyForNextPhase

CheckIfAllPlayersAreReadyForNextPhase is a server function, meaning it can only be run on the server. The code is shown below:

[Server]
void CheckIfAllPlayersAreReadyForNextPhase()
{
	bool allPlayersReady = false;
	foreach (GamePlayer gamePlayer in Game.GamePlayers)
	{
		if (!gamePlayer.ReadyForNextPhase)
		{
			allPlayersReady = false;
			break;
		}
		else
		{
			allPlayersReady = true;
		}
	}
	if (allPlayersReady)
	{
		if (Game.CurrentGamePhase == "Unit Placement")
			Game.CurrentGamePhase = "Unit Movement";
		RpcAdvanceToNextPhase(allPlayersReady, Game.CurrentGamePhase);
	}
	else
	{
		RpcAdvanceToNextPhase(allPlayersReady, Game.CurrentGamePhase);
	}
}

CheckIfAllPlayersAreReadyForNextPhase iterates through all players in NetworkManagerCC’s GamePlayers list. If all of them have their ReadyForNextPhase value set to true, the server’s CurrentGamePhase value is set to Unit Movement, and then a ClientRpc function RpcAdvanceToNextPhase is called.

RpcAdvanceToNextPhase

RpcAdvanceToNextPhase will be what tells all the clients to start the process of advancing to the next phase locally. This will be what calls for changes the UI and all that for the Unit Movement phase. The code is shown below:

[ClientRpc]
void RpcAdvanceToNextPhase(bool allPlayersReady, string newGamePhase)
{
	Debug.Log("Are all players ready for next phase?: " + allPlayersReady);
	if (allPlayersReady)
	{
		Debug.Log("Advancing phase from player: " + this.PlayerName);
		GameplayManager.instance.ChangeGamePhase(newGamePhase);
	}
}

If the server returns allPlayersReady as true, then the ChangeGamePhase function in GameplayManager will be called with the server’s game phase as an argument. But wait, you need to create ChangeGamePhase!

ChangeGamePhase in GameplayManager.cs

ChangeGamePhase will be used to have GameplayManager advance to the various stages locally. It will check what phase the server has advanced to, and then call the appropriate functions to advance to that phase. The code is shown below:

public void ChangeGamePhase(string newGamePhase)
{
	currentGamePhase = newGamePhase;
	if (newGamePhase == "Unit Movement")
		EndUnitPlacementPhase();
}

So, if the new phase is “Unit Movement” (which is the only other phase as of right now…), ChangeGamePhase will then call EndUnitPlacementPhase.

But, oh, no, some more changes will be made. EndUnitPlacementPhase needs to be updated to the following:

public void EndUnitPlacementPhase()
{
	Camera.main.orthographicSize = 7;
	SetGamePhaseText();
	UnitPlacementUI.SetActive(false);
	RemoveCannotPlaceHereOutlines();
	LocalGamePlayerScript.ChangeReadyForNextPhaseStatus();
	StartUnitMovementPhase();
}

The main change is the addition of the line LocalGamePlayerScript.ChangeReadyForNextPhaseStatus(); to reset the player’s ReadyForNextPhase value back to false. I also removed a check on if the EscMenu is open since I still want the phase to advance locally if the menu is open or not.

Displaying Ready Players in the UI

You may be thinking to yourself, “Hey, what was that hook function HandlePlayerReadyStatusUpdate added for?” and I’m here to tell you. When a player presses endUnitPlacementButton I want all players’ UIs to update and display that a player is ready to advance the phase. I also want the UI to update if a player presses the button again to unready. The code for the hook function is shown below:

public void HandlePlayerReadyStatusUpdate(bool oldValue, bool newValue)
{
	Debug.Log("Player ready status has been has been updated for " + this.PlayerName + ": " + oldValue + " to new value: " + newValue);
	if (hasAuthority)
	{
		GameplayManager.instance.UpdateReadyButton();
	}
	GameplayManager.instance.UpdatePlayerReadyText(this.PlayerName, this.ReadyForNextPhase);
}

So, when the GamePlayer’s ReadyForNextPhase value changes, it will first check if the client has authority over the GamePlayer. If it does, UpdateReadyButton will be called from GameplayManager. Regardless of authority, UpdatePlayerReadyText will be called from GameplayManager. These functions will update the endUnitPlacementButton and the “player ready” text respectively.

UpdateReadyButton

The code for UpdateReadyButton is shown below:

public void UpdateReadyButton()
{
	if (currentGamePhase == "Unit Placement")
	{
		if (LocalGamePlayerScript.ReadyForNextPhase)
		{
			Debug.Log("Local Player is ready to go to next phase.");
			endUnitPlacementButton.GetComponentInChildren<Text>().text = "Unready";
		}
		else
		{
			Debug.Log("Local Player IS NOT ready to go to next phase.");
			endUnitPlacementButton.GetComponentInChildren<Text>().text = "Done Placing Units";
		}
	}
}

UpdateReadyButton simply checks what the LocalGamePlayer’s ReadyForNextPhase is. Based on that value, it will change the text of the endUnitPlacementButton button to make it clear to the player that if they press the button again, they will unready themselves.

UpdatePlayerReadyText

UpdatePlayerReadyText will require some prep to get everything to work. This will modify a new UI element to display the users that are ready. First, these two variables should be added to GameplayManager.cs

[SerializeField] Text PlayerReadyText;
public List<string> readyPlayers = new List<string>();

PlayerReadyText is a new UI text object will be created next. readyPlayers is list of the player’s who are ready and will be displayed in PlayerReadyText.

PlayerReadyText UI

More UI to add! Remember how fun that’s always been in the past? In the Unity Editor, make sure you are in the Gameplay scene. Then, click on GameplayUI in the hierarchy and create a new Text object.

Rename the text to PlayerReadyText. Then, in the inspector, set its anchor to “Bottom Center” and its position values to:

  • Pos X: 0
  • Pos Y: 25
  • Width: 500
  • Height: 50

In the text section, set the following values:

  • Font: ThaleahFat
  • Font Size: 30
  • Alignment: Centered
  • You can check “best fit” if you want
  • Color: white

You can then disable PlayerReadyText in the hierarchy. Then select the GameplayManager object in the hierarchy and attach the PlayerReadyText object

Updating the UI with UpdatePlayerReadyText

Below, then, is the code to update the UI PlayerReadyText.

public void UpdatePlayerReadyText(string playerName, bool isPlayerReady)
    {
        if (isPlayerReady)
        {
            readyPlayers.Add(playerName);

            if (!PlayerReadyText.gameObject.activeInHierarchy)
            {
                PlayerReadyText.gameObject.SetActive(true);
            }
        }
        else
        {
            readyPlayers.Remove(playerName);
            if (readyPlayers.Count == 0)
            {
                PlayerReadyText.gameObject.SetActive(false);
                PlayerReadyText.text = "";
            }
        }
        if (readyPlayers.Count > 0)
        {
            PlayerReadyText.text = "Players Ready:";
            foreach (string player in readyPlayers)
            {
                PlayerReadyText.text += " " + player;
            }
        }
    }

UpdatePlayerReadyText will add the player’s name to the readyPlayers list if they are ready. If they are not ready, they will be removed from the list. If the count of the readyPlayers list is greater than zero, the text of PlayerReadyText will be updated.

Final changes To Trigger this

There are a few more changes to be made in MouseClickManager.cs to make sure this all gets trigged. Right now, there is no call to CheckIfAllUnitsHaveBeenPlaced which activates the endUnitPlacementButton button. Without that button, none of these changes really matter! So, in MouseClickManager.cs, add the following lines of code to the MoveAllUnits function after the foreach loop:

if (GameplayManager.instance.currentGamePhase == "Unit Placement")
	GameplayManager.instance.CheckIfAllUnitsHaveBeenPlaced();
if (GameplayManager.instance.currentGamePhase == "Unit Movement")
	GameplayManager.instance.UnitsHaveMoved();

Then, in CheckIfAllUnitsHaveBeenPlaced, changes will need to be made so that it checks only if the local player placed all their units. In GameplayManager.cs, update CheckIfAllUnitsHaveBeenPlaced to the following code:

public void CheckIfAllUnitsHaveBeenPlaced()
{
	Debug.Log("Running CheckIfAllUnitsHaveBeenPlaced()");

	GameObject[] PlayerUnitHolders = GameObject.FindGameObjectsWithTag("PlayerUnitHolder");
	foreach (GameObject unitHolder in PlayerUnitHolders)
	{
		PlayerUnitHolder unitHolderScript = unitHolder.GetComponent<PlayerUnitHolder>();
		if (unitHolderScript.ownerConnectionId == LocalGamePlayerScript.ConnectionId)
		{
			bool allPlaced = false;
			foreach (Transform unitChild in unitHolder.transform)
			{
				if (!unitChild.gameObject.GetComponent<UnitScript>().placedDuringUnitPlacement)
				{
					allPlaced = false;
					break;
				}
				else
					allPlaced = true;
			}
			if (allPlaced)
			{
				endUnitPlacementButton.SetActive(true);
			}
		}
	}
}

The main change from before is that CheckIfAllUnitsHaveBeenPlaced iterates through all of the PlayerUnitHolder objects until it finds the one owned by the LocalGamePlayer, and then only makes the unit placement checks on those units and not another player’s units.

The above checks, though, rely on placedDuringUnitPlacement being set to true on all units. This will need to be added to the MoveUnit function in UnitScript.cs

if (GameplayManager.instance.currentGamePhase == "Unit Placement")
     placedDuringUnitPlacement = true;

Then, finally, the function that the endUnitPlacementButton calls needs to be changed from EndUnitPlacementPhase to ChangePlayerReadyStatus in the Unity Editor.

Save all changes in the Gameplay scene, build and run, and you should see the ready changes in the game!

But then, wait, when you advance to Unit Movement, you still can’t see the opponent’s units…

Syncing Player Units on Phase Change

The main thing to “sync” when the phase changes from Unit Placement to Unit Movement is where the opponent’s units are on the map. During the Unit Placement phase, the server set each unit’s newPosition value to be where the player placed them. So, the main thing to do to sync each unit is to have each client place the opponent player’s units on the land object whose transform.position matches the unit’s newPosition value.

To get started, open the GamePlayer.cs script and create two new functions. The first is a public UpdateUnitPositions function, and the second a command function CmdUpdateUnitPositions.

UpdateUnitPositions

UpdateUnitPositions will simply check if the client has authority over the gameplayer object and then makes a request to the server to run CmdUpdateUnitPositions.

public void UpdateUnitPositions()
{
	if (hasAuthority)
		CmdUpdateUnitPositions();
}

CmdUpdateUnitPositions

CmdUpdateUnitPositions will go through each unit owned by the requesting player, check if newPosition is different from startingPosition, and if they are, set startingPosition to be the same as newPosition. Code is shown below:

[Command]
void CmdUpdateUnitPositions()
{
	NetworkIdentity networkIdentity = connectionToClient.identity;
	GamePlayer requestingPlayer = networkIdentity.GetComponent<GamePlayer>();
	GameObject[] PlayerUnitHolders = GameObject.FindGameObjectsWithTag("PlayerUnitHolder");
	foreach (GameObject unitHolder in PlayerUnitHolders)
	{
		PlayerUnitHolder unitHolderScript = unitHolder.GetComponent<PlayerUnitHolder>();
		if (unitHolderScript.ownerConnectionId == requestingPlayer.ConnectionId)
		{
			foreach (Transform unitChild in unitHolder.transform)
			{
				UnitScript unitScript = unitChild.transform.gameObject.GetComponent<UnitScript>();
				if (unitScript.ownerConnectionId == requestingPlayer.ConnectionId && unitScript.startingPosition != unitScript.newPosition)
				{
					unitScript.startingPosition = unitScript.newPosition;
				}
			}
			break;
		}
	}
}

So, now when the phase changes, all the unit movement changes should be reflected in the startingPosition value.

Moving the Unit to startingPosition

When startingPosition changes, the unit should be moved to that new startingPosition land object. To do this, a hook function will be added to startingPosition in UnitScript.cs

[SyncVar(hook = nameof(HandleMoveUnitToStartingPosition))] public Vector3 startingPosition;

The code for the HandleMoveUnitToStartingPosition hook function is shown below:

public void HandleMoveUnitToStartingPosition(Vector3 oldValue, Vector3 newValue)
{
	if (!hasAuthority)
	{
		GameObject landHolder = GameObject.FindGameObjectWithTag("LandHolder");
		foreach (Transform land in landHolder.transform)
		{
			if (land.transform.position == this.startingPosition)
			{
				MoveUnit(land.gameObject);
			}
		}
	}
}

HandleMoveUnitToStartingPosition will first check that the client does not have authority over the unit, then the rest of the function executes. It looks for units you do not have authority over because your units should already have been moved by you. This should only move units of other players, which you will not have authority over. The code then does the following:

  • Finds the LandTileHolder based on its tag
  • iterates through each land child object
    • if the land object’s transform.position is equal to the unit’s startingPosition value, then:
      • Call MoveUnit with the land object as the argument.
      • Move unit will move the sprite of the unit to the correct position on the land tile, and it will also call UpdateUnitLandObject to make sure all the unit text and outlines are setup correctly

This should take care of moving all the units to their correct position. The last thing to do is to actually call GamePlayer’s UpdateUnitPositions function from somewhere.

In GameplayManager.cs, go to the StartUnitMovementPhase and update it to the following:

public void StartUnitMovementPhase()
{
	Debug.Log("Starting the Unit Movement Phase.");
	haveUnitsMoved = false;
	ActivateUnitMovementUI();
	SaveUnitStartingLocation();
	LocalGamePlayerScript.UpdateUnitPositions();
}

This removes the check for if EscMenu is open and adds the call for LocalGamePlayerScript.UpdateUnitPositions(). You should be able to save everything now, build and run, and test it out…

The units look to be synced!

And if you go to the individual units and check their starting/newPosition values, you should see that they have been updated to match at the start of the phase

And those values match the transform.position of their land object!

Hurray! Things look like they are working!!!

Before you go…Initializing the Player Hand

Before finishing this post, I want to make sure that the player’s card hands get properly initialized when the Unit Movement phase starts.

Open PlayerHand.cs, and make sure that the synclists for the card network identities in the hand and discard piles are created:

public SyncList<uint> HandNetId = new SyncList<uint>();
public SyncList<uint> DiscardPileNetId = new SyncList<uint>();

To pupulate these synclists with the values of the cards the player owns, InitializePlayerHand will be modified so it calls a command function to add the cards to the list.

First, here is the code for the modified InitializePlayerHand:

public void InitializePlayerHand()
{
	if (!isHandInitialized)
	{
		GameObject[] allCards = GameObject.FindGameObjectsWithTag("Card");
		foreach (GameObject card in allCards)
		{
			Card cardScript = card.GetComponent<Card>();
			if (cardScript.ownerConnectionId == this.ownerConnectionId)
			{
				this.Hand.Add(card);
			}
		}
		Hand = Hand.OrderByDescending(o => o.GetComponent<Card>().Power).ToList();
		CmdInitializePlayerHand();
		Debug.Log("Hand initialized for: " + ownerPlayerName);
	}
}

The main update is to make a request to the server to run CmdInitializePlayerHand and that the function was made public. The code for CmdInitializePlayerHand is shown below:

[Command]
void CmdInitializePlayerHand()
{
	if (!this.isHandInitialized)
	{
		GameObject[] allCards = GameObject.FindGameObjectsWithTag("Card");
		foreach (GameObject card in allCards)
		{
			Card cardScript = card.GetComponent<Card>();
			if (cardScript.ownerConnectionId == this.ownerConnectionId)
			{
				this.HandNetId.Add(card.GetComponent<NetworkIdentity>().netId);
			}
		}
		this.isHandInitialized = true;
		Debug.Log("Hand initialized for: " + ownerPlayerName);
	}
}

CmdInitializePlayerHand does the following:

  • Gets all cards based on the “card” tag
    • Gets each card’s Card script
    • Checks if the card’s owner info matches the owner info of the PlayerHand
    • If they match, add the network identity of the card to HandNetId

To make sure this is executed when the phase gets updated from Unit Placement to Unit Movement, open GameplayManager.cs and add the following to the StartUnitMovementPhase function.

GameObject[] allPlayerHands = GameObject.FindGameObjectsWithTag("PlayerHand");
foreach (GameObject playerHand in allPlayerHands)
{
	playerHand.GetComponent<PlayerHand>().InitializePlayerHand();
}

Save everything, build and run, and the PlayerCardHand synclists are updated when you go to Unit Movement!

Next Steps…

Now, I will need to update the Unit Movement phase “gameplay” to be multiplayer. That means updating unit movement for the unit movement phase and other things like resetting a unit’s position and finally, advancing to the phase after unit movement (what could that be!?)

And then, someday, adding more to the game that I haven’t done yet! Battles! picking cards! winning and losing!