CardConquest GameDev Blog #17: Updating the Unit Movement Phase Multiplayer using Mirror – Part 1

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 my previous two posts I worked on making the “Unit Placement” phase work in multiplayer. Now, it’s time to do the same for Unit Movement! The following “functions” within the Unit Movement phase will need to be updated for multiplayer:

1. Server validated unit movement
2. Server validated “reset movement” feature
3. Update the ability to view your own card hand so it finds your hand now that there will be more than one in the game
4. Create a “ready up” system to advance the unit movement phase to the next phase when all players are ready

And then finally, I will need to add one more thing to Unit Movement that didn’t exist before: Allow players to view other players’ cards!

Let’s get started

Server Validated Unit Movement

In the Unit Placement phase, all unit movement was validated by the server in the CmdServerCanUnitsMove function of UnitScript.cs. There was a check for when the phase was “Unit Placement” that checked if the player was allowed to place a unit on that land tile, and if they could, the server would respond that the movement was valid.

Now, a new check specific for the Unit Movement phase will need to be added to CmdServerCanUnitsMove. This check will be largely based on the older singleplayer movement function which checks the distance between where the unit is and where the player is trying to move the unit. If the distance isn’t greater than 3, then the movement is allowed. The code is shown below:

if (GameplayManager.instance.currentGamePhase == "Unit Movement")
{
	bool canMove = false;
	foreach (GameObject unit in unitsSelected)
	{

		UnitScript unitScript = unit.GetComponent<UnitScript>();
		float disFromCurrentLocation = Vector3.Distance(positionToMoveTo, unitScript.startingPosition);
		if (disFromCurrentLocation < 3.01f)
		{
			Debug.Log("SUCCESS: Unit movement distance of: " + disFromCurrentLocation.ToString("0.00"));
			canMove = true;
		}
		else
		{
			Debug.Log("FAILURE: Unit movement distance of: " + disFromCurrentLocation.ToString("0.00"));
			canMove = false;
			break;
		}
	}
	TargetReturnCanUnitsMove(connectionToClient, canMove, landUserClicked, positionToMoveTo);
	return;
}

If you save UnitScript.cs now, you should be able to move units in the Unit Movement phase.

Making Sure the Player is Moving Their Units

To finish the update to unit movement, I want to add one more check that has the server validate that the units the player is requesting to move are all units that the player owns.

When the player joins the game and their GamePlayer object is spawned, the local GameplayManager object will then make a request to the server to spawn the player’s units in the game. When the units are spawned, the network ids of the units are added to the GamePlayer’s playerUnitNetIds list to track which units a player owns, based on the unit’s network id.

So, in the CmdServerCanUnitsMove function of UnitScript.cs, a new check will be added that goes through each unit the player is requesting to move. If any of the unit’s network ids is NOT in the requesting player’s playerUnitNetIds list, the move request will be rejecting. If they are all in the requesting player’s playerUnitNetIds list, then the rest of the checks will be performed to validate the move.

The code for this validation is shown below:

foreach (GameObject unit in unitsSelected)
{
	uint unitNetId = unit.GetComponent<NetworkIdentity>().netId;
	if (requestingPlayer.playerUnitNetIds.Contains(unitNetId))
	{
		continue;
	}
	else
	{
		Debug.Log("Player tried to move unit they do not own. Rejecting movement. Unit: " + unit + " netid: " + unitNetId);
		TargetReturnCanUnitsMove(connectionToClient, false, landUserClicked, positionToMoveTo);
		return;
	}
}

The above code does the following:

  • Iterate through all units in the unitsSelected list sent by the player
    • get the network id of the unit
    • check if the requesting player’s playerUnitNetIds list contains the network id
      • if true, continue to the next unit in the unitsSelected list
      • if false, call TargetReturnCanUnitsMove to the client saying the check failed. Then return to stop execution of the CmdServerCanUnitsMove function

Save everything now and then build and run the game. Move the units along the map and locally “End Unit Movement” so you can get the units of opposing players within one tile of each other, like this:

Select a green and blue infantry and try to move them to one of the two tiles between them. You should see that the movement fails, and on the server running in unity, you will see the following in the debug log:

Great! So a player can’t move a unit they don’t own. But wait, why are they able to select an opponent’s unit in the first place? Glad you asked.

