Welcome back to our five-part series on writing cleaner code using design patterns and SOLID principles. This series is written in collaboration with Peter from the Sunny Valley Studio YouTube channel.
In this series, we focus on using object-oriented programming best practices to write cleaner, more maintainable code in Unity. By expanding our basic project with additional features, we demonstrate how to apply SOLID principles and design patterns to create scalable game mechanics.
You can find the first article in the series here. We’ll continue with the series in January of 2025, so watch this space!
The main character from the Unity Starter Assets - Third Person Character Controller package
Adding a jump mechanic using the state pattern
In this second article we step through adding jumping mechanics to the game. We’ll build upon the refactored codebase from our first article and use the state pattern to implement this feature. We’ll demonstrate how this can help separate behaviors into distinct states; by defining transitions between them, we can manage complex logic in small, manageable chunks. This approach will allow us to extend our game with new features while keeping the codebase clean and maintainable.
To follow along with this article you can download the full project from Github and in Unity 6.0 or higher, open the Project tab and open the scene in the folder titled _Scripts → Article 2 → Start.
So far our code design looks like this:
The
Agent
class uses the GroundDetector
, AgentAnimations
, IAgentMovementInput
and BasicCharacterControllerMover
(which uses the AgentRotationStretegy
abstract class). PlayerRotationStrategy
and NPCRotationStrategy
inherit from the AgentRotationStrategy
abstract class. NPCAIInput
and PlayerGameInput
both implement the IAgentMovementInput
interface.
We have the Agent
script that is meant to be a high-level script that uses other objects like IAgentMovementInput
to get the input information or AgentAnimation
to play a specific animation. This means that the code that controls how our game character should act will reside inside the Agent
script. However, this becomes problematic as soon as we try adding the jumping mechanic as we will have to modify the code inside the Agent
class.
We could start with a few innocent bool flags like isJumping
or isFalling
but with every new flag that controls the behavior of our Agent the code will become more and more unmanageable.
If you have previously done something similar you know exactly what the problem will be: spaghetti code.
If you’ve read the first article, please note that we now have an Agent
script instead of AgentMonolithic
script as a result of the homework assignment. You can see that Animations
, Movement
and Ground Detection
are all separate scripts now so the Agent
script is no longer “Monolithic”.
Introducing the state pattern
The state pattern is a design pattern that allows us to encapsulate the behaviors of a class into separate objects, referred to as “states.” By doing so, we can break down complex logic into small, manageable parts. Each state object defines how our game character should behave in a specific context.
A diagram illustrating the state pattern
Additionally, we define the transitions between these states as separate objects. For instance, pressing the Jump button when in the “Movement” state will trigger a transition to the “Jumping” state but only if we are already in a Movement state.
The animation system in Unity implements the same system where each animation clip represents a state and we also specify how different clips transition between each other.
An example of the state pattern being used in the Unity Animation system
The key advantage of this pattern is that it lets us focus on the logic that drives a specific behavior without intertwining it with the logic of other states. By separating transitions from the state logic, we can easily introduce new states and transitions without modifying existing ones. This promotes cleaner, more maintainable code and adheres to the open-closed principle (OCP) by allowing our system to be extended with new behavior without altering the existing code.
We can also specify how exactly different states transition to each other. This way, we can prevent jumping again when we are already inside the Jump state. We can just as easily create a new Double Jump state to implement double jumping in our game, showcasing the flexibility and scalability provided by the state pattern.
You can read more about the state pattern in the e-book Level up your code with design patterns and SOLID.
Our game states
Here is the class diagram of what we will be creating:
Different states defined at the bottom are inheriting the
State
abstract class. Agent
class owns objects of type State, depicted by the filled diamond symbol. It manages the ITransitionRule
instances, depicted by the empty diamond shape. State objects use ITransitionRule
in their logic. Each state can depend on other objects.
First, we will refactor our existing logic into a state pattern by creating MovementState
and FallingState
. Once we ensure that these states work correctly, we will add JumpState
.
Each state will have Enter()
, Exit()
, and Update(float deltaTime)
methods. For example, in the FallState
, the Enter()
method will trigger the fall animation, and the Update()
method will apply gravity to our movement.
To adhere to the dependency inversion principle (DIP), we will define an abstract class called State. The Agent
class will now reference the current State and will call the Enter()
, Update()
, and Exit()
methods on it.
Every state will have an OnTransition(Type)
event action delegate. This will trigger the transition logic inside the Agent
class. Using a delegate allows us to separate the abstract state concept from knowing anything about the specific Agent
class.
The downside is that our Agent
class will now have another responsibility: Controlling state transitions. However, we can always refactor it into a separate object later, once we have our state pattern working. Sometimes, instead of focusing on the current problem, we start thinking about future problems, making the task seem daunting. Instead, we should tackle one problem at a time. Right now, our goal is to refactor our existing code to use the state pattern.
To implement transitions between different states, each state will have a protected list of ITransitionRule
objects that specify the conditions triggering a state transition. It is protected so all subclasses will have access to it. We may not need it to be protected right now, but from my experience designing similar systems, it might be useful later. Let me explain the concept behind these transitions.
What is public event Action?
Action is a type of a delegate so a field that can store a reference to a method. It allows a class to provide notifications to clients of that class when something has changed in an object. This is particularly useful when dealing with one-time input events, such as a jump input in a game or in this case a request to transition to a new state. By using a delegate, multiple parts of your game can react to some event without needing to constantly check a bool flag. This can help decouple your code as we can put the code detecting the input in a separate object than the ones that would like to use that information.
The event keyword is used because, by default, any object that can access an Action delegate can invoke it like a method. However, our goal is almost always to allow only the object that defines the delegate, our State class, to invoke it. This delegate acts as a mechanism to call assigned methods, and the event keyword ensures that only the defining object can trigger it. Other objects are limited to assigning their methods to the delegate, enabling the delegate to call them and notify the object about the event.
Can we use EventHandler?
EventHandler is very useful for creating a notification system that needs to send many arguments to the methods that it calls. But our event doesn’t send any methods. It is much quicker to create an event Action and if we need to send multiple arguments we would definitely create EventHandler. We can also always refactor the code to use EventHandler. But writing code is about making things as simple as possible. That is why we chose to use an Action delegate here.
Read more about:
State pattern: Transitions
Specific transitions defined at the bottom are implementing the
ITransitionRule
interface.
To easily add transitions to our states, we will create an ITransitionRule
interface. This interface will define the condition for the transition and the Type of the state we want to transition to. While we are using the Type, you could also use an enum or a string value. Each solution has its trade-offs, so use whatever works best for your implementation.
Specific transitions will define their conditions inside a method called ShouldTransition(float deltaTime)
, which will return a boolean flag. In the Update(float deltaTime)
method of each state, we will loop through the list of ITransitionRule
objects. If any ITransitionRule
returns true, we will invoke the OnTransition
event and change the current state of the agent to the one defined in our ITransitionRule
.
Each specific transition will receive the necessary objects for its condition check through its constructor. For example, FallLandTransition
will require a GroundedDetector
reference, as we want to stop playing the falling animation and trigger the landing animation only when we detect that the character is grounded. On the other hand, LandTransition
will include a delay corresponding to the length of the landing animation, ensuring the character completes the animation before transitioning back to the MovementState
.
Each State has a method called AddTransition(ITransitionRule)
that allows us to add as many TransitionRules
as needed. This makes it very easy to add a new transition to an existing state without modifying the existing code.
Implementing the state pattern
Let’s start implementing the state pattern by converting our current logic: Movement and Falling into States. You can find all the scripts on Github:
First we need the abstract State
class:
public abstract class State
{
protected List<ITransitionRule> TransitionRules = new();
public event Action<Type> OnTransition;
public abstract void Enter();
public void Update(float deltaTime)
{
if (ShouldTransition(deltaTime))
return;
StateUpdate(deltaTime);
}
protected abstract void StateUpdate(float deltaTime);
public abstract void Exit();
private bool ShouldTransition(float deltaTime)
{
foreach (ITransitionRule rule in TransitionRules)
{
if (rule.ShouldTransition(deltaTime))
{
OnTransition?.Invoke(rule.NextState);
return true;
}
}
return false;
}
public void AddTransition(ITransitionRule rule)
{
TransitionRules.Add(rule);
}
}
State
will be represented by an abstract class and the Agent script will depend on this abstraction.
We also need to define a ITransitionRule
interface:
public interface ITransitionRule
{
bool ShouldTransition(float deltaTime);
Type NextState { get; }
}
public abstract class State
{
...
protected virtual void StateUpdate(float deltaTime)
{
//default behavior reusable by all the state implementations
}
}
public abstract class MovementState : State
{
…
protected override void StateUpdate(float deltaTime)
{
base.StateUpdate(deltaTime); //call the default behavior
//custom code from MovementState
}
}
This will be an interface that allows us to separate the conditions for the state transition from states themselves.
Next let’s define our MovementState
and FallState:
public class MovementState : State
{
private BasicCharacterControllerMover m_mover;
private GroundedDetector m_groundedDetector;
private AgentAnimations m_agentAnimations;
private IAgentMovementInput m_input;
private float m_moveSpeed = 2.0f;
private float m_sprintSpeed = 5.335f;
private float m_verticalVelocity;
private float m_gravity = -15.0f;
private float m_animationMovementSpeed;
private float m_speedChangeRate = 10.0f;
public MovementState(BasicCharacterControllerMover mover, GroundedDetector groundedDetector, AgentAnimations agentAnimations, IAgentMovementInput movementInput)
{
m_mover = mover;
m_groundedDetector = groundedDetector;
m_agentAnimations = agentAnimations;
m_input = movementInput;
}
public override void Enter()
{
m_agentAnimations.SetBool(AnimationBoolType.Grounded, m_groundedDetector.Grounded);
}
public override void Exit()
{
return;
}
protected override void StateUpdate(float deltaTime)
{
if (m_groundedDetector.Grounded == false)
{
m_verticalVelocity += m_gravity * Time.deltaTime;
}
else
{
m_verticalVelocity = 0;
}
float targetMovementSpeed = m_input.SprintInput ? m_sprintSpeed : m_moveSpeed;
m_mover.Move(new Vector3(m_input.MovementInput.x, m_verticalVelocity, m_input.MovementInput.y), targetMovementSpeed);
targetMovementSpeed = m_input.MovementInput == Vector2.zero ? 0 : targetMovementSpeed;
m_animationMovementSpeed = Mathf.Lerp(m_animationMovementSpeed, targetMovementSpeed, Time.deltaTime * m_speedChangeRate);
if (m_animationMovementSpeed < 0.01f)
m_animationMovementSpeed = 0f;
//play animations
m_agentAnimations.SetFloat(AnimationFloatType.Speed, m_animationMovementSpeed);
}
}
We have moved fields and the Update()
logic connected with the movement behavior from our Agent
class into the MovementState
class. Inside the Enter()
method we are setting the Grounded
bool flag to ensure that we trigger the correct animation – Movement Animation. We do this because we now want our state pattern to control everything, including the animations. This sometimes implies making changes to the Animator of the character so that we can more easily control it.
Our State
abstract class defined all methods as abstract. This means that we can end up with empty methods like the Exit()
method here. We could instead define them as virtual methods that are empty inside the State
abstract class.
What is a virtual method ?
Virtual methods in C# are methods that have a default implementation in a base class and can be overridden in derived classes. They provide a way to define common behavior in the base class while allowing derived classes to extend or modify that behavior.
By using virtual methods instead of abstract methods, you can provide default behavior in the base class (
State
). This approach can reduce the need for empty method implementations in derived classes. However, in designs where most states will utilize all the methods (likeEnter
,Exit
, andUpdate
in our state pattern), using abstract methods ensures that each derived class explicitly implements its behavior. This makes it clear what methods are available and necessary for each state, helping maintain a consistent structure and making the design easier to understand and follow.Learn more about virtual keyword.
Another downside is the lengthy constructor. Our MovementState
depends on many objects. We could try refactoring our code later to make the constructor shorter. Here is FallState
script:
public class FallState : State
{
private BasicCharacterControllerMover m_mover;
private AgentAnimations m_agentAnimations;
private IAgentMovementInput m_input;
private float m_moveSpeed = 2.0f;
private float m_sprintSpeed = 5.335f;
private float m_verticalVelocity;
private float m_gravity = -15.0f;
private float m_fallTimeoutDelta;
private bool m_fallTransition = false;
private float m_fallTimeout = 0.15f;
public FallState(BasicCharacterControllerMover mover, AgentAnimations agentAnimations, IAgentMovementInput movementInput)
{
m_mover = mover;
m_agentAnimations = agentAnimations;
m_input = movementInput;
}
public override void Enter()
{
m_agentAnimations.SetBool(AnimationBoolType.Grounded, false);
m_agentAnimations.SetTrigger(AnimationTriggerType.Fall);
}
public override void Exit()
{
m_agentAnimations.ResetTrigger(AnimationTriggerType.Fall);
m_agentAnimations.SetBool(AnimationBoolType.Grounded, true);
}
protected override void StateUpdate(float deltaTime)
{
if (m_fallTransition == false && m_fallTimeoutDelta > 0)
{
m_fallTransition = true;
m_fallTimeoutDelta = m_fallTimeout;
}
else
{
m_fallTimeoutDelta -= Time.deltaTime;
if (m_fallTimeoutDelta <= 0)
{
m_agentAnimations.SetTrigger(AnimationTriggerType.Fall);
}
m_fallTransition = false;
}
float targetMovementSpeed = m_input.SprintInput ? m_sprintSpeed : m_moveSpeed;
targetMovementSpeed = m_input.MovementInput == Vector2.zero ? 0 : targetMovementSpeed;
m_verticalVelocity += m_gravity * Time.deltaTime;
m_mover.Move(
new Vector3(m_input.MovementInput.x, m_verticalVelocity, m_input.MovementInput.y), targetMovementSpeed);
}
}
In our fall state we are duplicating the movement logic so that the Player can still control the character’s movement even when falling. You can preview how we can refactor this logic into a new object in the scripts for article 3 on Github.
We also need the transitions to allow us to move between FallState
and MovementState
. Here is a GroundFallTransition
script:
public class GroundedFallTransition : ITransitionRule
{
public Type NextState => typeof(FallState);
private GroundedDetector m_groundedDetector;
public GroundedFallTransition(GroundedDetector groundedDetector)
{
m_groundedDetector = groundedDetector;
}
public bool ShouldTransition(float deltaTime)
{
return m_groundedDetector.Grounded == false && m_groundedDetector.StairsGrounded == false;
}
}
GroundedFallTransition
makes our character transition to a FallState
when we are suddenly no longer grounded, like when a character walks off the edge. This transition simply depends on the GroundedDetector
object and its Grounded
and StairsGrounded
properties. Here is the LandTransition
script:
public class LandTransition : MonoBehaviour
{
public Type NextState => typeof(MovementState);
private GroundedDetector m_groundedDetector;
public LandTransition(GroundedDetector groundedDetector)
{
m_groundedDetector = groundedDetector;
}
public bool ShouldTransition(float deltaTime)
{
return m_groundedDetector.Grounded;
}
}
LandTransition
is a temporary transition. It is basically the same as the previous one but here we check if we are again grounded and based on this information we trigger the transition to the MovementState
.
Factory method: Integrating Agent class with the state pattern
To integrate our states and transitions into the agent script we will first add a currentState
field to our Agent
script:
public class Agent : MonoBehaviour
{
…
private State m_currentState;
…
To instantiate different states and add specific transitions to them, we’ll use a Factory
method that encapsulates (knows) all the details of how to instantiate each state and each transition.
public class Agent : MonoBehaviour
{
…
private State m_currentState;
…
private State StateFactory(Type stateType)
{
State newState = null;
if (stateType == typeof(MovementState))
{
newState = new MovementState(m_mover, m_groundDetector, m_agentAnimations, m_input);
newState.AddTransition(new GroundedFallTransition(m_groundDetector));
}
else if (stateType == typeof(FallState))
{
newState = new FallState(m_mover, m_agentAnimations, m_input);
newState.AddTransition(new LandTransition(m_groundDetector));
}
return newState;
}
else
{
throw new Exception($"Type not handled {stateType}");
}
…
Here, we use the Type argument in an if
statement (which will soon get longer) to create a new State
object and assign it the necessary transitions. Ideally we would create a new class to handle State
creation since right now Agent
class depends not only on the abstract State
but also on the concrete implementations. We will try to refactor it in the last article of this series.
At this stage, it is challenging to make our code fully adhere to the OCP. Our goal is to limit the number of classes that need modification when adding new features to our project. By centralizing changes within the Agent
script, we ensure that modifications do not impact the functionality of other classes, as no other scripts depend on the Agent
script directly. That being said, right now Agent
will depend on all the existing State
implementations. We will extract this dependency in the upcoming Article 5.
We’ll demonstrate how to handle situations where modifications affect other classes using the example of IAgentMovementInput
.
What is the factory method pattern?
The factory method pattern is a pattern designed to encapsulate logic behind how to create specific implementations of some abstract concept. In our case it helps us to create different types of
IState
objects based on aType
parameter. This hides the instantiation details of each state class from the client (the rest of your application), which only needs to know about theIState
interface.Read more about Factor method pattern here:
Learn more about using the factory pattern in your Unity projects in the e-book Level up your code with design patterns and SOLID.
This is a basic implementation of the factory pattern, as the Agent
is now responsible for creating the states and transitions. This setup is an area where we can further enhance our code architecture. Object-oriented programming is iterative. We make something work, then refine our design when needed, such as when we encounter code duplication across multiple classes. It becomes a problem when you only focus all your attention on adding the new code and don’t stop to refactor the existing logic as needed.
The last step is to create a method that allows us to transition between states and to start using the States to perform our logic instead of the current code. Here is our Agent
script:
public class Agent : MonoBehaviour
{
…
private State m_currentState;
…
private void Start()
{
TransitionToState(typeof(MovementState));
}
private void TransitionToState(Type stateType)
{
State newState = StateFactory(stateType);
if (m_currentState != null)
{
m_currentState.Exit();
m_currentState.OnTransition -= TransitionToState;
}
m_currentState = newState;
Debug.Log($"Entering {stateType}");
m_currentState.OnTransition += TransitionToState;
m_currentState.Enter();
}
…
The TransitionToState()
method takes the Type of the state as an argument and uses the StateFactory
method to create a new state. It calls Exit()
the old state and Enter()
the new state. The most important part here is that it assigns itself to the OnTransition
event Action
delegate which makes it possible for the State to request transition to a new state without needing a reference to the Agent
object.
We also add a Start()
method where we set our initial state to the MovementState
. We do this because in the Awake()
we can’t be sure that all the objects that our State might need to access in its Enter()
method are initialized. For more information look at the order of execution article from the Unity documentation.
What is += and -= ?
When working with delegates like our abstract
State
class and its “public event Action OnTransition” we need some way to let other objects assign their methods to be invoked when the event is called. This is also referred to as subscribing to the event. That is what “+=” allows us to do. An object can get a reference to aState
object and assign its own method to theOnTransition
event delegate. A caveat is that in our case we are sending a parameter of type “Type” which means that we can only assign to our delegate a method that accepts “Type” as its argument (like ourTransitionToState()
method).When we call
OnTransition?.Invoke()
delegate it will go through a list of all the method references that were assigned to it and it will call them one by one - so notifying them about this event. Notice the null checking operator? It is needed because if no method is assigned to a delegate and we Invoke it we will get aNullReferenceException
.We use “-=” to allow objects to un-assign their methods from an event delegate. This is especially important because if we destroy an object that was “listening” for the notifications about our
OnTransition
event delegate without unassigning it, aNullReferenceException
will be thrown every time the notification is sent as we are trying to access a method on an object that is null (that doesn’t exist anymore).Read more about Action delegate.
Lastly, we need to update our currentState
somewhere in our script. Here’s how the Agent
class looks after applying the State Pattern:
public class Agent : MonoBehaviour
{
…
private State m_currentState;
private void Update()
{
if (m_currentState != null)
m_currentState.Update(Time.deltaTime);
}
}
All we need to do is simplify the Update()
method by removing all existing logic and calling the Update()
method on the currentState
.
This demonstrates one of the key benefits of applying design patterns and object-oriented programming principles: each script becomes much shorter and easier to manage. Our Agent
script is now focused primarily on coordinating the different states, making it more maintainable and understandable
At this point the character controller should look like this:
Our system based on the state pattern works but we can see some “sliding” movement when we play Land animation.
One thing that we have is a slight sliding when we land. Lets try removing it using our state pattern implementation.
Adding new Land Sate
In this part we will explore the true power of the state pattern - how much control it gives us over the way our character behaves. Let’s say that we want to stop the sliding movement when our character has just landed.
We will just create a new LandState
(full script on Github):
public class LandState : State
{
private AgentAnimations m_agentAnimations;
public LandState(AgentAnimations agentAnimations)
{
m_agentAnimations = agentAnimations;
}
public override void Enter()
{
m_agentAnimations.SetFloat(AnimationFloatType.Speed, 0);
}
public override void Exit()
{
m_agentAnimations.SetTrigger(AnimationTriggerType.Land);
}
protected override void StateUpdate(float deltaTime)
{
return;
}
}
We need it to actually trigger the Land animation so we have added a new “Land” Trigger to the AgentAnimations
scripts. We do this because now our state pattern needs to be in control of everything. We also need to implement the trigger into our Agent Animations
script (script on Github):
"Land” trigger is added as a parameter to the player Animator. It is also added as a condition for the transition between EllenJumpGoesDown and EllenIdleLand animations.
Next we will modify our LandTransition
to be called FallLandTransition
and it will transition us first to the new LandState
(scripts on Github):
public class FallLandTransition : ITransitionRule
{
public Type NextState => typeof(LandState);
private GroundedDetector m_groundedDetector;
public FallLandTransition(GroundedDetector groundedDetector)
{
m_groundedDetector = groundedDetector;
}
public bool ShouldTransition(float deltaTime)
{
return m_groundedDetector.Grounded;
}
}
We now need a new transition to go from LandState
to MovementState
after the delay long enough to allow the Land Animation to be played. Here is our LandMovementTransition
script:
public class LandMovementTransition : ITransitionRule
{
//Land Animation length in seconds
private float m_landAnimationDelay = 0.533f;
public Type NextState => typeof(MovementState);
public bool ShouldTransition(float deltaTime)
{
if (m_landAnimationDelay <= 0)
{
return true;
}
m_landAnimationDelay -= deltaTime;
return false;
}
}
Lastly we need to modify our FactoryMethod
inside our Agent
script to include the LandState
and the new transitions (full script on Github):
public class Agent : MonoBehaviour
{
…
private State StateFactory(Type stateType)
{
State newState = null;
if (stateType == typeof(MovementState))
{
…
}
else if (stateType == typeof(FallState))
{
newState = new FallState(m_mover, m_agentAnimations, m_input);
newState.AddTransition(new FallLandTransition(m_groundDetector));
}
else if(stateType == typeof(LandState))
{
newState = new LandState(m_agentAnimations);
newState.AddTransition(new LandMovementTransition());
}
else
{
throw new Exception($"Type not handled {stateType}");
}
return newState;
}
}
…
We add a new else if line of code to handle the creation of our new LandState
.
We didn’t completely remove the need to modify the existing code but we are getting closer with binge able to just create new State and new Transition as separate objects. The modifications are limited to the StateFactory()
method. To create new objects instead we could try refactoring our code to an Abstract Factory pattern. You can read more about it here:
After introducing the LandState
there is no more sliding when we play the Land animation.
Now our avatar stops for the duration of the Land animation in place before moving again. There is no more sliding during the Land animation. It isn’t perfect but it served as a great example of how easy it is to introduce modifications to how our character behaves.
Console logs showing how our
Agent
object on the Player
GameObject transitions between different states.
If you have copied the code in the console you should see that a message is being printed the moment the state of our character changes.
One thing to point out is that because we are creating a new State
subclass and a new Transition we don’t have to worry about any other state - what it is doing. We can simply focus on the task at hand - here on how to make Land behavior work as we want it.
Adding jump input using ISP (interface segregation principle):
First, we need to add a new input for our jump mechanic. If you take a look at the diagram at the start of the article, you’ll see that we’ve extracted our movement input into the IAgentMovementInput
interface. The Agent
class references it to access this input.
Solution 1 - Modify the IAgentMovementInput
Modifying the IAgentMovementInput
can break the existing logic of the classes that already implement it - PlayerGameInput
and NPCAIInput
. According to the open-closed principle, we should extend our code rather than modify it. While some elements of our code, like the Agent
class, are expected to change, others, like the IAgentMovementInput
interface, are meant to represent a high-level, abstract idea of movement input and should remain stable.
If you are the only developer on the project, you have more freedom to introduce modifications and fix any resulting errors. However, the problem arises when you break classes that belong to other developers and can’t fix them yourself.
An alternative solution could instead look something like the following diagram.
Agent
script uses IAgentMovementInput
and IAgentJumpInput
. NPCAIInput
and PlayerGameInput
both implement the IAgentMovementInput
interface but only PlayerGameInput
implements the IAgentJumpInput
. CameraFollow
uses the PlayerGameInput
script.
Solution 2: Extending functionality with IAgentJumpInput
Another option is to create a new interface called IAgentJumpInput
and make the Agent
class use it. This way, we can handle situations where NPC characters might not have jump functionality. NPCs that do not need the jump input won’t be forced to implement it. This approach follows the Interface segregation principle:
Interface segregation principle: Clients should not be forced to depend upon interfaces that they don’t use. Learn more at Wikipedia.
The key point here is that unless we are certain all agents in our game will need to jump or perform other specific actions, we should create smaller, more focused interfaces. However, there is a trade-off: if we create too many small interfaces, we risk a class explosion with hundreds of interfaces.
Since we want to demonstrate a solution that avoids modifying existing code, we will apply Solution 2. Let’s create the IAgentJumpInput
interface (you can find this code on Github):
public interface IAgentJumpInput
{
public bool JumpInput;
}
Next we will extend the functionality of PlayerGameInput
by making it implement this interface:
public class PlayerGameInput : MonoBehaviour, IAgentMovementInput, IAgentJumpInput
{
private PlayerInput m_input;
public bool JumpInput;
…
}
private void OnEnable()
{
m_input.actions["Player/Jump"].performed += OnJump;
…
}
private void OnDisable()
{
m_input.actions["Player/Jump"].performed -= OnJump;
…
}
…
}
Lastly we need to connect it to our Agent
class:
public class Agent : MonoBehaviour
{
…
private IAgentMovementInput m_input;
private IAgentJumpInput m_jumpInput;
private void Awake()
{
…
m_input = GetComponent<IAgentMovementInput>();
m_jumpInput = GetComponent<IAgentJumpInput>();
}
}
Since we have implemented it onto the same object as our IAgentMovementInput
our Agent
GameObject already has this component and can access it using the GetComponent<>()
method.
Adding new game features vs open-closed principle
We have modified PlayerGameInput
by making it implement IAgentJumpInput
for jumping functionality. At first glance, this seems like a breach of the OCP, which encourages systems to be open for extension but closed for modification in a way that does not disrupt existing functionality. However, it’s important to understand that the OCP is a guiding principle rather than an absolute rule.
In practice, some parts of the codebase, such as interfaces and abstract classes (like IAgentJumpInput
), are designed to be stable and should not change. These stable components form the foundation of our system. On the other hand, classes like PlayerGameInput
are more volatile and are expected to evolve as new features are added. By integrating IAgentJumpInput
directly into PlayerGameInput
, we’re extending its capabilities to meet new feature requirements, which is a necessary part of the iterative process of designing our game.
Modifying PlayerGameInput
is risky because the CameraFollow
object relies on it, polling this object to get the CameraInput
vector. If we modify this property, it could break the CameraFollow
script. This suggests that we should use the dependency inversion principle (DIP) to make both scripts depend on some IPlayerCameraInput
abstraction. However, this is not a priority now, as we need to focus on implementing our jumping logic.
Adding Jump State
All we need is a new JumpState
class (script of Github):
public class JumpState : State
{
private AgentAnimations m_agentAnimations;
private IAgentMovementInput m_input;
private BasicCharacterControllerMover m_mover;
private float m_verticalVelocity;
private float m_jumpHeight = 1.2f;
private float m_gravity = -15.0f;
private float m_moveSpeed = 2.0f;
private float m_sprintSpeed = 5.335f;
public JumpState(BasicCharacterControllerMover mover, AgentAnimations agentAnimations, IAgentMovementInput input)
{
m_agentAnimations = agentAnimations;
m_input = input;
m_mover = mover;
}
public override void Enter()
{
m_verticalVelocity = Mathf.Sqrt(m_jumpHeight * -2f * m_gravity);
m_agentAnimations.SetTrigger(AnimationTriggerType.Jump);
m_agentAnimations.SetBool(AnimationBoolType.Grounded, false);
}
public override void Exit()
{
m_agentAnimations.ResetTrigger(AnimationTriggerType.Jump);
}
protected override void StateUpdate(float deltaTime)
{
float targetMovementSpeed = m_input.SprintInput ? m_sprintSpeed : m_moveSpeed;
targetMovementSpeed = m_input.MovementInput == Vector2.zero ? 0 : targetMovementSpeed;
m_verticalVelocity += m_gravity * Time.deltaTime;
m_mover.Move(
new Vector3(m_input.MovementInput.x, m_verticalVelocity, m_input.MovementInput.y), targetMovementSpeed);
}
}
In this class we are applying a positive Y velocity in Enter()
method and we are affecting the Animations system to ensure that a correct animation is being played.
In the Update()
method we apply gravity and move the character when in midair based on the input. If we don’t want it we can just modify the JumpState
to better suit our needs.
The downside is that we can end up with some code duplication that might need some more refactoring work. Here the part about being able to move while we are Jumping is similar to Movement and Fall state movement logic. Feel free to try to refactor this logic. You can see our approach to solving it in the FallState
, MovementState
and JumpState
implementations for article 3 on Github.
We have already added a Jump animation so let’s add a new JumpTransition
:
public class JumpTransition : ITransitionRule
{
public Type NextState => typeof(JumpState);
public float m_jumpTimeout = 0.20f;
private IAgentJumpInput m_jumpInput;
public JumpTransition(IAgentJumpInput jumpInput)
{
m_jumpInput = jumpInput;
}
public bool ShouldTransition(float deltaTime)
{
if (m_jumpTimeout <= 0 && m_jumpInput.JumpInput)
return true;
m_jumpTimeout -= deltaTime;
return false;
}
}
JumpTransition
will make use of our new IAgentJumpInput
. If the player has pressed the jump button and the initial delay has passed (it blocks the player from being able to jump immediately after landing) we will transition to the JumpState
.
Next we also need a way to transition from JumpState
to the FallState
by creating a JumpFallTransition
:
public class JumpFallTransition : ITransitionRule
{
public Type NextState => typeof(FallState);
private BasicCharacterControllerMover m_mover;
//Delay to let the JumpSate call Move() on the m_mover so that we
//don't immediately exit from Jump to Fall state
private float m_checkDelay = 0.2f;
public JumpFallTransition(BasicCharacterControllerMover mover)
{
m_mover = mover;
}
public bool ShouldTransition(float deltaTime)
{
if (m_checkDelay <= 0)
return m_mover.CurrentVelocity.y <= 0;
m_checkDelay -= deltaTime;
return false;
}
}
Here we want to let the Jump state call its Update()
method at least once to make the BasicCharacterControllerMover
set its Velocity on the Y axis. That is why we have a small delay before we are ready to check if velocity Y is less or equal to zero in which case we are starting to fall and want to transition to the FallState
.
Lets update our StateFactory()
so that it can handle the new states and so that we can transition to JumpState
from the MovementState
. Here is our Agent
script:
public class Agent : MonoBehaviour
{
…
private State m_currentState;
private IAgentJumpInput m_jumpInput;
…
private State StateFactory(Type stateType)
{
State newState = null;
if (stateType == typeof(MovementState))
{
newState = new MovementState(m_mover, m_groundDetector, m_agentAnimations, m_input);
newState.AddTransition(new GroundedFallTransition(m_groundDetector));
if (m_jumpInput != null)
newState.AddTransition(new JumpTransition(m_jumpInput));
}
else if (..)
…
}
else if (stateType == typeof(JumpState))
{
newState = new JumpState(m_mover, m_agentAnimations, m_input);
newState.AddTransition(new JumpFallTransition(m_mover));
}
else
{
throw new Exception($"Type not handled {stateType}");
}
return newState;
}
We had to add a new transition to the MovementState
, which required a bit of code modification. Additionally, we had to check for null as our NPC might not have the IAgentJumpInput
component. These are signs that we may want to refactor this logic further.
The benefit of this approach is that we can easily decide from which states we can transition to the JumpState
. This helps us avoid bugs where a character might unexpectedly start running midair due to flawed logic using boolean flags. By defining transitions between states explicitly, we ensure a more robust and maintainable state management system. In conclusion, adding new states and transitions has become quite straightforward with this setup.
Lets test see the jump by pressing space bar on our keyboard:
The result of adding JumpState
to our project.
Conclusion
In this article, we explored the use of the state pattern by adding a jumping mechanic to our existing movement system in Unity. Using the state pattern we were able to keep the code clean and easily maintainable, because we simply separated complex behaviors into manageable chunks.
We demonstrated how to define states and transitions, allowing us to encapsulate the behavior of our game characters in distinct states like Movement, Fall, and Jump. This approach not only keeps our code organized but also makes it extensible, as adding new states or transitions becomes straightforward and less error-prone.
We also leveraged the open-closed principle and interface segregation principle, by creating new interfaces, like IAgentJumpInput
, that helps us avoid breaking existing code while adding new functionalities.
By implementing the factory method pattern, we encapsulated the creation logic of our states, further improving the maintainability of our code. This pattern allows us to add new states with minimal modifications to our existing code, adhering to the principles of object-oriented design.
Stay tuned for the next article, launching after the holidays, where we will delve into creating a flexible interaction system using interfaces, further demonstrating the power of object-oriented design in game development.