In addition to turning reams of design documentation and gorgeous art assets into an actual playable game, one of the goals of a video game developer should be to ensure that their code is efficient and maintainable. With these end goals in mind, it is important to start the coding process with certain principles in mind.
This guide presents the strategies and ideas that underpin our approach to coding. While each game is unique, these tactics can be applied to many different situations to ensure that your project is manageable and won't become a nightmare if you need to refactor or debug your code (which is always).
Please note that we use the Unity platform and that these guidelines assume that a dependency injection framework for Unity, such as Zenject, is being used.
Follow the S.O.L.I.D principles, but do not over-engineer. In this context, over-engineering refers to making too elaborate of a solution than is needed to obtain an effective level of functionality and flexibility. The best designs follow the principles while producing code that is easy to grasp on a conceptual level and follow.
One of the goals of this document is to address common cases where over-engineering may occur, resulting in lesser maintainability despite the S.O.L.I.D principles being met.
When implementing systems, all major ‘events’ within each system should have Signals declared for them. For example, in a game where you defeat enemies and obtain points (from a variety of sources), there should be signals declared for when enemies are defeated, and when points have been obtained. Gameplay modules will listen to these events and act on them. This minimizes coupling.
Be careful, as this can easily lead to over-engineering and code that is difficult to follow - this is addressed in the system order of operations section of this document.
Signal Parameter Conventions
Signals should be defined to use a single parameter that follows the naming convention of <NameOfSignalClass>Data. For example, if a signal is named PointsAwardedSignal, it would have a parameter of PointsAwardedSignalData. This is so the method signatures of all listeners to that signal will not need to be updated should a developer ever need to modify the data that is passed through that signal.
Signal Declaration Conventions
Document a signal at the point of its declaration. When documenting a signal, make sure to describe all the circumstances in which the signal is called, whether its signal data parameter (and any of its members) can ever be null, and any other information relevant to making modules that will listen to that signal.
System Order of Operations and Maintainability
Build gameplay modules such that a strict order of operations between them is not mandatory for correct functionality. The more you have seemingly independent systems with an essential order of operations between them, the more fragile the codebase is to any change which modifies the order-dependent system code. However, sometimes a strict order of operations among the systems is necessary for gameplay reasons.
Wherever order of operations is of high importance to gameplay functionality, have a single class handle each step of the particular aspect of gameplay in succession, rather than having multiple listeners acting on the same signal simultaneously, in which case the order of listener assignment would be significant. This class will have modules (as interfaces / abstract classes) to handle each step of the particular aspect of gameplay as dependencies.
The other approach of having many important listeners who depend on a specific order of operations among themselves upon a signal call is prone to error, tedious to manage, and causes difficulty in tracking the flow of control.
As a general rule of thumb, if your design involves maintaining the execution order of listeners, that indicates this maintainability problem which can be solved by the single-class, multiple-dependency approach. The cost is a bit more boilerplate code in writing the class which handles the order of operations and writing the interfaces for each type of module it depends on.
If you want to implement functionality that can work as a standalone module (not as a dependency of a higher-level class), use individual classes that listen to one or more signals and act on them. Since the order of operations is not important for these modules, the functionality they provide can be disabled or modified without compromising the stability of other modules. Generic examples include classes for playing sound effects for system events and triggered asynchronous auto-saving.
Project Scene Configuration
Scene hierarchies can become maintainability problems long term, filled with many objects of ambiguous importance. Whenever you add an object that needs to stay in a scene’s default hierarchy, add a comment component to it. The comment should describe:
- Whether the object must be present in the hierarchy by default for the game to function correctly.
- Whether specific fields or properties in the object or its children are open or closed to modification.
- Any other important information which may not be apparent at a glance in the inspector. For example, any notable components that are added to the object by script during runtime.
Utilizing Scriptable Objects for Faster Iteration
Gameplay balance values such as speeds, rates, quantities, and more are often set through public members on objects, or in generic gameplay logic scripts themselves. This is difficult to manage, as changing a specific variable of gameplay requires hunting down a prefab which exposes it or the script which defines it. Scriptable objects being used as gameplay tuners solves this problem.
Define your gameplay variables in a scriptable object’s public interface. You can calculate other internal variables using the public variables, but display them in the scriptable object’s interface so the developer or designer can see how the public variables affect the internal variables.
Load your gameplay variable scriptable objects as a resource, and provide them as a dependency to classes that need it. Rather than having their own internal gameplay variables, they will use the variables provided by the scriptable object.
A single scriptable object should not necessarily contain all possible gameplay variables. If you have a lot of variables, separate them into different scriptable objects by the system of the game they relate to most. For example, camera variables, UI variables, character balance variables, and so on. As a result, tweaking system variables become an easy and efficient process.