Prevent Selecting Opponent’s Unit

The component that handle’s unit selection is MouseClickManager.cs. Unit selection is done in the Update function when MouseClickManager detects that you left clicked with the mouse and that you hit an object in the “units” layer. Right now, it just checks to make sure that the unit you clicked on isn’t null and then proceeds with the unit selection process.

This can be updated with a simple check under the if (rayHitUnit.collider != null) check that makes sure you have network authority over the unit you clicked on. This is done by getting the NetworkIdentity component of the unit and using .hasAuthority to return true/false on if you have authority over the unit. Code is shown below:

if (rayHitUnit.collider.gameObject.GetComponent<NetworkIdentity>().hasAuthority)

Make sure that everything that was previously under the if (rayHitUnit.collider != null) if statement is now under the if (rayHitUnit.collider.gameObject.GetComponent().hasAuthority) if statement.

You’re probably seeing some errors right now in VisualStudio, and that is because MouseClickManager.cs isn’t importing Mirror yet. Add the Mirror import to the top of the MouseClickManager.cs script:

using Mirror;

Save everything, and you shouldn’t be able to select other player’s units any more! Yay!

Fixing the “Reset Movement” Function

Back in the time of singleplayer, a “Reset Movement” function was created to allow the player to undo moves they had made in the Unit Movement phase and start back at the beginning of the turn. If you try to use it now, it doesn’t really work. It sort of works on the host player’s game, in that in moves the unit sprite to the previous land tile, but it doesn’t update the unit’s position on the server and causes a discrepancy between where the server thinks the unit is and where the host player sees where the unit is. On the client game, it just doesn’t work at all. So, time to update it!

The Reset Movement function is handled in GameplayManager.cs’s ResetAllUnitMovement function. Currently, the code looks like this:

The code for the new ResetAllUnitMovement function is shown below:

public void ResetAllUnitMovement()
{
	if (!EscMenuManager.instance.IsMainMenuOpen)
	{
		GameObject unitHolder = LocalGamePlayerScript.myUnitHolder;
		PlayerUnitHolder unitHolderScript = unitHolder.GetComponent<PlayerUnitHolder>();
		if (unitHolderScript.ownerConnectionId == LocalGamePlayerScript.ConnectionId)
		{
			foreach (Transform unitChild in unitHolder.transform)
			{
				if (unitChild.GetComponent<NetworkIdentity>().hasAuthority)
				{
					UnitScript unitChildScript = unitChild.GetComponent<UnitScript>();
					if (unitChildScript.newPosition != unitChildScript.startingPosition && unitChildScript.previouslyOccupiedLand != null)
					{
						if (MouseClickManager.instance.unitsSelected.Count > 0)
							MouseClickManager.instance.ClearUnitSelection();
						MouseClickManager.instance.unitsSelected.Add(unitChild.gameObject);

						unitChildScript.CmdUpdateUnitNewPosition(unitChild.gameObject, unitChildScript.startingPosition, unitChildScript.previouslyOccupiedLand);
						Debug.Log("Calling MoveAllUnits from GameplayManager for land  on: " + unitChildScript.previouslyOccupiedLand.transform.position);
						MouseClickManager.instance.MoveAllUnits(unitChildScript.previouslyOccupiedLand);
						//MouseClickManager.instance.unitsSelected.Clear();
						unitChildScript.currentlySelected = true;
						MouseClickManager.instance.ClearUnitSelection();
					}
				}
			}
		}

		if (resetAllMovementButton.activeInHierarchy)
		{
			Debug.Log("Deactivating the resetAllMovementButton");
			resetAllMovementButton.SetActive(false);
		}
		if (!unitMovementNoUnitsMovedText.gameObject.activeInHierarchy)
		{
			Debug.Log("Activating the unitMovementNoUnitsMovedText");
			unitMovementNoUnitsMovedText.gameObject.SetActive(true);
		}
		if (endUnitMovementButton.activeInHierarchy)
		{
			Debug.Log("Changing the endUnitMovementButton color to white");
			endUnitMovementButton.GetComponent<Image>().color = Color.white;
		}
		haveUnitsMoved = false;
	}
}

