Tips for writing cleaner code in Unity that scales: A five-part series
Welcome to the first article in a new five-part series on creating cleaner code for your Unity projects. Our aim with this series is to provide you with guidance on how to apply general object-oriented programming principles to make your code more readable, well-organized, and maintainable.
We teamed up with Peter from Sunny Valley Studio, who’s created a long list of great YouTube tutorials and courses on the topic. Peter helped to write the articles in the series and create a project you can follow along with.
Over the course of the five articles in the series, we’ll expand the Unity Starter Assets - Third Person Character Controller package, which provides a basic player character controller and prototyping assets, with a number of new features to illustrate how to apply SOLID principles and design patterns to create scalable and robust game mechanics.
Let’s dive in!
Assets from the Unity Starter Assets - Third Person Character Controller package, available on the Unity Asset Store.
The goal we explore in this first article is expanding the functionality of the Character Controller package by adding an AI-driven NPC. We’ll demonstrate how to refactor the existing monolithic player script, using object-oriented principles and design patterns, into reusable parts so that we can add this new feature into our game.
If you want to follow along in the project, download the free Starter Assets - Third Person Character Controller package from the Unity Asset Store.
You can also follow along by downloading the modified starter project we are using for this article series from Github. You will need to work in version Unity 6 or later. Download the repository, open the Unity Hub, click on the Project tab and open a new project. When the Unity project is loaded go to _Scripts → Article 1 → Start and open the scene titled _Article 1 Starter Project.
Adding an AI controlled NPC to the game
With the basic package installed we are currently limited to just a character controller:
All we can do at this point is to move our character around the environment using the keyboard as input. However, let’s make it more interesting by adding an AI NPC.
Adding an NPC to the Starter Assets package
The PlayerMonolithic.cs
script looks like this (the full version of the script can be found on Github):
public class PlayerMonolithic : MonoBehaviour
{
[Header("Movement Parameters")]
public float MoveSpeed = 2.0f;
…
private CharacterController m_controller;
private PlayerGameInput m_input;
…
[Header("Grounded Check")]
public float Gravity = -15.0f;
…
[Header("Camera")]
public GameObject CinemachineCameraTarget;
…
private GameObject mm_mainCamera;
…
[Header("Animations")]
public string AnimationSpeedFloat;
…
private Animator m_animator;
…
//Input
private Vector2 m_movementInput, m_lookInput;
private bool m_isSprintingInput;
private void Awake()
{
if (m_mainCamera == null)
{
m_mainCamera = GameObject.FindGameObjectWithTag("MainCamera");
}
m_animator = GetComponent<Animator>();
… gets references to ALL the components - making the code very rigid
}
private void OnEnable()
{
m_input.actions["Player/Move"].performed += OnMove;
m_input.actions["Player/Move"].canceled += OnMove;
.. connects to the New Input system which makes this script PLAYER specific
}
private void OnDisable()
{
m_input.actions["Player/Move"].performed -= OnMove;
m_input.actions["Player/Move"].canceled -= OnMove;
.. DISCONNECTS from the New Input system
}
private void Update()
{
… movement / animations code
}
etc…
}
The main problem with our code so far is that our PlayerMonolithic.cs
script has too many responsibilities. It handles:
- Getting player input
- Moving the character using CharacterController
- Making the camera follow the player
- Controlling the Animator
- Controlling the flow of information, e.g., how input connects to the movement, animations, logic, etc
Another challenge is the code for both the Player and the NPC will do largely the same thing:
The responsibilities between a humanoid player character and NPC are overlapping in several ways.
At the same time we can’t easily reuse those already-implemented features because our PlayerMonolithic script is too tightly connected with the main camera and inputs system. At the same time, duplicating the same logic in another script in the long run means making changes in at least two places whenever we need to modify or improve the movement logic. That means our first step is to refactor our existing code so that it’s reusable.
Refactoring: Your code needs to evolve
Object-oriented design is inherently iterative. We often need to refactor previously written code to make it more reusable and maintainable while ensuring it continues to function as before. In our case, refactoring the PlayerMonolithic script will allow us to introduce one or more AI-driven characters.
The single-responsibility principle (SRP) is one of the principles encapsulated by the SOLID acronym (it’s the S in SOLID). It emphasizes that a class should have only one reason to change, promoting modular, maintainable, and easily extensible code. By adhering to the SRP, developers can enhance code readability, reduce complexity, and improve overall software design quality.
Single-responsibility principle: Each software module should have one and only one reason to change
You can read more about SOLID principles in the Unity e-book Level up your code with design patterns and SOLID.
One reason to change our PlayerMonolithic script is how it gets the Input. To make the code more reusable we need to disconnect the PlayerMonolithic script from the logic that reads the input from the mouse and keyboard.
AgentMonolithic
uses PlayerGameInput
. It is represented by a line with an arrow.
In the above diagram the responsibility of getting the input has been moved to the PlayerGameInput script and the PlayerMonolithic will use this object. So the only “reason to change” of the PlayerGameInput script is about getting the input from the Player. Here is how we could create this new script (Github):
public class PlayerGameInput : MonoBehaviour
{
private PlayerGameInput m_input;
public Vector2 MovementInput { get; private set; }
public Vector2 CameraInput { get; private set; }
public bool SprintInput { get; private set; }
private void Awake()
{
m_input = GetComponent<PlayerGameInput>();
}
private void OnEnable()
{
m_input.actions["Player/Move"].performed += OnMove;
…
}
private void OnDisable()
{
m_input.actions["Player/Move"].performed -= OnMove;
…
}
private void OnLook(InputAction.CallbackContext context)
{
CameraInput = context.ReadValue<Vector2>();
}
private void OnMove(InputAction.CallbackContext context)
{
MovementInput = context.ReadValue<Vector2>();
}
private void OnInput(InputAction.CallbackContext context)
{
SprintInput = context.ReadValueAsButton();
}
}
However, there are still a few things we can do. By the end of this article we will end up with this class design that will turn a monolithic class into a reusable AgentMonolithic
script and which will look something like this:
CameraFollow
uses the PlayerGameInput
as does the AgentMonolithic
the IAgentMovementInpit
. PlayerGameInput
and NpcAIInput
implements the IAgentMovementInput
interface.
For now, here is the updated PlayerMonolithic
script that uses our new PlayerGameInput
script:
public class PlayerMonolithic : MonoBehaviour
{
[Header("Movement Parameters")]
public float MoveSpeed = 2.0f;
…
private CharacterController m_controller;
private PlayerGameInput m_input;
//.. Other parameters
private void Awake()
{
// get a reference to our main camera
if (m_mainCamera == null)
{
m_mainCamera = GameObject.FindGameObjectWithTag("MainCamera");
}
m_animator = GetComponent<Animator>();
m_controller = GetComponent<CharacterController>();
m_input = GetComponent<PlayerGameInput>();
}
//remaining code just uses m_input.MovementInput and m_input.SprintInput
}
The next responsibility limited to just the Player Character is the logic that controls the camera rotation. It will rely on the PlayerGameInput but not really on the movement or animation logic. To adhere to the SRP we should separate that as well:
CameraFollow
and AgentMonolithic
use PlayerGameInput
in their own code.
This is what the the new CameraFollow
script will look like (full script on Github):
public class CameraFollow : MonoBehaviour
{
[Header("Camera")]
public GameObject CinemachineCameraTarget;
private GameObject m_mainCamera;
…
PlayerGameInput m_input;
private void Awake()
{
// get a reference to our main camera
if (m_mainCamera == null)
{
m_mainCamera = GameObject.FindGameObjectWithTag("MainCamera");
}
m_input = GetComponent<PlayerGameInput>();
}
private void LateUpdate()
{
CameraRotation();
}
private void CameraRotation()
{
…Rotate the camera with the m_input.CameraInput…
}
…
}
Now the rotation logic for the camera is separate from the PlayerMonolithic
script. We just need to add the CameraFollow
script onto our Player GameObject to make the system work as before:
Our character will now have an additional three scripts each responsible for their own role.
Encapsulation
You might have spotted that in our implementation of the PlayerGameInput
the only thing that we have set as public were properties called:
public class PlayerGameInput : MonoBehaviour
{
private PlayerGameInput m_input;
public Vector2 MovementInput { get; private set; }
public Vector2 CameraInput { get; private set; }
public bool SprintInput { get; private set; }
…
}
While all the methods of this class are private:
public class PlayerGameInput : MonoBehaviour
{
…
private void Awake(){...}
private void OnEnable(){...}
private void OnDisable(){...}
private void OnLook(InputAction.CallbackContext context){...}
private void OnMove(InputAction.CallbackContext context){...}
private void OnInput(InputAction.CallbackContext context){...}
}
Encapsulation is bundling data with the methods that control it, kind of like a capsule for medicine. Imagine a toaster – you push a lever (function) and it toasts your bread (data), but you don’t need to know how the heating elements work.
The encapsulation concept applies to our PlayerGameInput
. PlayerMonolithic
doesn’t need to know how the keyboard input is processed. The input might also be coming from a game controller or another device. It only needs to know the resulting Vector2 for the movement and a bool value in case we want to sprint or not.
What is a property in C#?
Properties in C# are a way to create controlled access points for your class’s data. They act like public variables, but behind the scenes, they are special methods that can control how data is read from or written to. This lets you enforce data security (encapsulation) while still providing a user-friendly way to interact with your class.
Read more about properties in C#.
Why not just use public fields?
We have used properties to represent MovementInput
, CameraInput
, and SprintInput
in PlayerGameInput
, because they offer more than just data storage – they provide a layer of protection for your game’s logic. For instance, if the CameraFollow
object modified MovementInput
in every frame, while it was also being updated from player controls, you’d get a conflict that could lead to unpredictable character movement. Tracking down such a bug, where data is modified from unexpected places, could turn into a real headache.
Properties act like gatekeepers, regulating access to your game’s data. They ensure that only intended modifications happen so that our game mechanics work as intended. This control is not possible with public fields, which can be changed from anywhere in your codebase, increasing the risk of bugs.
What about CameraInput?
CameraInput
is used by the CameraFollow
script. PlayerMonolithic
doesn’t need it and the CameraFollow
script doesn’t need to access the other two properties. This is a small issue that we don’t need to worry about yet.
To summarize, encapsulation protects us from a situation where changes to how the PlayerGameInput
works internally (like fixing a bug) would affect any other script that accesses it. Since PlayerMonolithic
and CameraFollow
only expect to get the values from the properties they will be unaffected by the changes inside the PlayerGameInput
script (unless we modify the properties that they require to work).
DIP (dependency inversion principle)
The remaining problem with our logic is that our PlayerMonolithic
has the name “player” and that is because it still depends on the PlayerGameInput
.
PlayerMonolithic
still depends on the PlayerGameinput
.
We still have no easy way to swap it for the AI NPC input. But there is one solution: The dependency inversion principle.
Dependency inversion principle: High-level modules should not depend on low-level modules. Both should depend on abstractions. Learn more at Wikipedia.
In our setup the high-level module is called PlayerMonolithic
and it depends on a low-level module named PlayerGameInput
. Why do we call it low-level? Because it is very specific. It knows how to get the input for the Player. PlayerMonolithic
is right now a mix of high-level and low-level logic but we aim to make it into something reusable that can work for both Player and NPC agents. Right now the fact that it depends on the PlayerGameInput
is the problem that we need to solve.
What is abstraction?
Abstraction in software development is the process of hiding the complex reality while exposing only the necessary parts. For example, in our case the idea of input is to know the direction of movement so some Vector2 value represents left, right, up, and down input. It doesn’t matter if it is provided by an online server, the keyboard or AI code.
How can we introduce this concept of abstraction to our code? We’ll introduce an abstract concept of the movement input called IAgentMovementInput
, which will be an interface:
public interface IAgentMovementInput
{
public Vector2 MovementInput { get; }
public bool SprintInput { get; }
}
We have just extracted the properties needed by the PlayerMonolithic
to a separate interface definition, PlayerGameInput
:
public class PlayerGameInput : MonoBehaviour
{
public Vector2 MovementInput { get; }
public bool SprintInput { get; }
Vector2 CameraInput { get; private set; }
}
Notice that PlayerMonolithic
doesn’t need Vector2 CameraInput so we do not include it in our interface.
What is an interface?
An interface is a contract or a blueprint for a class. It specifies a set of methods or properties that any class implementing the interface agrees to expose. However, an interface does not contain any actual implementation of these methods; it only defines the method signatures. This allows different classes to implement the interface in their own way, providing the flexibility to have different behaviors while guaranteeing that certain methods are available. In essence, an interface defines the “what” (as in what methods or properties are required), but not the “how” (as in how these methods perform their tasks).
For a deeper dive into interfaces and their use in software development, you can refer to the following external resources: Understanding Interfaces in C# or Interfaces at Unity Learn
Abstraction can also be achieved by creating an abstract class. In the second article we show some practical examples of how they are implemented.
We have now created an abstract definition of an input that enables us to use polymorphism, which is a core concept of OOP, to easily extend our codebase with an AIInput
class. This way we can make both the PlayerGameInput
and the PlayerMonolithic
rely on this abstraction:
At the end of this example, we want to have our
AgentMonolithic
script (previously PlayerMonolithic
) depend on IAgentMovementInput
interface and the CameraFollow
script to depend on the PlayerGameInput
(both depicted by a hollow diamond shape and a solid line).
The only thing that changes in our PlayerGameInput.cs
is the statement that it implements the IAgentMovementInput
:
public class PlayerGameInput : MonoBehaviour, IAgentMovementInput
{
public Vector2 MovementInput { get; private set; }
public bool SprintInput { get; private set; }
public Vector2 CameraInput { get; private set; }
…
}
Our PlayerMonolithic
can now be renamed to AgentMonolithic
because it now relies on an abstract form of input so it isn’t specific to the concept of the Player. It still has too many reasons to change but we will tackle that problem in article two of this series. Right now we are getting closer to being able to create our AI NPC agent. Here is how our AgentMonolithic
script looks (you can find full scripts on Github):
public class AgentMonolithic : MonoBehaviour
{
…
private IAgentMovementInput m_input;
private void Awake()
{
…
m_input = GetComponent<IAgentMovementInput>();
}
// method from this script will now be using m_input to get access to the input values
}
Is renaming part of the refactoring?
Yes, enhancing the clarity of our code is a crucial step in the process of improving its quality. Robert C. Martin, commonly known as “Uncle Bob” and the co-creator of the SOLID principles, emphasizes the significance of naming in his book, Clean Code: A Handbook of Agile Software Craftsmanship. He argues that good naming is vital because developers spend more time reading code than writing it. Clear, meaningful names in the context of your project can save considerable time when implementing new features.
In our case, renaming
PlayerMonolithic
toAgentMonolithic
was a deliberate choice to reflect the class’s broader applicability. This new name makes it clear that the class is suitable for all agents in the game, not just the player. This is a prime example of how thoughtful naming can enhance code readability and maintainability.Learn more about the process of refactoring at Wikipedia.
How much refactoring is too much?
What about the CameraFollow
script and CameraInput
property? Doesn’t it need to rely on abstraction as well?
It would be best if it did but there is always a balance between the need to refactor our code and the need to make progress on actually adding new functionality. We don’t need this additional refactoring in order to add an AI Agent to our game. Actually the only certain thing about our code is that it will need to evolve the moment we get a new feature request. For all we know the new requirement might be to change the way the Camera reacts to the player and we might need to delete the current CameraFollow
class anyhow.
For now we can leave the CameraFollow
to keep depending on the PlayerGameInput
script.
Strategy pattern
We have missed one dependency of the AgentMonolithic
on the Main Camera rotation due to this being a Third Person Controller camera (full script on Github):
public class AgentMonolithic : MonoBehaviour
{
//other properties …
private GameObject m_mainCamera;
private float m_targetRotation = 0.0f;
private void Update()
{
//CHeck if we are grounded…
RotationCalculation();
Vector3 targetDirection = Quaternion.Euler(0.0f, m_targetRotation, 0.0f) * Vector3.forward;
//move the character controller
m_controller.Move(targetDirection.normalized * (m_speed * Time.deltaTime) + new Vector3(0.0f, m_verticalVelocity, 0.0f) * Time.deltaTime);
…
}
private void RotationCalculation()
{
…
m_targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg + m_mainCamera.transform.eulerAngles.y;
float rotation = Mathf.SmoothDampAngle(transform.eulerAngles.y, m_targetRotation, ref m_rotationVelocity, RotationSmoothTime);
…
}
}
It seems that the RotationCalculation()
method depends on the Main Camera. Can we really refactor this code to make it reusable without rewriting the whole rotation logic?
The simplest solution would be to add somewhere an if statement and a bool flag:
if (isPlayer)
{
// Player rotation logic
m_targetRotation = Mathf.Atan2(inputDirection.x,
inputDirection.z) * Mathf.Rad2Deg +
m_mainCamera.transform.eulerAngles.y;
}
else
{
// NPC rotation logic
m_targetRotation = Mathf.Atan2(inputDirection.x,
inputDirection.z) * Mathf.Rad2Deg;
}
While simple, this solution requires us to add the isPlayer
bool flag. If you ever have worked with those in the long run, bool flags are what can make simple code very complex when we need to control a lot of them.
There is a more maintainable solution that we can use, the strategy pattern:
AgentMonolithic
script depends on AgentRotationStrategy
abstract class (depicted by a hollow diamond and a solid line). Specific strategies at the bottom of the diagram inherit from AgentRotationStrategy
(depicted by a solid line pointing from those classes to the abstract AgentRotationStrategy
class).
The idea behind the strategy pattern is that we define an AgentRotationStrategy
as an abstract concept. The fact that we are using an abstract class to define the AgentRotationStrategy
is merely an implementation detail in this example, chosen to avoid code duplication. The crucial aspect is the application of the dependency inversion principle (DIP) to decouple the high-level class from specific implementations. Whether you use an abstract class or an interface, the key takeaway is the separation of concerns, ensuring that the high-level class depends on abstractions rather than concrete implementations.
The AgentMonolithic
script will now use an object of type AgentRotationStrategy
and call it to calculate the rotation that should be applied to our Agent. Since AgentMonolithic
depends on an abstract concept of rotation strategy we can easily add more strategies and AgentMonolithic
can use them without any code modification.
You can read more about the strategy pattern at Wikipedia and in our design patterns e-book.
How is it different from SPR and DIP?
At its core, the strategy pattern embodies the principles of SRP and DIP, showing how these abstract principles can be applied in a reusable and understandable way to improve your code.
By using the strategy pattern, we apply SRP by encapsulating the rotation logic into separate classes, thereby minimizing the impact of changes to this logic on other parts of the system.
Similarly, the strategy pattern implements DIP by making the AgentMonolithic
rely on the abstract concept of an AgentRotationStrategy
rather than on specific rotation implementations.
Here is how AgentRotationStrategy
would look like in the code (link to full scripts on Github):
public abstract class AgentRoatationStrategy : MonoBehaviour
{
public float RotationCalculation(Vector2 movementInput, Transform agentTransform, ref float rotationVelocity, float RotationSmoothTime, float targetRotation)
{
if (movementInput != Vector2.zero)
{
targetRotation = RotationStrategy(movementInput);
float rotation = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetRotation, ref rotationVelocity,
RotationSmoothTime);
// rotate to face input direction relative to camera position
agentTransform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f);
}
return targetRotation;
}
protected abstract float RotationStrategy(Vector2 movementInput);
}
The RotationCalculation()
method defines the logic that can be reused by all rotation strategies, like the smoothing by using Mathf.SmoothDampAngle()
. It is also the method that AgentMonoltithic
will have access to. The RotationStrategy()
method is what each separate Strategy will define.
public class PlayerRotationStrategy : AgentRoatationStrategy
{
[SerializeField]
private GameObject m_mainCamera;
private void Awake()
{
// get a reference to our main camera
}
protected override float RotationStrategy(Vector2 movementInput)
=> Mathf.Atan2(movementInput.x, movementInput.y) * Mathf.Rad2Deg + m_mainCamera.transform.eulerAngles.y;
}
public class NPCRotationStrategy : AgentRoatationStrategy
{
protected override float RotationStrategy(Vector2 movementInput)
=> Mathf.Atan2(movementInput.x, movementInput.y) * Mathf.Rad2Deg;
}
The PlayerRotationStrategy
needs to access the Main Camera while the NPCRotationStrategy
uses the movement direction as the direction to rotate towards.
And now we would just add the correct strategy to our Agent object and in the AgentMonolithic
script we would call:
public class AgentMonolithic : MonoBehaviour
{
//Other parameters…
//private GameObject m_mainCamera; <- we don’t need the camera anymore
[SerializeField]
private AgentRoatationStrategy m_rotationStrategy;
private void Awake()
{
if (m_rotationStrategy == null)
{
m_rotationStrategy = GetComponent<AgentRoatationStrategy>();
}
…
}
private void Update()
{
//Grounded check and movement logic…
m_targetRotation = m_rotationStrategy.RotationCalculation(m_input.MovementInput,transform, ref m_rotationVelocity, RotationSmoothTime, m_targetRotation)
Vector3 targetDirection = Quaternion.Euler(0.0f, m_targetRotation, 0.0f) * Vector3.forward;
…
}
This way we have separated the rotation logic from the AgentMonolithic
class and in this way we have made it possible to reuse this script to implement an AI NPC agent.
Our Player GameObject has now 4 separate components -
AgentMonolitic
, PlayerGameInput
, CameraFollow
and PlayerRotationStrategy
- responsible for making it move.
The question that we always have to ask ourselves is “Do I really need it?” We have only two rotation strategies so maybe we should have used a simple if-else solution which would take much less time to implement. The strategy pattern-based solution is more maintainable if we need to add other rotation strategies. It also gave us a chance to see how SOLID principles and the design patterns are connected.
Open-closed principle (OCP)
Now we are finally ready to create our AI agent. To do that all we need is to create a new script that will implement the IAgentMovementInput
interface called NPCAIInput
:
Our
AgentMonolithic
uses IAgentMovementInput
and AgentRotationStrategy
. NPCAIInput
and PlayerGameInput
implements the IAgentMovementInput
. PlayerRotationStrategy
and NPCRotationStrategy
inherits from the abstract AgentRotationStrategy
class. CameraFollow
uses PlayerGameInput
.
Here is the implementation of the NPCAIInput
class (you can find the full script on Github):
public class NPCAIInput : MonoBehaviour, IAgentMovementInput
{
//NPC will follow a predefined path
[SerializeField]
private Transform[] m_waypoints;
[SerializeField, Min(0.5f)]
private float m_distanceThreshold = 1.0f;
private int m_currentWaypointIndex = 0;
public Vector2 MovementInput { get; private set; }
//Sprint is not used by the NPC in my logic
public bool SprintInput { get; private set; }
private void Update()
{
if (m_waypoints.Length <= 0)
return;
FindClosestWaypoint();
Vector3 movementDirection = FindDirectionToTheNextWaypoint();
MovementInput
= new Vector2(movementDirection.x,movementDirection.z);
}
private Vector3 FindDirectionToTheNextWaypoint()
{
…
}
private void FindClosestWaypoint()
{
…
}
}
This is a simple implementation of an AI agent that follows a path specified in the m_waypoints array of Transform objects.
It might be interesting to ask: Does the NPCAIInput.cs
conflict with SRP?
NPCAIInput
combines the responsibility of pathfinding (following the path waypoints) with calculating the movement direction based on that path. This can make it difficult to reuse the code for different AI agents. We can however refactor this script when we see the need for it.
Let’s now add a second character to our scene and add to it our scripts:
AgentMonolithic
NPCAIInput
NPCRotationStrategy
The NPC GameObject reuses
AgentMonolithic
component and has NPCAIInput
that contains a list of waypoints and NPCRotationStrategy
component.
The path for the NPC Agent to follow by placing empty GameObjects in the scene acts as waypoints:
Map of waypoints defining a path that our NPC agent will follow
And here is the result of reusing our movement logic to move both the player and the NPC:
The result of our efforts of adding an AI driven character into our project
We have made our code adhere to the open-closed principle:
Open-closed principle: Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. Learn more at Wikipedia.
By creating the IAgentMovementInput
interface and refactoring the PlayerMonolithic
script to AgentMonolithic
– which is disconnected from any logic specific to how the Player moves (or how the camera follows the Player) – we have opened our code for extension. At the same time if we want to add a new AI Agent type we don’t have to modify anything in the AgentMonolithic
script. All we need to do is to create a new class that implements the IAgentMovementInput
interface.
The result of refactoring
The key takeaway is that adding new NPC types now doesn’t require altering established scripts – just add new classes that adhere to our established interfaces.
It’s also important to note that adhering to the OCP often requires foundational work with other principles like the SRP and the DIP. Understanding and applying OCP involves embracing a broader design philosophy. There aren’t specific “actionable” steps to apply it directly, which is why studying design patterns can be a great investment in becoming a better programmer. Design patterns provide tried-and-tested solutions that can help your code adhere to OCP and other SOLID principles.
Homework Assignment
If you want to practice refactoring game code using the SRP and DIP, get the full project at Github and try refactoring the movement logic, ground detection logic and animation logic out of the Agent Monolithic class.
Here is a class diagram showing to give you an idea of how it should look like after refactoring:
Agent class uses
BasicCharacterControllerMover
, AgentAnimations
and GroundDetector
classes; BasicCharacterControllerMover
uses the AgentRotationStrategy
.
There is no single correct solution for this task so treat this diagram as an example of separating Movement, Ground checking and Animation logic into separate objects. This should allow us to actually rename our AgentMonolithic
script to just “Agent” since it is now mostly controlling the flow of information between the components that it uses. It will still have too much knowledge about the implementation details but we will change that in Article 2.
To compare your solution with ours visit Github or inside the downloaded project look for _Scripts → Article 2 folder.
Conclusion
In this article, we demonstrated how to break down a monolithic player script into several smaller scripts and thus a more modular, maintainable codebase by leveraging object-oriented principles and design patterns.
Through the application of the single responsibility principle (SRP), we refactored the code to separate concerns, isolating input handling, camera control, and movement logic into distinct components. This separation not only made our code more readable and maintainable but also prepared it for easy extension.
We introduced the dependency inversion principle (DIP) by abstracting input handling into an interface, IAgentMovementInput
, allowing us to create different input sources, such as player input and AI input, without modifying the core movement logic. This abstraction enabled us to rename the monolithic script to AgentMonolithic
, reflecting its broader applicability.
To further decouple the rotation logic, we employed the strategy pattern, encapsulating different rotation strategies in separate classes. This approach allowed us to handle player-specific and NPC-specific rotation logic without cluttering the agent class.
We then added an NPC that uses the same movement logic as the player, demonstrating the power of adhering to SOLID principles and design patterns in creating scalable and maintainable code.
The key takeaway from this article is the importance of modularity and abstraction in software design. By breaking down complex scripts into smaller, focused components, we not only make our code easier to understand and maintain but also prepare it for future extensions with minimal modifications.
Stay tuned for the next article, where we will further enhance our game by adding a jumping mechanic using the state pattern, continuing our journey towards cleaner, scalable code.