Your classes (scripts) should not represent behaviours, but rather concepts.
Why?
When you learn Unity, you’ll find plenty of examples of classes called MoveToPlayer or Shoot.
The issue with this is that it frames the class as a method, when it should be a concept or an entity.
This is great for prototypes, but it’s not practical when designing a system, which you’ll need when making anything larger than a prototype.
Why, you might ask?
In a big project, so many of these behaviours could repeat for different entities under different systems with different variations in their logic. You’ll have to repeat some of this disconnected logic, you’ll make the game harder to debug, and you’re going to break organization conventions.
But most importantly, you’ll make it harder to decouple your code because it’s stateless.
Take a look at this example. This script moves an object towards a player if the player is close enough.
using UnityEngine;
public class MoveToPlayer : MonoBehaviour
{
public Transform player;
public float speed;
public float maxDistance;
void Update()
{
if (Vector3.Distance(player.position, transform.position) < maxDistance)
{
float step = speed * Time.deltaTime;
transform.position = Vector3.MoveTowards(transform.position, player.position, step);
}
}
}
Think of a situation where you now want to implement sound and animation.
You’ll have to do one of the following:
- Put the animation and sound code directly in this class, making it carry more responsibilities than what it was designed to do
- Replicate the same condition in another script and put the animation or sound code there instead, repeating your code and thus having to keep track of more things if you want to make changes
- Accessing this script somewhere else to determine if the object is moving towards the player, but then you’ll have to deduce the state of the object according to multiple similarly non-contextual behaviour scripts
So how do you write your features in this case?
Structure with states and events
Your object or enemy or whatever you’re writing is always in a specific state. This state is self-contained and worries only about its own logic. It doesn’t really communicate directly with any other system unless it depends on it, so it remains decoupled. However, it fires events, such as “I stopped walking!” informing other systems that are currently listening.
Generally you’d have a class that handles animation for this object, another that handles sound, and others that would respond to events fired from the current active state. The architecture for that might look like this:
In the example mentioned in the previous point, the behaviour where an enemy moves towards the player will happen in a state, and all the state has to do is invoke the action that the animation system is subscribed to, without even worrying about knowing what’s subscribed to that event.
OnStartedMovingTowardsPlayer?.Invoke();
This is especially helpful because being this decoupled means you can plug as many systems as you want to your classes without having to change them a lot of the time.
A very good example of this that you’ll see me repeating a lot in this book for a good reason is plugging an achievements system late in development. Let’s say an achievement is triggered when a player collects a certain item. If items fire an event when they get collected, you can easily plug that into your newly created achievements system.
public class Achievements : MonoBehaviour
{
//..More code here
private void Awake()
{
Item.OnCollectedItem += HandleItemCollected;
}
private void OnDestroy()
{
Item.OnCollectedItem -= HandleItemCollected;
}
private void HandleItemCollected(Item item)
{
if (item is SpecialItem)
{
UnlockSpecialItemsAchievement();
}
}
private void UnlockSpecialItemsAchievement()
{
//..Unlock achievement
}
}
The alternative would be your item class having to take care of a lot of changing features because your scope is likely growing. But not just your item class. Could be your player class, your weapon class, your enemy class, etc.
What you want to do here is very simple: isolate your features so you don’t have to change 20 scripts at once. This is at the core of scalability. Because changing lines of code could easily introduce new bugs to already working features.