The code does the following:

  • Gets the unitHolder object stored in LocalGamePlayer’s myUnitHolder variable
  • Gets the UnitHolder script for the unitHolder
  • Verifies that the unitHolder’s owner information matches the LocalGamePlayer’s information
  • Iterates through every unit that is a child of the unitHolder object
    • Checks to see if the unit’s newPosition value is different from its startingPosition value. This will be true if the unit moved
    • If the unit moved, then:
      • Clear the MouseClickManager’s unitsSelected list if player has any units selected
      • Add the unit to MouseClickManager’s unitsSelected list
      • call the CmdUpdateUnitNewPosition function on the unit to have the server update the player’s position back to their starting position
      • Call “MoveAllUnits” from MouseClickManager to move the unit sprite
      • Set the unit’s “currentlySelected” value to true
      • call ClearUnitSelection in MouseClickManager to clear the selected units and unhighlight any units/land objects
  • After the units have been moved, it then resets some of the UI necessary for the Unit Movement phase to match the state of having no units moved
  • Sets GameplayManager’s haveUnitsMoved to false

You will notice two errors in VisualStudio above. First is the squiggly line under NetworkIdentity, which means Mirror needs to be imported to GameplayerManager.cs

The second error is from CmdUpdateUnitNewPosition. In UnitScript.cs, CmdUpdateUnitNewPosition is currently a private function. So, make sure to change it to a public function:

You may notice that ResetAllUnitMovement relies on the unit’s previouslyOccupiedLand not being null, as a lot of the downstream functions to move the unit require the land object from there. When the game transitions from Unit Placement to Unit Movement, previouslyOccupiedLand should be set in GameplayManager’s SaveUnitStartingLocation function. However, I noticed that the game wasn’t setting that value for all units. you may be able to figure out why by looking at the code for SaveUnitStartingLocation:

SaveUnitStartingLocation finds a object with the PlayerUnitHolder tag and then updates the unit child objects. The issue now is that there isn’t only one object with the PlayerUnitHolder tag, there are multiple objects with that tag for each player in the game. So, SaveUnitStartingLocation will need to be updated to find your PlayerUnitHolder and update your units. The new code is shown below:

void SaveUnitStartingLocation()
{
	Debug.Log("Saving unit's starting land location.");
	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.GetComponent<NetworkIdentity>().hasAuthority)
				{
					UnitScript unitChildScript = unitChild.GetComponent<UnitScript>();
					if (unitChildScript.currentLandOccupied != null)
					{
						unitChildScript.previouslyOccupiedLand = unitChildScript.currentLandOccupied;
					}
				}
			}
		}
	}
}

The main difference with the old SaveUnitStartingLocation code and the new code is that the new version first finds all objects with the PlayerUnitHolder tag. It then iterates through all of them until it finds the PlayerUnitHolder that has the same owner information as the LocalGamePlayer. When that is found, it then updates the units.

Save everything, and you should be able to do Reset Movement on both the host and the client, and everything should stay in sync!

A Little UI Failure

While playing around with Reset Movement, I noticed a bug when doing a specific movement/reset sequence that caused the “unit text” on a map not to update correctly after the movement was reset. A demonstration of the issue is shown here:

If you pay close attention, you can see that after the Reset Movement button is pressed, the “x2” unit text next to the tank and infantry doesn’t update correctly. There are still two tanks and infantry on the tile, and if you move the units off and back on it updates correctly.

I spent a really long time trying to figure out why this was occurring, and why it only occurred under very specific circumstances. The code for recreating the unit text is in the MultipleUnitsUIText function of LandScript.cs. In the MultipleUnitsUIText function, it checks if the infText or tankText GameObjects are null, and if they are, instantiates them.

While I was troubleshooting the issue, I found this blog post by Jovanni Cutigni. The tl;dr of the post is that using “Destroy” on a gameobject and then using if (gameObj == null) checks may fail due to how Unity handles “destroying” gameobjects.

A solution to this issue seemed to be to explicitly make my gameobjects equal to null after they are destroyed. So, in the UpdateUnitText function, this:

Was updated to this:

Save LandScript.cs, and the UI issues with the unit text not reappearing after Reset Movement is clicked is fixed! Or, at least I think it is fix? Good enough for now anyway!

Viewing Cards in Your Hand

