I have released one game implemented with the Unity engine and have two more in the pipeline. Over time I've learned quite a bit, often by doing things the wrong way first.

1. Embrace component-based design and avoid Inheritance

When I first started working with Unity I was coming from an object oriented programming background. I was creating abstract classes to combine common methods. But I ran into some annoyances such as C#'s lack of multiple inheritance requiring strange workarounds.

C# is an object oriented language, but that doesn't mean you have to create complex hierarchies of classes.

If you stick to just the tools and design of Unity you'll quickly come across the concept of GameObjects, Components. (Note that Unity does use inheritance: MonoBehaviours are a type of Component!) But what is the relationship between GameObjects and Components?

A GameObject is essentially an object in your scene. They also have a Transform (or RectTransform) and therefore a position, rotation and scale. But otherwise GameObjects don't really have any life to them. They can't perform actions on their own, and they are invisible.

But then you attach Components (and MonoBehaviours) to a GameObject. Perhaps you add a MeshRenderer and MeshFilter so that you can see the GameObject. Then you add a Rigidbody so that it interacts with the physics system. And then you add a Collider so that it can bump into other GameObjects (well, other Colliders). And then you add your own AI MonoBehaviour so the object moves automatically.

Create small, focused components with well specified inputs and outputs. Each component should be focused on a single, primary purpose like facilitating player input or locomotion.

If you still have multiple classes that you want to use in a similar way, use C# interfaces.

This will enable you to more easily reuse components in other parts of your project, or in entirely different projects.

Try to avoid creating gigantic components thousands of lines of code that are difficult to design, debug, enhance, and test.

2. Cache GetComponent calls

Be careful how and where you use GetComponent:

public void Update() {
    GetComponent<Rigidbody>().MovePosition(Vector3.forward*Time.deltaTime);
}

The problem is GetComponent is rather slow and this will cause the garbage collector to run. Typically this issue is masked by using GetComponent inside of method that is called by Update.

There are a few ways to correct this.

Create a serialized field and manually connect the two components

