Godot Tactics RPG – 04. State Machine

Hi, 7thsage again. I’m back for part 4. State Machines. We got most of the setup done last lesson so this time around we can focus mostly on just the state machines.

State Machines

A very useful pattern that you will frequently run into in game design is the State Machine. A state machine is like a little box of behavior that we can attach to an object and swap out as things change to modify the behavior. Without it, we would need a long chain of if statements, or a switch statement(Match in Godot). While this could work in a smaller example, it doesn’t scale well and can get messy if there is a lot of logic inside each state.

#Example Code
enum StateEnum {
  LOADING,
  PLAYING,
  GAMEOVER
}

var _state: StateEnum

func CheckState:
	match _state:
		:StateEnum.LOADING:
			#Loading Logic here
			pass
			
		:StateEnum.PLAYING:
			#Playing Logic here
			pass
			
		:StateEnum.GAMEOVER:
			#GameOver Logic here
			pass

		:_
			#Default value
			pass

Cleanup

Before we get started, lets do a tiny bit of cleaning up by deleting the Test node we created in the last tutorial. We’ll leave the script alone without being attached to any objects for the time being so we can copy things over from it if we want. Don’t worry if you’ve already deleted both though, we’ll go over the lines we need from it later on.

State

We’ll be creating a basic state class that will be potentially useful and generic enough to use in a number of places. With that in mind, we’ll organize accordingly. Create a subfolder under “Scripts/Common” called “State Machine” and inside that we’ll create a script called “State.gd”. This will be the base state for all of our state objects.

class_name State
extends Node

func Enter():
	AddListeners()

func Exit():
	RemoveListeners()

func _exit_tree():
	RemoveListeners()

func AddListeners():
	pass

func RemoveListeners():
	pass

It’s a pretty simple script. The original version of the code marked the class as abstract, which GDScript does not seem to have any concept of. But just realize we will be inheriting from this class, and not using it directly.

The Enter() and Exit() functions we’ll use to setup and clean up states, along with anything we need to transition. We’ll also be using the chance to Add and Remove listeners for our signals. As a back up, we also call RemoveListeners() from the _exit_tree() function that is called when the node is deleted.

Later on we’ll be using this to add things such as listening for input signals. Because this state will only be listening while it is active, we don’t have to worry about it receiving events when something else is in control. For example, it prevents us from moving our position when we are using input for our menu.

State Machine

In the same folder as the last script, “Scripts/Common/State Machine”, create another script “StateMachine.gd”. This is another script that will be reusable in the future. The purpose of this script is to hold a reference to the current state and handle switching from one to another.

class_name StateMachine
extends Node

var _currentState: State

func ChangeState(newState: State) -> void:
	if _currentState == newState:
		return
	
	if _currentState:
		_currentState.Exit()

	_currentState = newState
	
	if _currentState:
		_currentState.Enter()

I simplified the code a bit here. The two main things that I may have to add back are a method for another object to get the current state, which I’m not sure if we need, and there was a check around the changing states to not call transition a second time if it was already in the process of changing. I think this though is primarily down to slight differences in how Unity and Godot handle coroutines

Now let’s create a new node to attach our script to. In the Battle scene, create a child node under “Battle Controller” and name it “State Machine”. Attach the “StateMachine.gd” script to this node.

Battle Controller

We already started the battle controller in the last lesson. There isn’t much we need to add to it for this lesson. Two variables that we will use @export to add to the inspector, one that will hold our State Machine, and a second to hold the initial state we will call. After that the only thing we need is a single line in the _ready() function. This will set the current state to the one we set in the variable for startState.

@export var stateMachine: StateMachine
@export var startState: State

func _ready():
	stateMachine.ChangeState(startState)

Battle State

Earlier we made a generic State class to handle all the things needed for managing state, but now we want to build on that to create a base for all of our battle states. This will hold things like registering/unregistering listeners for our signals, getting a link to the Battle Controller and so on.

Start by adding a subfolder to the folder,”Scripts/Controller” named “Battle States” and create a new script named “BattleState.gd”. We’ll inherit from this for the future states we create.

extends State
class_name BattleState

var _owner: BattleController

func _ready():
	_owner = get_node("../../")

func AddListeners():
	_owner.inputController.moveEvent.connect(OnMove)
	_owner.inputController.fireEvent.connect(OnFire)
	_owner.inputController.quitEvent.connect(OnQuit)

func RemoveListeners():
	_owner.inputController.moveEvent.disconnect(OnMove)
	_owner.inputController.fireEvent.disconnect(OnFire)
	_owner.inputController.quitEvent.disconnect(OnQuit)	

func OnMove(e:Vector2i):
	pass

func OnFire(e:int):
	pass

func SelectTile(p:Vector2i):
	if _owner.board.pos == p:
		return
	
	_owner.board.pos = p
	
func OnQuit():
	get_tree().quit()

Ok, first off we are inheriting from State instead of Node and we give it a class_name. A lot of the next parts we’ll be bringing in from the “Test.gd” script. We use the same variable _owner to store the battle controller and set it in the _ready() function. We are going up two levels with “../../” this time though because the node these scripts will be on will be children of the node State Machine instead of Battle Controller directly.

We bring over our listeners for our signals here as well so we don’t need to do so individually with each state we create later. The OnMove() and OnFire() functions we keep blank for now, as they will have different uses depending on what state we are in.

The SelectTile() function I changed up a little. I removed the check for whether or not a tile is valid from the original tutorial. My reasoning here is that it will allow us to move our cursor over empty tiles, without the need to create dummy empty tiles. This will however mean that later on we will need to do the check before performing an action on the tile.

