Today we build out the flow of our game logic – setting up the game, starting a round, identifying a scoring event, and starting the next round. There are many steps to consider and so we visually map them out into a flow diagram that we will use to build our “finite state machine”. We will also use an “enumerated type” to create an easy-to-read variable to hold our current state, and use the “GameObject.Find( )” command to allow one object to easily locate another. Let’s go!
Part 1: The Finite State Engine
Right now our “game” isn’t much of a game yet. We are lacking the basic components of a game such as rules and objectives. We need to add the steps and stages and rules and mechanics that make this a game of Pong! rather than a really basic screensaver.
The goal of our game is for one player to win, which they do by scoring more points than their opponent. This objective implies that we need a method by which to score points, and multiple opportunities for players to score. A score is registered when the ball gets past and hits the wall behind the opposing player’s paddle. In order to have multiple attempts, we need to create “rounds” of playing through our core bouncing ball mechanic. Each round starts with the ball reset to the center, and ends when a score is registered.
Given that, we can generate a diagram of the “flow” of our game as such:
This is an example of a Finite State Machine – a model of our the logic of our game, where our game will inhabit only one state at a time, and certain conditions must be met to move or transition from state to state.
In our model, we can see the need for a few particular stages as we progress. First we have to set up or initialize a new game. This is where we will set our initial score of 0-0 and initialize all of the objects back into their start state. Next we have to set up the individual round. This state is separate from the initialize game state because we will return to this frequently as each new round begins. Here we expect to see behaviors like putting the ball back at the origin, and resetting any modifications that we made to the pitch of the sound or speed of the ball. Once the round begins, we enter the actual gameplay state, which continues until one side scores. Once a point is scored, we need to register that event and determine if someone has won the game, or if we need to reset our board and start a new round. If the objective number of points have been scored, it’s time to declare a winner and move to a “game over” state.
It is important to note that this is an abstract machine – we will use this model to code the states and transitions and object behaviors, but these components will be assembled through composite parts so that the preferred action occurs, rather than coding everything into one giant instruction set in one particular function.
Enumeration
Since we will share the responsibility for moving between states across the objects in our scene, we need a reliable method to get and set our current state. Traditionally one might use a string to hold a value so that it hold an easy to read value, or one might use an integer and assign values to each index so that the lookup is fast. Instead, we will use a method that combines the advantages of both – Enumeration.
Enumeration lets us define all of the possible values for a set, which fits very well when working with a finite number of states. An commonly given example of this would be to create an enumerated set of directions (north, east, south, west) so that the values are plain English, but are also the only possible values for the type.
In our case, we want to define some states for our game – setting up the game, getting a round ready, the actual gameplay itself, and finally the game over state. We define our enumeration by declaring a public enum outside of the PingelPongel class declaration in our project. This will make the type available to use across all scripts in our scene.
using System.Collections; using System.Collections.Generic; using UnityEngine; public enum GameState { setup, getReady, playing, gameOver}; public class EasyPong: MonoBehaviour { public GameObject ballObject; private Rigidbody ballRigidbody; ...
Now that I’ve declare the enumerations, I can declare my own variable in the class and by default put it in the “Setup” state…
// define my gamestate variable public GameState myState = GameState.setup;
Now we have something that we can test. Let’s hop over to the Paddle Script to see an example of how we can use this…
Last time, we left the instructions for moving the paddle inside of the Update( ) method, meaning that it would be called once per frame. But paddles should only move while the game is in play, not before or after the round. We want to constrict the paddle movement to only occur when the game state is set to “playing”.
First, we take the contents of the Update( ) method and move them into a new void function called MovePaddle( ). We rewrite Update( ) to feature a check to see if the game state is set to “GameState.playing”, and if it is, we run the MovePaddle( ) command.
void Update() { if (game.myState == GameState.playing) { MovePaddles(); } } private void MovePaddles() { Vector3 currentPosition = transform.position; // get my position if (Input.GetKey(upButton)) { currentPosition.z += speed * Time.deltaTime; } if (Input.GetKey(downButton)) { currentPosition.z -= speed * Time.deltaTime; } currentPosition.z = Mathf.Clamp(currentPosition.z, -offset, offset); transform.position = currentPosition; }
If we try to run this now, Unity will be cross with us and we will get a Null Reference Exception, because we have no connection to the Game Manager object reference to the game manager to access the “gamestate” variable.
We could build a public GameObject variable and use our drag-and-drop method to connect the Game Manager object, but let’s try another useful method – the Find( ) command.
Rather than continue to hide our main script in the Main Camera object, we create a new Empty GameObject and named it “GameManager”, so that it will be easier to find. We copy our “EasyPong” component from the camera and paste it to the new empty object. (Don’t forget to delete the old component, or we will have two managers running!)
private EasyPong game; // Start is called before the first frame update void Start() { // get the game manager object GameObject gameManager = GameObject.Find("GameManager"); Debug.Log("Game Manager object is: " + gameManager); // set the easy ping script game = gameManager.GetComponent<EasyPing>(); }
Here, we declare a GameObject which we name “gameManager” (this cleverly named variable will hold the reference to our Game Manager), and then we access the “EasyPong” script attached to that object using GetComponent<EasyPong>( ).
NOTE:
Although I used two separate variables here, we could have also done this with just one running GetComponent on the GameObject that Find( ) would return like so:
game = GameObject.Find(“GameManager”).GetComponent<EasyPong>( );
… but here I chose the extra step for clarity. Note that I declared the “gameManager” variable inside of the function, as opposed to “game” which is declared at the start of the class. This mid-function declaration is a local variable, meaning it is only available within the function itself, and is released when finished. Since I only need “gameManager” once – to find the EasyPong component – it makes sense to declare it here and let that variable expire when the function is over. My “game” variable is an instance variable, which means that value is available to that instance of the script. This means that multiple instances can have different values, such as our paddles holding different strings for which button is up or down.
You may think it would make more sense to call this a class variable, but that term is reserved for a variable whose value is consistent across every instance of the class. We will learn more about this next week when we dive into “singletons”.
Inside of the Start( ) method, we set gameManager equal to GameObject.Find(“GameManager”). Find()
will return the first object in the scene that it finds with a matching name.
Next we set our EasyPong object “game” by running a GetComponent<EasyPong >() on gameManager, and now we have direct access into the public variables and methods of that script.
Armed with this new information, we update our Update( ) “if” statement to read as: if (game.myState == GameState.playing)
NOTE:
You may have noticed the Debug.Log( ) commands also used here. This is a function that allows you to print a message directly to the Log and the Console Window for debugging purposes. I tend to have these scattered throughout my code as they are useful for tracking down whether or not a particular function is being called, and what values are set at different locations. Note the use of the “+” symbol to concatenate a string with another value. More on Debug.Log( )…
Now go back to Unity and hit play to see what happens. If you have done this correctly, nothing should occur – the paddles should stay in the same place and be unresponsive to key presses. This is because our gamestate is not set to “playing”. Because we made this public, we can change the setting in our Editor. You will see a dropdown for the Game State – set that to “Playing” and click on your game window and now your paddles should move.
Part 2: Moving Through States
Initializing the Game
Most games that we play in life will require some sort of preparation before the game itself can begin. Bases must be placed, cards must be dealt, game pieces must be distributed. Our game is no different – before we can play our game, we need to set the objects and attributes and place them into a ready state for play to begin.
In order for our game to have a winner, we need to be able to keep score. We also should have some definition of what “winning” is. (Here it will be the first player to reach 3 points.)
// score tracking private int playerOneScore; private int playerTwoScore; private int MAX_SCORE = 3;
Here I set the value for MAX_SCORE because I only intend to read that variable, not set it. (Hence the use of ALL_CAPS_SNAKE_CASE). But our player one and two scores I will set in our Start( ) menu because these could reset again, for instance when we restart the game.
void Start() { ballRigidbody = ballObject.GetComponent<Rigidbody>(); // assign the rigidbody // initialize the score playerOneScore = 0; playerTwoScore = 0; // get the start position of the ball ballStartPosition = ballObject.transform.position; // set the game state gameState = GameState.setup; // start the first round InitRound(); }
In our start we reset the scores to 0, fetch the position of the ball object (so that we have a reference point to use once a round is over), and make sure the GameState is at “setup”. Finally, move to our next stage – starting an individual round, which we do with our new method InitRound( )
Starting the Round
The start of a new round, whether it is the first round or comes after a scoring event, consists of two primary steps:
- Resetting the ball to the start position
- Starting the round by pushing the ball into motion
To handle this, we created three new functions – InitRound( ), ResetBall( ), and StartBall( ). InitRound( ) will handle our initial setup of a new round. Here we should start in the “setup” stage. It will call ResetBall( ) for us, which will move the ball back to the origin, and reset any other modifications we made such as pitch modulation. Finally StartBall( ) will put the ball into motion.
private void InitRound() { // Reset Ball ResetBall(); // Start Ball StartBall(); } private void ResetBall() { // make sure we have control of the ball, not physics ballRigidbody.isKinematic = true; // move the ball to the start position ballObject.transform.position = ballStartPosition; } private void StartBall() { // turn kinematics off ballRigidbody.isKinematic = false; // make the direction vector a heading direction.Normalize(); // move the ball by adding force ballRigidbody.AddForce(direction * force); // change the state to playing gameState = GameState.playing; }
New here is the isKinematic value that we are modifying on our Rigidbody object “rb”. The Rigidbody.isKinematic flag is a boolean – that is, it can only be set to true or false. When you set an object’s isKinematic value to true, you can now control and move the object, instead of Unity’s physics system. It is a simple way to take physics out of the equation. Without this, your movement of the ball would be considered to be a physical move and well… let’s just say bad things can happen.
Here in ResetBall( ) we set isKinematic to TRUE. We then move the ball back to the original spot by setting the transform.position value to ballStartPosition.
Once we finish resetting the ball, InitRound( ) calls StartBall( ). This script turns ball Physics back on (by setting isKinematic to FALSE), and then applies the direction and force that we previously held inside Start( ). Finally, we set the gamestate to GameState.playing.
In order to set this machine into motion, we made the last line of our Start( ) method a call to InitRound( ). Now the ball is in action and our paddles finally move!
Now that we’ve started the round, we need a way to stop it, and we do this by recognizing that back wall has been hit, and registering the scoring event.
Part 3: Registering the Score
We set up our system so that the back walls would detect whether or not they had been hit by the ball, and tell the game manager to register the scoring event. We did this so that we could set a variable on each wall to indicate to whom the score should be attributed.
In order to talk to the EasyPong script, we needed a reference to the Game Manager so that we could access the component, and call the public function RegisterScore( ). In the past, we have referenced a GameObject and the used GetComponent<>( ) to access the script, but here we shortcut that by defining a public variable of type EasyPong and now we can ONLY associate objects with the appropriate component attached.
public class BackWallScript : MonoBehaviour { public EasyPong gameManager; public int playerNumber; private void OnCollisionEnter(Collision collision) { if (collision.gameObject.name == "Ball") { gameManager.RegisterScore(playerNumber); } }
Another change from our previous collisions – this time we tested the name of the game object that we collided with, verifying that it is indeed the “Ball”. Once we have confirmed this, we tell the EasyPong instance to run the RegisterScore function, and pass it an integer that we will populate with the player id who should receive the point.
Back in EasyPong, we define RegisterScore(int playerNumber) as such:
public void RegisterScore(int playerNumber) { // stop the action ballRigidbody.isKinematic = true; // process the score if (playerNumber == 1) { playerOneScore++; // increment the player score } else if (playerNumber == 2) { playerTwoScore++; // increment they player two score } else { Debug.Log("Player Number was not assigned correctly"); } // Display the score Debug.Log("The Score is " + playerOneScore + "-" + playerTwoScore); // no longer playing gameState = GameState.setup; // start the next round InitRound(); }
There’s a lot going on here, so let’s unpack it:
- We set isKinematic to true, effectively stopping the ball and removing it from the physics equation
- We check to see which player scores, and increment their score value by one (the ++ is the same as saying that something “+= 1” or something = something + 1)
- We use Display.Log( ) to put the score into the console to track our progress
- We prepare for a move back to the setup stage by setting the gamestate to “setup” and running InitRound( ).
- This is also where we would test to see if either player had just made it to our MAX_SCORE value, and if so, move to the Game Over state instead, but that part is for you to complete as your homework.
BackWallScript.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class BackWallScript : MonoBehaviour { public EasyPong gameManager; public int playerNumber; private void OnCollisionEnter(Collision collision) { if (collision.gameObject.name == "Ball") { gameManager.RegisterScore(playerNumber); } } }
EasyPong.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public enum GameState { setup, getReady, playing, gameOver}; public class EasyPong : MonoBehaviour { public GameObject ballObject; private Rigidbody ballRigidbody; public Vector3 direction; public float force; // define my gamestate variable public GameState myState = GameState.setup; // score tracking private int playerOneScore; private int playerTwoScore; private int MAX_SCORE = 3; // ball start position private Vector3 ballStartPosition; // Start is called before the first frame update void Start() { // connect to the ball ballRigidbody = ballObject.GetComponent<Rigidbody>(); // reset the score playerOneScore = 0; playerTwoScore = 0; // set the ball position ballStartPosition = ballObject.transform.position; // initialize the round InitRound(); } private void InitRound() { Debug.Log("InitRound Called"); // Reset the ball position ResetBall(); // Start the ball StartBall(); } private void ResetBall() { Debug.Log("ResetBall Called"); // make sure we have control of the ball, not physics. ballRigidbody.isKinematic = true; // position the ball ballObject.transform.position = ballStartPosition; } private void StartBall() { Debug.Log("Start Ball called"); // turn kinematics off, let physics control the ball ballRigidbody.isKinematic = false; // fix the direction vector direction.Normalize(); // push the ball ballRigidbody.AddForce((direction * force), ForceMode.VelocityChange); // Enter the playing state myState = GameState.playing; } public void RegisterScore(int playerNumber) { // stop the action ballRigidbody.isKinematic = true; // process the score if (playerNumber == 1) { playerOneScore++; // increment player one's score } else if (playerNumber == 2) { playerTwoScore++; // increment player two's score } else { Debug.Log("Player Number was not assigned correctly: " + playerNumber); } // display the score Debug.Log("The Score is: " + playerOneScore + " - " + playerTwoScore); // stop the gameplay myState = GameState.setup; // start the next round InitRound(); } }