There are two ways to make fields serialized (that is, Unity will save the object's configuration in the editor and use that configuration at runtime):

  1. Create a public field
  2. Create a private field, and use the [SerializeField] attribute

I typically prefer the second option since it's generally considered a good practice to not expose class variables to other classes (that way the owning class can control the whole lifecycle of the variable, such as whether or not it is ever null).

[SerializeField]
private RigidBody rb;

public void Update() {
	rb.MovePosition(Vector3.forward*Time.deltaTime);
}

But what if we forget to set "rb"? Then at runtime we will get a NullReferenceException in the Update loop! Also this won't work for components that are created at runtime.

Grab a reference to the component in Start and stash it in a variable

What if instead of manually setting the variable, we just grab it in the Start method when the object is instantiated?

private RigidBody rb;

public void Start() {
	rb=GetComponent<Rigidbody>();
}

public void Update() {
	rb.MovePosition(Vector3.forward*Time.deltaTime);
}

Now we don't have to manually set the variable, but the Rigidbody component must exist before Start is called.

Use a property to refer to the component, which grabs the component if it is null

This is my preferred way of dealing with this issue.

[SerializeField]
private Rigidbody _rigidbody;
public Rigidbody Rigidbody {
	get {
		if(_rigidbody==null)
			_rigidbody = GetComponent<Rigidbody>();
        return _rigidbody;
	}
}
                
public void Update() {
	Rigidbody.MovePosition(Vector3.forward*Time.deltaTime);
}

Now we can set the field in the Inspector, but even if we don't as long as the component exists when Update is called it will automatically get the Rigidbody and cache the result.

3. Use Coroutines

Coroutines are a powerful feature that are typically billed as a way to control animations or movement over several frames. But they can also be used to break up any long-running task over many frames to protect against framerate drops.

Here is a pattern I've used in a couple of my projects to queue up tasks that I want to spread across multiple frames:

//Using a List here or could use a proper Queue type instead
private List<System.Action> tasks = new List<System.Action>();
    
private Coroutine runningTaskQueue;

public void EnqueueTask(System.Action action) {
    tasks.add(action);
    
    if(runningTaskQueue==null) {
    	runningTaskQueue = StartCoroutine(TaskQueue(()=>{
    		runningTaskQueue=null;
    	});
    }
}
private IEnumerator TaskQueue(System.Action onDone) {
    while(tasks.Count>0) {
        var task = tasks[0];
    	tasks.RemoveAt(0);
    	if(task!=null) {
    		task();
    		yield return null;	// wait a single frame
    	}
    }
    // Automatically stop the coroutine when there are no more tasks in the queue
    if(onDone!=null)
    	onDone();
}

Now to use it:

public void RunSomeTasks() {
	//Pass a lambda
	EnqueueTask(()=>{
		Debug.Log("This gets called in one frame");
	});
	//Or pass a whole method
	EnqueueTask(ComplexMethod);
}

But one issue with this particular implementation is it will only execute one task per frame. What if we want to execute as many tasks as we can up to some time limit? Simply modify the TaskQueue method to use a timer:

private IEnumerator TaskQueue(System.Action onDone) {
    float maxRunTime = 2.0f/1000.0f; //run for about 2ms
    while(tasks.Count>0) {
        var start = Time.realTimeSinceStartup;

        while(Time.realTimeSinceStartup<start + maxRunTime 
           && tasks.Count>0) {
            var task = tasks[0];
            tasks.RemoveAt(0);
            if(task!=null) {
                task();
            }
        }
            
        yield return null;	// wait a single frame
    }
    if(onDone!=null)
    	onDone();
}

It's not guaranteed to keep total execution time under 2 milliseconds since you could have a task that runs too long, but its better than trying to run everything all at once.

Want to make this even more powerful? Add in methods for priority and canceling tasks. Or rather than passing around System.Action for tasks, use IEnumerators so that the tasks can be broken down more granularly.

4. Use UnityEngine.Events.UnityEvent

Unity Events are extremely similar to C# events. You can have methods subscribe to an event so that, whenever that event is triggered, your methods are called.

One significant difference between UnityEvent and C# events is that UnityEvent fields are exposed in the Inspector:

A lone UnityEvent waiting for subscribers

Events are useful if you have some action (a publisher) that you want to trigger other actions (listeners), but don't want to, or are unable to, code all the listeners directly within the publisher.

For example, say you have a "PropManager" class that manages interactions with all "prop" objects in your game:

public class PropManager : MonoBehaviour {
    public enum PropInteraction {
        PickedUp,
        Dropped,
        AddedToInventory,
        Consumed
    }
    [System.Serializable]
    public class PropEvent : UnityEngine.Events.UnityEvent<Prop, PropInteraction, Player> {}

    [SerializeField]
    private PropEvent propEvents = new PropEvent();
    public PropEvent PropEvents {
        get {
            return propEvents;
        }
    }

    public void PropPickup(Prop prop, Player player) {
        PropEvents.Invoke(prop, PropInteraction.PickedUp, player);
    }
}

Then you add a save file and any time props are manipulated you want to save them to the database.

public class SaveManager : MonoBehaviour {
	public void SaveProp(Prop prop, PropManager.PropInteraction propInteraction, Player player) {
		Debug.Log($"Prop {prop.name} was {propInteraction.ToString()} by {player.name}");
	}
}

Finally in the Unity Inspector, on your GameObject with the PropManager component, add a new entry for the PropEvents field and connect the SaveManager.SaveProp method to it.

5. Save your code in a version control system

There isn't much to say here that hasn't been said a thousand times. Use a version control system such as git, and start it early in your project (ideally before you even start your project).

Version control systems are often presented as a solution for teams to work on the same codebase, but they are also essential tools to individual developers.

One way I use git is to review all my changes at the end of a coding session to get a big picture view of the work I've done that day. I also use the change log to step through each file that has changed to do a mini self-code-review.

Conclusion

There are just my top 5.  Other things I've learned include use object pooling, use MonoBehaviour.Awake to deal with self and MonoBehaviour.Start to deal with other objects, and invest in good editor tools. Perhaps I will cover those another time.