The OnQuit() function we are pulling from the test script from the last lesson. This will mean that we can quit from whichever state is active.

There are still a few bits from “Test.gd” that we haven’t moved over, but those will go into different locations.

Camera Controller Refactor

Before we move on to creating our first state, let’s do a tiny bit of refactoring. We’ll move the camera listeners to the state machine instead of being stored on the camera controller itself. This will let us turn off the ability to rotate the camera for when that makes sense, such as when we are in a menu.

Open CameraController.gd and in the functions AddListeners() and RemoveListeners(), and move the listeners under each to their respective functions in BattleState.gd. Once that is done, in the CameraController.gd script we can remove the 4 functions: _ready(), _exit_tree(), AddListeners(), and RemoveListeners(). We can also remove the variable “_owner” at the top. This will all be done inside states now.

One last thing, back in BattleState.gd we need to add a couple base functions that we will later use to control the camera.

func Zoom(scroll: int):
	pass
		
func Orbit(direction: Vector2):
	pass

Init Battle State

Now we’ll create our first state that we’ll be using. This will be the first state that that the battle controller starts with. It will be responsible for setting up the level.

In the Battle scene, start by creating a new node under State Machine named “Init Battle State” and create a new script inside the folder “Scripts/Controller/Battle States” named “InitBattleState.gd”. Attach the script to the node we just created.

extends BattleState
@export var moveTargetState: State

func Enter():
	super()
	Init()

func Init():
	var saveFile = _owner.board.savePath + _owner.board.fileName
	_owner.board.LoadMap(saveFile)
	
	var p:Vector2i = _owner.board.tiles.keys()[0]
	SelectTile(p)
	
	_owner.cameraController.setFollow(_owner.board.marker)
	
	_owner.stateMachine.ChangeState(moveTargetState)

We’re inheriting from BattleState as we mentioned earlier. Instead of getting the path of the moveTargetState node in code, we’ll link to it through the inspector.

The super() function calls the base method of that function. In this case, it will call Enter() on the base State class.

At the moment we’re just loading the saveFile specified on the BoardCreator object, but we will be able to change that here in the future.

Next we grab the first entry in the tile dictionary, and set that as our start point. Because the dictionary is not sorted, this is just whichever tile was first created.

We set the camera to follow the selection indicator. We don’t need camera control until after the init phase so we won’t deal with the camera listeners in this state. Last we change to the next state, which in this case will be the moveTargetState

Move Target Battle State

We’ll start off by creating a node for the state. Create a new Child node of “State Machine” and name it “Move Target State”. Create a script named “MoveTargetState.gd” and attach it to the node.

We’ll add more to this script in the future, but for now it will let us move the cursor around the board and listen to our signals. We also kept the Onfire() function from the test script that prints the input events to the console, which we can remove once we start adding functionality to our battle. This time we did use the camera listeners, and passed the values received on to the camera controller. That also marks the last bit from “Test.gd” and we can safely get rid of it now, if you haven’t already.

extends BattleState

func OnMove(e:Vector2i):
	var rotatedPoint = _owner.cameraController.AdjustedMovement(e)
	SelectTile(rotatedPoint + _owner.board.pos)

func OnFire(e:int):
	print("Fire: " + str(e))

func Zoom(scroll: int):
	_owner.cameraController.Zoom(scroll)
		
func Orbit(direction: Vector2):
	_owner.cameraController.Orbit(direction)

Node Setup

One last thing we need to do is set up the variables for our nodes in the inspector. On the “Battle Controller” node, in the inspector assign the State Machine node to the variable for State Machine. Assign the “Init Battle State” node to the “Start State” variable.

Next lets go to the node “Init Battle State” and in the inspector assign the Move Target State node to its variable.

Move Target State doesn’t have any nodes to transition to currently, so we don’t need any references there, but we’ll be adding those in the future.

Summary

Well, this was a pretty quick one since we got all the setup done in the previous lesson, but now we should have everything working. You should be able to once again rotate the camera around and move the selection indicator around the board similar to the previous lesson, but this time it is using a state machine to control the logic.

If you’d like to get a more detailed description of state machines or other patterns, I’d suggest checking out the book Game Programming Patterns by Robert Nystrom. You can read it free online on his website https://gameprogrammingpatterns.com or as a physical copy.

6 thoughts on “Godot Tactics RPG – 04. State Machine

  1. Awesome series, thanks for presenting it. Can’t wait for the next one. So far I’m seeing how many of the items are translating over to Godot, but there’s still some items I’m looking forward to seeing your interpretation of.

    As far as ECM goes in godot (as opposed to inheritance) do you (or anyone on the blog) have anything to recommend reading about component programming?

    1. Regarding ECM, I’ve recently watched this series of videos about using composition in Godot. The practical examples really made it click for me.

    2. Sorry I didn’t respond sooner. Yea, there are definitely more than one way to interpret things. In general though I think Godot expects you to create multiple control nodes as children where Unity expects you to attach multiple scripts on a single object. In the end I don’t think it really makes much difference. You can think of each Unity script attached as a child much the same as a separate node would be. Although I think in Godot you need to focus a little more on keeping the Scene Tree a little more organized, which isn’t necessarily a bad thing. In this lesson for instance I used a separate state machine node instead of putting it directly on battle controller so that the states would be separated from other objects in the tree a bit cleaner.

Leave a Reply

Your email address will not be published. Required fields are marked *