Unity Best Practices

Making a game is very hard. There are a million sides to work on. However, it is possible to code in a certain way to avoid problems in the long run.

Warning: Consider this guide a starting point rather than a rulebook. These are practices I have found useful.
Warning: Most practices will increase development time and cost. Consider them long-term investments rather than immediate requirements.

Enable Nullables

Issue

When handling objects in Unity, it is possible to get a NullReferenceException in certain methods. This is could come from a bunch of sources:

  • Object getting destroyed at the wrong time
  • Components not existing in certain situations
  • Mods "breaking" your content

In short, it is not a good sign. It shows only the intended use case was considered.

Solution

A good solution is to allow nullables by default. This will let the IDE know that a certain field may be null, which would force to consider this case.

The easiest way of adding this is to add -nullable:enable to the csc.rsp file. Unity and the IDE should then notify you that certain fields may have a null reference.

// Before Nullables
HealthBar target;
target.SetHealth(10); // NullReferenceException if target is not assigned

// After Nullables
HealthBar? target;

if (target != null)
    target.SetHealth(10); // Safe

target?.SetHealth(10); // Short and Safe

A good habit is to mark every field as nullable by default, and only remove it when it's guaranteed the field will always be assigned.

Note: You need to create the file csc.rsp inside Assets. It should then recompile the project.

Condense Parameters

Issue

As a functionality grows, there might be a method that needs more and more parameters. It starts at 3 parameters, but it grows until it reaches 9. This makes the method less maintainable, and the problem will only grow more and more:

void InitV1(int health, int maxHealth, int speed)
{
    // ...
}

// Several days after
void InitV2(int health, int maxHealth, int speed, int defense, int shield, int maxSlugCount)
{
    // ...
}

Solution

When a method starts to have too many parameters, you can put them into a class. The class (or struct) whose only job is to hold data and process it if needed.

struct PlayerStats
{
    int health;
    int maxHealth;
    int speed;
}

void InitV1(PlayerStats stats)
{
    // ...
}

// Several days after
struct PlayerStats
{
    int health;
    int maxHealth;
    int speed;
    int defense;
    int shield;
    int maxSlugCount;
}

void InitV2(PlayerStats stats)
{
    // ...
}

The signature stays the same, even if more properties are added. This allows to not bleed the requirements onto every method that touch it. They also only need to receive a PlayerStats to pass it along.

Warning: This solution should only be considered when a method has more than 4 or 5 parameters. Below that, this will be overkill.

It is also possible to have utility methods in the data. Using the example above, it is maybe interesting to know how much total health the player has, combining the health and the shields.

struct PlayerStats
{
    int maxHealth;
    int shield;

    int GetTotalHealth()
    {
        return maxHealth + shield;
    }
}

Drop MonoBehaviour (when possible)

Issue

A habit I see often when making games is to make everything a MonoBehaviour. This stems from developers thinking this is needed to interact with the game. However, it is untrue. Making everything a MonoBehaviour has a few downsides:

  • Creates a worse scene hierarchy, littering it with unnecessary objects
  • Makes heavier instances
  • Locks the class into a single ecosystem

Solution

Consider using plain C# classes instead. Not everything needs to be a component in the world. For example, object pools could be done without being present in the world. They simply manage ìnstances of IDisposable, not even knowing if they are objects, other classes or something else.

A good rule of thumb is to ask the following question: Can the class do its work without Unity's lifecycle methods? If so, it might not need to be a MonoBehaviour.

Another good example are Behaviour Trees. In essence, they are just a bunch of classes linked together. Let's see how it would work with both methods:

// === Using MonoBehaviour ===
class Tree : MonoBehaviour
{
    // ...
}

class Node : MonoBehaviour
{
    // ...
}

// Hierarchy:
// |- tree
// |  |- Node1
// |  |- Node2

// === Using Plain C# classes ===
class TreePlayer : MonoBehaviour
{
    // ...
}

class Tree
{
    // ...
}

class Node
{
    // ...
}


// Hierarchy:
// |- tree player

This makes the code more portable. With the example of the trees, it would be possible to extract the framework into a basic .dll file.

Learn more: A good real-life example is this, where the developers switched engine with little disturbances, because of using plain classes.