When starting to convert the game to multiplayer, I made sure to comment out any reference to PlayerHand.instance because that wasn’t going to work in the multiplayer version. There would be multiple PlayerHand objects in the scene, so referencing one as the instance would cause issues.

There’s a pretty quick fix for this! In GameplayManager add the below line to ShowPlayerHandPressed:

LocalGamePlayerScript.myPlayerCardHand.GetComponent<PlayerHand>().ShowPlayerHandOnScreen();

I also added a call to MouseClickManager’s ClearUnitSelection to avoid any issues with the player having units selected when they view their cards. It probably isn’t necessary but it seemed helpful to me when I was debugging some issues I was having.

Then, in HidePlayerHandPressed, add the following line:

LocalGamePlayerScript.myPlayerCardHand.GetComponent<PlayerHand>().HidePlayerHandOnScreen();

This will…almost work! If you run the game now, you will see that the first time you click the button to view your hand, nothing happens. If you click to “hide” your invisible hand, then click to view it again, THEN you will see your cards. Why? Well, it’s because I made an assumption in PlayerHand.cs’s ShowPlayerHandOnScreen() to only change the cards positions if they aren’t active in the scene:

This can be fixed by simply moving playerCard.transform.position = cardLocation; outside of that if statement.

And now look, you can view your cards again!

One final thing to add in regards to viewing your cards is fixing it so hitting the Esc key will hide your card. To do that, open the EscMenuManager.cs script. Then, add some new variables to store the LocalGamePlayer information:

[Header("GamePlayers")]
[SerializeField] private GameObject LocalGamePlayer;
[SerializeField] private GamePlayer LocalGamePlayerScript;
[SerializeField] private GameObject LocalPlayerHand;
[SerializeField] private PlayerHand LocalPlayerHandScript;

Then, create two new functions, GetLocalGamePlayer and GetLocalGamePlayerHand, to get the LocalGamePlayer and the player’s PlayerHand.

void GetLocalGamePlayer()
{
	LocalGamePlayer = GameObject.Find("LocalGamePlayer");
	LocalGamePlayerScript = LocalGamePlayer.GetComponent<GamePlayer>();
	
}
public void GetLocalGamePlayerHand()
{
	LocalPlayerHand = LocalGamePlayerScript.myPlayerCardHand;
	LocalPlayerHandScript = LocalPlayerHand.GetComponent<PlayerHand>();
}

Make a call to GetLocalGamePlayer in EscMenuManager’s Start function.

GetLocalGamePlayerHand won’t be called in the Start function. I tried that before but always got issues with it due to the myPlayerHand value not being set yet when EscMenuManager “starts.” I also don’t actually need to worry about hiding the player’s cards until the Unit Movement phase when they can actually view the cards. So, in GameplayManger.cs in its EndUnitPlacementPhase function, add a call to LocalGamePlayerScript.ChangeReadyForNextPhaseStatus(); so that when the Unit Movement phase starts, the EscMenuManager will have a reference to the player’s myPlayerCardHand value.

Also, for this to work, back in GameplayManager.cs, add back the Awake function and call the MakeInstance function from Awake

Save everything, build and run, and you can view your cards and press Esc to hide them!

Ready Up!

Much like I didn’t want the game to advance past the “Unit Placement” phase until all players were ready, I do not want the “Unit Movement” phase to end until all players are ready. The first step in all this is to into the Gameplay scene and select the “endUnitMovementButton”

Then, in endUnitMovementButton’s On Click, change the function to GameplayManager’s ChangePlayerReadyStatus

A few more changes will need to be made in GameplayManager.cs. In the UpdateReadyButton function, some code will need to be added to make changes to the Unit Movement UI.

if (currentGamePhase == "Unit Movement")
{
	if (LocalGamePlayerScript.ReadyForNextPhase)
	{
		endUnitMovementButton.GetComponentInChildren<Text>().text = "Unready";
		endUnitMovementButton.GetComponent<Image>().color = Color.white;
		if (resetAllMovementButton.activeInHierarchy)
			resetAllMovementButton.SetActive(false);
		if (MouseClickManager.instance.unitsSelected.Count > 0)
			MouseClickManager.instance.ClearUnitSelection();
	}
	else
	{
		endUnitMovementButton.GetComponentInChildren<Text>().text = "End Unit Movement";
		if (haveUnitsMoved)
		{
			endUnitMovementButton.GetComponent<Image>().color = Color.yellow;
			resetAllMovementButton.SetActive(true);
		}
	}
}

