Making State Machine for Units in RTS Game

RTS unit moving

The generic unit is sufficient for basic movement and very basic states. For complex cases, we have to define a state machine system for our units, to manage their states and reduce the problems in future. There are tons of approaches to making a state machine. I am using one of them.

I also came across many problems I found in my own approach, and ways to correct them. The code however, is not changed by me, but I will give tips of how you can prevent these problems in your code.

What is State Management?

Before we dive into creating it, lets see what it is exactly. In short, we have a unit that can be in different states, such as moving, standing (idle), collecting, fighting. But here is the problem, a unit can be moving for tons of different reasons. It can be moving while fighting, while collecting, while depositing, or while chasing and so on. Also, a unit can be fighting while standing, while moving and so on.

If we keep using if-statements directly, we will soon get stuck in problems, where there will be multiple conditions becoming true at same time. In that case what our unit will do? For example, if an enemy appears in view, and we have given user a command of patrolling, what the unit will do? As both the conditions are true now (condition for chasing the enemy and condition for patrolling).

State machine is one of the ways to solve this problem. A state machine is a way of programming in which we restrict our unit to have exactly 1 state at a given time. And on certain conditions, it can transit to other pre-defined states.

What are States and Transitions

States are like nodes and transitions are like edges in a graph. Transition is a link between 2 states. Transition is a condition that, if gets true, causes the state to change from A to B.

State machine diagram of door that can be either opened or closed. Image from Wikipedia.

Re-Creating Our Unit with State Machine System

Breakdown of Our State Management Approach

In our RTS unit state management approach, we will start with a base class called Unit. This base class will define the fundamental states that are common to all units, such as IDLE, WALKING, DYING, and DEAD. It will also include methods to handle actions and transitions based on the unit’s current state. For example, if the current state is WALKING, the base class can trigger a walking animation.

# PSEUDOCODE:

enum {
	IDLE, MOVING, DYING, DIED
}

# Current state of the unit
var current_state = IDLE


func _process(delta):
	_check_state_transitions(delta) # Finalize current state
	_process_all_states(delta) # Act based on current state
	

func _check_state_transitions(delta: float):
	# decide what state should the unit be in


func _process_all_states(delta: float):
	# decide what to do in current state

When creating specific types of units, like a Warrior, we will extend the Unit class. The Warrior class will inherit all the base states and methods from Unit and will also define its own unique states, such as FIGHTING and CHASING.

To integrate these additional states, the Warrior class will override the methods in Unit responsible for handling actions and state transitions. For example:

  • Override _process_all_states: The Warrior can extend the behavior of the base method _process_all_states to process its new states. Base states such as WALKING are already handled in base Unit class, but since Warrior adds new states (such as FIGHTING), it must define the actions that should occur when state becomes FIGHTING, in this method.
  • Override _check_state_transitions: The Warrior can add new transitions to the state machine by overriding this method. Base transitions are already handled in base Unit, and additional ones will be added by the Warrior.
# PSEUDOCODE:
class Warrior extends Unit


enum {
	FIGHTING=10, CHASING=11 # Additional states for warrior
}


# no need to call overridden functions (below) again in game-loop as base class calls them, just add super(delta)
func _process(delta):
  super(delta)
	

# overriding
func _check_state_transitions(delta: float):
  super(delta) # call base class implementation
	# handle only warrior specific transitions (base states handled by super() call)

func _process_all_states(delta: float):
  super(delta) # call base class implementation
	# process only warrior specific states (base states handled by super() call)

This way, we can add more and more specialized states in extended classes, while retaining existing states, and adding new ones on top.

Implementing our State Management Approach

Now its time to re-create our unit with this better approach. Start by creating a script named “Unit.gd” (if it already exists, remove all the previous code).

Lay down the fundamental structure of our state machine system, like this (in Unit.gd):

extends CharacterBody3D
class_name Unit

# The possible states our Unit can be in
enum UnitState {
	IDLE, MOVING, DYING, DIED
}

# Current state of the unit
var current_state = UnitState.IDLE

# Current path agent is following (if it exists)
var current_agent_path = []
var current_agent_path_index = 0


# Give correct collision_layer to unit so RaycastSystem can detect it
func _ready():
	add_to_group("units")
	# Units are on layer 2. So selection-box must check for layer 2 via collision_mask
	collision_layer = 0b00000000_00000000_00000000_00000010
	collision_mask = 0b00000000_00000000_00000000_11111111


func _physics_process(delta):
	_check_state_transitions(delta) # Finalize current state
	_process_all_states(delta) # Act based on current state
	

func _check_state_transitions(delta: float):
	pass


func _process_all_states(delta: float):
	pass

