Tips for writing cleaner code that scales in Unity Part 2: Adding a jump mechanic using the state pattern

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 (like Enter, Exit, and Update 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 a Type parameter. This hides the instantiation details of each state class from the client (the rest of your application), which only needs to know about the IState 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 a State object and assign its own method to the OnTransition 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 our TransitionToState() 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 a NullReferenceException.

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, a NullReferenceException 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:

image10
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.
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:

image8
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.

1 Like