The above code changes the text and color of the endUnitMovementButton to indicate to the user that their ready status has changed. It will also remove some UI elements if the player changed to ready, and restore some elements if the player unreadied.

Then, update ChangeGamePhase to the following code:

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

One change made to ChangeGamePhase is that it doesn’t set currentGamePhase to newGamePhase until after it checks what the new game phase is. This is to allow it to be able to check what the previous game phase was as well. If the phase change was Unit Placement -> Unit Movement, EndUnitPlacementPhase is called. If it went Unit Movement -> Unit Movement, then StartUnitMovementPhase is called instead. This is likely a temporary thing, but for now a needed one!

Fixing the PlayerHand Initialization

This isn’t really necessary right now, but I noticed that the way the PlayerHands are initialized isn’t working quite correctly. Right now, sometimes a playerhand gets “initialized” twice on the client game. To fix this, open up PlayerHand.cs. Then, add a new bool variable called localHandInitialized.

Then, the InitializePlayerHand function will be updated to the following:

public void InitializePlayerHand()
{
	if (!localHandInitialized)
	{
		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();
		localHandInitialized = true;
		if(hasAuthority)
			CmdInitializePlayerHand();
		Debug.Log("Hand initialized for: " + ownerPlayerName);
	}
}

The changes to the code are as follows:

  • Check if localHandInitialized is false instead of isHandInitialized
  • Set localHandInitialized to true
  • Only call CmdInitializePlayerHand if the player has network authority over this PlayerHand

Next will be a change to where InitializePlayerHand is called from and how. Currently, it is called in GameplayManager’s StartUnitMovementPhase function.

This can all be deleted. The PlayerHand only needs to be initialized once, so calling it in StartUnitMovementPhase shouldn’t be necessary since StartUnitMovementPhase will be called multiple times throughout a full game. Instead, the PlayerHands can instead be initialized during EndUnitPlacementPhase since that will only be called once in a game.

In EndUnitPlacementPhase, the code to initialize the PlayerHand will be changed to the following:

GameObject[] allPlayerHands = GameObject.FindGameObjectsWithTag("PlayerHand");
foreach (GameObject playerHand in allPlayerHands)
{
	PlayerHand playerHandScript = playerHand.GetComponent<PlayerHand>();
	if (!playerHandScript.localHandInitialized)
	{
		playerHandScript.InitializePlayerHand();
	}
}
if (MouseClickManager.instance.unitsSelected.Count > 0)
	MouseClickManager.instance.ClearUnitSelection();

InitializePlayerHand will be called for each playerHand object, but only if the localHandInitialized value is false. I also call ClearUnitSelection so any units selected in Unit Placement don’t carry over into Unit Movement.

In EndUnitPlacementPhase there is a call to LocalGamePlayerScript.ChangeReadyForNextPhaseStatus(); that I was using to make sure that the “ReadyForNextPhase” value of each player would be false. Instead, I’m going to remove that from EndUnitPlacementPhase (delete that line of code).

Instead, in GamePlayer.cs, a change will be made to the CheckIfAllPlayersAreReadyForNextPhase server function so that each player’s ReadyForNextPhase value is set to false when the game phase changes to the next phase. Under the if (allPlayersReady) check, change the code to the following:

if (allPlayersReady)
{
	foreach (GamePlayer gamePlayer in Game.GamePlayers)
	{
		gamePlayer.ReadyForNextPhase = false;
	}
	if (Game.CurrentGamePhase == "Unit Placement")
		Game.CurrentGamePhase = "Unit Movement";
	if (Game.CurrentGamePhase == "Unit Movement")
	{
		// Placeholder code for real code which will do things like check for battles
		Debug.Log("Current phase is Unit Movement");
	}
	RpcAdvanceToNextPhase(allPlayersReady, Game.CurrentGamePhase);
}

Save everything, build and run, and you should experience something similar to this video!

Next Steps…

Next I will add the one NEW FEATURE I wanted to add to Unit Movement: Allow players to view other players’ cards!

Smell ya later!