Now, all kinds of Units in your game, for example Warrior, Villager, Soldier, Archer, etc will be created as a separate scene with their own scripts, which extends the base Unit. They will override _check_state_transitions and _process_all_states functions in their scripts to check for their own states and act for their own transitions.

For now, just complete the Unit class implementation. In _check_state_transitions, add following code:

func _check_state_transitions(delta: float):
		if Input.is_action_just_pressed("left_mouse_button") and self in get_tree().get_nodes_in_group("selected_units"):
		# reset path when clicked
		current_agent_path.clear() # Can be a source of bug, if extended script expects path to be there
		current_agent_path_index = 0
		current_state = UnitState.MOVING

	# Clear the path when the destination is reached
	elif current_agent_path_index >= current_agent_path.size():
		current_state = UnitState.IDLE

The above function checks for conditions (mouse click, and unit is be selected) and changes current state to MOVING. And it changes current state back to IDLE if there is no path or if path is already traversed.

In _process_all_states, for now, add following code:

func _process_all_states(delta: float):
	_process_died_state()
	_process_dying_state()
	_process_moving_state()
	_process_idle_state()

We have not yet defined the above functions, but we will define them slowly. For now, just define the function to handle IDLE and MOVING states as we have only created transitions for these two:


# What to do when current_state is MOVING
func _process_moving_state():
	if current_state == UnitState.MOVING:
		if current_agent_path.size() > 0 and current_agent_path_index < current_agent_path.size():
			var target_pos = current_agent_path[current_agent_path_index]
			target_pos.y = self.global_position.y # DO NOT TOUCH IT
			var direction = (target_pos - self.global_position).normalized()
			var distance = self.global_position.distance_to(target_pos)

			# Update velocity for move_and_slide
			velocity.x = direction.x * speed
			velocity.z = direction.z * speed

			# Play walk animation
			animation_player.play("Walk")
			# Rotate towards the target only along y-axis
			look_at(target_pos, Vector3.UP)


			# Check if agent reached the current target position
			if distance < 0.5:
				current_agent_path_index += 1  # Move to the next waypoint
				print("Reached waypoint:", current_agent_path_index)
		else:
		  # (re)calculate the path if no path
			calculate_path(RaycastSystem.get_mouse_world_position())



# What to do when current_state is IDLE
func _process_idle_state():
	if current_state == UnitState.IDLE:
		current_agent_path.clear()
		velocity.x = 0
		velocity.z = 0
		 
		# Ensure idle animation is enforced
		animation_player.play("Idle")
		animation_player.get_animation("Idle").loop_mode = Animation.LOOP_LINEAR

Explanation: For MOVING state, the path is calculated to where the mouse is clicked. If there exists a path, the unit moves to next point in the path, until it reaches the end of the path. – For IDLE, state, it makes the velocity 0.0 and clears the path. Additionally, animations are being played, but this part can be omitted.

What about _process_died_state and _process_dying_state, for now, define them like this, we will later come across them once we create health bar system:

func _process_died_state():
	pass

func _process_dying_state():
	pass

Testing our Unit

Create a scene, with CharacterBody3D root, a MeshInstance3D for visualize the character model & a CollisionShape3D. Overall scene should look like this:

Instance this unit scene in game world, and you should see the unit moving.

Note that, for now, we are applying Unit.gd (which is a base class) directly to the Warrior. Later, we will create a separate script for warrior (that extends Unit), and assign that to Warrior instead.

Thank you for reading <3

Problems in My Approach & their Solution

I am highlighting the problems I faced in the development so you can prevent them beforehand. Such as: transitions must check predecessor state & transit to new state only if the new state can be reached from predecessor state.

Right now, transitions between states are happening as soon as a trigger condition is met, without considering what the previous state was. This can lead to illogical behavior, like transitioning to a “walking” state immediately after being in a “dead” state, which doesn’t make sense.

To fix this, every state should define a list of valid previous states that the unit must be in to transition to the current state. For example:

  • To transition to “walking,” the previous state must be “idle” or “running,” but not “dead.”
  • To transition to “dead,” the unit could come from any state except already being “dead.”

This setup ensures that transitions follow a logical flow, like how in a graph, a node is connected only to certain other nodes. A state should only transition to a specific set of valid states, creating a well-defined flow of legal transitions.

Pseudocode:

if user_click and etc and is_in_states(IDLE or FIGHTING or COLLECTING):
  current_state = NEW_STATE

The problem with this approach is that we will have to define too many states, such as unit can be in any state such as collecting, taking items back to town center, harvesting and many tons of states. So another work to make it less tedious to developer is to check for is_not_in_states(STATE1 or STATE2) or to check their combination.

Another approach would be to group the related states. The ones which are often checked as a whole. But the best option would be to stick with the above two methods or to omit the previous-state check if the state can be reached globally with just a trigger and previous state doesn’t matters.

Leave a Reply

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