Multiplayer Experiment: Tit-tac-toe - Pt. 2
Starting to get somewhere π
Context
In my last article, I mentioned that I wanted to practice more multiplayer development, so I'd like to make an online Tic-tac-toe game, hopefully with exaggerated amount of particles and VFX. I'm having fun with this experiment!
I've just started though, so here's how it currently looks when running the game:
I've only setup a basic scene, and the ability to highlight a game spot when you hover the mouse over it.
So let's continue the development journey of this experiment, and see what we can make happen. Since I've managed to highlight the open game spots in the game, I'd like to add the functionality of selecting an open spot and claiming it.
First Prefab
I created a new folder under the pre-made Assets
folder called Prefabs
. Then I dragged one of the OpenSpot
GOs from the Scene Hierarchy into the Prefabs
folder to create my 1st prefab:
Now looking into the prefab, we have a way to mark which open spot we're highlighting with the mouse, but we don't have a way to mark an open spot. Let's add a new script called GameSpotBehavior
to handle that:
Adding some gameplay logic
I wanted to go ahead and add some code, but I felt like I needed to stop for a minute and think about the current problem.
Separating responsibilities
Let's pause for a bit and think about the flow of what needs to happen for a player to claim an open spot on the game board during their turn. Here's a quick diagram I made to help me think. It might not be the best or final way I build this process, but it's a working algorithm:
Also, shout out to the miro team for creating such an awesome tool! π
In this diagram, we can see that there are 3 main action steps:
Searching for a game spot
Highlighting a game spot
Claiming a game spot
Small code cleanup
If you remember from the last article, the OpenSpotSearcher
C# class handles searching for an open game spot and highlighting the spot when the mouse hovers over it. We can, (and should), separate these two actions into two separate classes, so let's do that.
Let's create a new file called GameSpotRaycastSearcher
:
using UnityEngine;
static class GameSpotRaycastSearcher
{
static private readonly float k_maxRayCastDistance = 500f;
static private readonly int k_rayCastLayerMask = 1 << 3;
static public bool SearchForGameSpotsUsingCamera(
Camera sceneCamera,
out RaycastHit raycastHit)
{
Ray rayFromMousePosition = sceneCamera.ScreenPointToRay(Input.mousePosition);
return Physics.Raycast(
rayFromMousePosition,
out raycastHit,
k_maxRayCastDistance,
k_rayCastLayerMask);
}
}
By structuring our code like this, we can now raycast into the scene looking for GameObjects that are part of the OpenSpot
layer whenever we need. Note, I include the word Raycast into the class name because I'm using that UnityEngine
specific feature.
Now, let's add code to our GameSpotBehavior
script file that we added to the prefab earlier:
using UnityEngine;
public class GameSpotBehavior : MonoBehaviour
{
public bool isSpotClaimed { get; private set; } = false;
private Color m_currentOpenSpotFoundColor = new Color();
[SerializeField]
private MeshRenderer m_meshRenderer;
public void HighlightOpenGameSpot()
{
m_currentOpenSpotFoundColor = m_meshRenderer.material.color;
m_meshRenderer.material.color = Color.blue;
}
public void ResetGameSpotToOriginalLook()
{
Debug.Log("reverting to color..." + m_currentOpenSpotFoundColor);
m_meshRenderer.material.color = m_currentOpenSpotFoundColor;
}
public void ChangeToClaimedSpot()
{
Debug.Log("Spot claimed!");
isSpotClaimed = true;
// TODO:
// if X turn
//ChangeVisualStyleToClaimedAs_X
// else //O turn
//ChangeVisualStyleToClaimedAs_O
}
private void ChangeVisualStyleToClaimedAs_X()
{
// TODO
}
private void ChangeVisualStyleToClaimedAs_O()
{
// TODO
}
}
This class will now hold the logic of the game spot behavior, mainly when claiming the open spot. This means for an object to be highlighted when the mouse cursor hovers over it and changing it to claimed as either X
or O
.
Also, please note that the reference I use of the object's MeshRenderer
component is now serialized:
[SerializeField]
private MeshRenderer m_meshRenderer;
So I set this reference in the prefab editor itself:
With this small change, I don't have to use the Start
method, or make an unnecessary call to gameObject.GetComponent<MeshRenderer>();
And last but not least, let's update our OpenSpotSearcher
script. It will now use the GameSpotRaycastSearcher
class for searching in the game world, and it will communicate with the object's GameSpotBehavior
component, to signal when to change claim the spot, and change its visual style. It will not define or care about what those two actions mean:
using UnityEngine;
public class OpenSpotSearcher : MonoBehaviour
{
public Camera sceneCamera;
private GameSpotBehavior m_currentGameSpotBehaviorFound = null;
private void FixedUpdate()
{
if (GameSpotRaycastSearcher.SearchForGameSpotsUsingCamera(
sceneCamera,
out RaycastHit raycastHit))
{
var objectHit = raycastHit.transform.gameObject;
var objectHitGameSpotBehavior =
objectHit.GetComponent<GameSpotBehavior>();
if (objectHitGameSpotBehavior != m_currentGameSpotBehaviorFound)
{
ResetCurrentGameSpotBehaviorFound();
}
m_currentGameSpotBehaviorFound = objectHitGameSpotBehavior;
if (m_currentGameSpotBehaviorFound.isSpotClaimed == false)
{
m_currentGameSpotBehaviorFound.HighlightOpenGameSpot();
if (Input.GetMouseButtonDown(0))
{
m_currentGameSpotBehaviorFound.ChangeToClaimedSpot();
}
}
}
else //no open spot found
{
ResetCurrentGameSpotBehaviorFound();
m_currentGameSpotBehaviorFound = null;
}
}
private void ResetCurrentGameSpotBehaviorFound()
{
if (m_currentGameSpotBehaviorFound != null)
{
m_currentGameSpotBehaviorFound.ResetGameSpotToOriginalLook();
}
}
}
π First fail of the day! ππ
Whoops! I found a critical error in my logic, check it out!
π¨ It leaves all highlighted spots in blue. This is NOT what should be shown to playersβ
The problem is that on every frame we tell the found spot to get highlighted, it calls the HighlightOpenGameSpot
function like this:
public class GameSpotBehavior : MonoBehaviour
{
...
public void HighlightOpenGameSpot()
{
m_currentOpenSpotFoundColor = m_meshRenderer.material.color;
m_meshRenderer.material.color = Color.blue;
//^^ how do we know it's not already highlighted?
}
This means that it first gets the original color of the material (correct), but once the mouse is still hovering over the spot, this function gets called again, and the m_currentOpenSpotFoundColor
changes to the new value of the material which is Color.blue
! So the ACTUAL original value of the material gets lost!
To currently fix this error, I added a new isHighlighted
property.
using UnityEngine;
public class GameSpotBehavior : MonoBehaviour
{
public bool isSpotClaimed { get; private set; } = false;
private bool isHighlighted = false;
...
public void HighlightOpenGameSpot()
{
if (isHighlighted)
return;
m_currentOpenSpotFoundColor = m_meshRenderer.material.color;
m_meshRenderer.material.color = Color.blue;
isHighlighted = true;
}
public void ResetGameSpotToOriginalLook()
{
m_meshRenderer.material.color = m_currentOpenSpotFoundColor;
isHighlighted = false;
}
...
}
And there we go! We're back to the desired result:
Note: Some of you might be already thinking that I could handle this with a C# event, or perhaps a Unity Event...And you're right!
π I might look into it in the next article!
Claiming a Spot!
So now that we have these 3 important classes working together, let's add some small changes to mark the game spot as claimed.
On GameSpotBehavior
, this is just some initial logic to show that a spot has been claimed:
public class GameSpotBehavior : MonoBehaviour
{
...
public void ChangeToClaimedSpot()
{
Debug.Log("Claiming spot!");
isSpotClaimed = true;
m_meshRenderer.material.color = Color.green;
...
}
...
}
Just for now, I'll stick to simply changing an open spot to green π’. This will visually mean that the spot is now claimed.
And on OpenSpotSearcher
, update this function:
public class OpenSpotSearcher : MonoBehaviour
{
...
private void ResetCurrentGameSpotBehaviorFound()
{
if (m_currentGameSpotBehaviorFound != null &&
m_currentGameSpotBehaviorFound.isSpotClaimed == false)
{
m_currentGameSpotBehaviorFound.ResetGameSpotToOriginalLook();
}
}
}
And just like that, we now have the base logic for highlighting an open spot and also claiming it. Now it's a matter of differentiating between claiming as X
or O
:
Back to using regular Update π
There's a principle out there called the YAGNI principle (You Aren't Gonna Need It). It states that at times people add unnecessary checks in their code, or start optimizing code before it's even needed.
I noticed that when I used the FixedUpdate
function, the game did NOT pick up on the mouse click as quickly as it should. I changed to using the regular Update
function for now. Even though raycasting is an expensive operation, the scene is still small enough that it doesn't affect the experience in the game so far:
public class OpenSpotSearcher : MonoBehaviour
{
...
private void Update()
{
...
}
}
Back to the regular update. So essentially, YAGNI = "if it ain't broke, don't fix it!"
Also, you can think of this as the 2nd fail of this experiment π
Completed goals
Improved code structure to change the visual aspect of game spots
The code now supports claiming a game spot
Lessons learned so far
Proper use of layer masks in ray casting
There's a reason there are only 32 possible layers in a Unity project
Again, YAGNI. Do NOT optimize before you need to! π
Please let me know what you think!
When has using the
FixedUpdate
worked best for you?Can you start identifying which logic will need to be reworked for a multiplayer experience?
How else would you have written the logic to claim an open spot in the game, and change the visual style?
πππ Happy building! πππ