Posted On: 2019-05-13
As I transition from working on a prototype to working on a larger, more permanent project, I've found myself working quite a bit on the architecture that the project will use. As such, this post will describe what Software Architecture is, why it is important, and what specific things I've looked at the past week.
To briefly describe it, Software Architecture is a set of self-imposed constraints that are intended to improve the consistency of the software project. It is usually focused on large scale constraints (such as where to store scripts shared between scenes) and generally less focused on the finer details (such as whether to use tabs or spaces for indentation in a document - that would be the domain of Code Style.)
Importantly, Software Architecture doesn't end with describing constraints - it also includes creating shared systems and components that leverage those constraints to facilitate future development. A common example of this is the creation of "base" classes: classes that are considered similar (from an architectural standpoint) may be grouped together by having them inherit from a single (abstract) class. This provides an extensiblity point where new functionality can be added to all the similar classes at once, by adding the functionality to the base class. It also provides a simple way for generic systems to constrain their inputs to a particular type (for example, a character factory may constrain itself to only create objects that inherit from BaseCharacter.)
Note: This is likely an overly permissive definition. The domain of Software Architecture literature may be more narrow than this, but I find this definition is useful as it helps distinguish software architecture that is adding value as compared to, say, creating class diagrams in order to check a checkbox.
Worth mentioning: Architectures that have the constraint of favoring composition over inheritance may instead use composition of interfaces to achieve the same value that a "base" class provides. This is just one of many examples where the set of constraints used by the architecture will inform future decisions about the project.
Simplifying by improving consistency is the primary goal of Software Architecture. This is particularly valuable for large teams, as it facilitates teammates being able to find, work with, and build upon each other's code (aka. maintainable code.) That being said, it is also quite valuable for small teams and solo developers:
As a brief aside: Architecture does not affect the complexity of handling any user-generated input: user input should always be validated, because it will inevitably violate your assumptions.
Additionally, implementing Software Architecture early is valuable as all future work can build on top of it. Architectural code that is mean to be shared (systems, components, interfaces, etc.) provides the most value if it is completed before any work that might take advantage of that shared code. (Of course, if other work is blocked as it waits for the architectural work, that may undercut its value. This can sometimes be mitigated by having the first implementation responsible for creating the relevant architectural code.)
Worth mentioning: an architecture that is a poor fit for a project may actually make the project more complex or error prone (such as base classes that are not accurately modeling similarity.) As always, it's important to keep in mind the goals and honestly evaluate how well a particular implementation is achieving them and at what cost.
As I start work on my larger project, I am trying to focus on using architecture to facilitate resolving certain specific pain-points that I have had while working with my earlier prototypes. I'll provide a few examples of what I've been thinking about here, but this is not intended to be exhaustive (both because I haven't finished and also because I will inevitably miss something - no project can be fully planned up-front.)
Note: Technically, some of these are more "branching strategy" rather than architectural decisions. Since (in my experience) decisions about branching are made by the architect with the goal of improving consistency, I am including them here anyway.
There are a number of small, simple steps that must be performed at the start of every project (setting the scripting language version, setting the color space, importing dependencies, etc.) Typically this is automated by creating an empty project and then copying it every time this is needed. Unfortunately, that approach means that any work with dependencies necessarily is working with old dependencies. Furthermore, if I intend to share more complex systems (such as the player character controls) then such an approach also would be inadequate. As such, I am looking to architect the system in a way that will allow me to easily spin off a prototype from the existing game, to focus on a specific scene, action, or experience without affecting the main game.
I intend to use branching (or possibly forking, still deciding on that detail) to spin up new prototypes based on the existing project. Since each branch/fork will, necessarily, include all the resources in the existing project, I intend to make careful use of asset bundles for my addresssable assets - assets that can be shared will be separated from assets that are only used in the base game (I expect soundtracks and environments will be less likely to be shared, compared to, say, main characters or common enemies.) Additionally, these new prototypes will make use of new scenes, so that they can focus on what is specifically relevant for that prototype.
In my prototype, I made use of some third-party assets (including the lovely Rex Engine and Yarn Spinner.) Over time, I found I needed to modify those assets, and, eventually, also needed to upgrade them. Manually merging the the new versions of the assets with my existing code could take hours and risked introducing regressions. I intend to continue using (and modifying) third-party assets, so I need to architect the system in a way that minimizes these risks.
Source control is intended to simplify such merges, and I am trying to setup the project in a way that maximizes the ability to do just that. Specifically, I have branched the code so that each third-party asset (aka. dependency) has its own branch, right at the start of the project. All those branches then merge together into the master branch. As I make changes to these dependencies, I will apply them to the master (either directly or by merging to master from a feature/wip branch.) Finally, when a new version of a dependency is available, I will switch to the branch for that specific dependency, upgrade it, and then merge the change into the master. I am optimistic that doing so will highlight only the merge conflicts, thereby allowing me to focus on making the merge as clean as possible.
There are many ways to organize assets (by type, by scene, by character/object, etc) and, while working on the prototype, I was not consistent about which approach I used. As a result, I eventually needed to rely on search features to find things (prefabs and code) rather than being able to locate them myself.
I will organize my project horizontally - specifically I take this to be it will be organized according to:
Some of the dependencies my project uses makes certain assumptions about the architecture of the project. As an example, Rex Engine offers extension using a blend of inheritance (extend RexActor to add death events) and composition (a RexActor can use any RexStates on its GameObject.) I will need to determine how best to remain consistent while still being able to leverage the extensibility points.
Worth mentioning: I personally prefer using interfaces instead of inheritance whenever possible, but I can understand the motivation behind RexEngine's approach - Unity's inspector is not designed to work with interfaces, so inheritance is often the best (least complex) option if you need extensibility points that can be modified by non-developers.
I haven't fully decided how to approach this yet, but part of that is due to not having all the dependencies I will need. I have plenty of prior practice working within others' architectural constraints, so that shouldn't be much of an issue if I go that route. Unfortunately, letting one dependency completely define the project's architecture may run into issues if another dependency makes a different set of assumptions. As such, I am currently looking for a way to support interfacing with a dependency in a way that is architecturally consistent with itself without forcing that architecture on the rest of the project. (Whether or not I do so will likely depend on what costs are associated with the approach. The default approach of writing a wrapper around everything is pretty costly from a time and maintenance perspective, so it wouldn't really be justified unless I had a clear conflict with another dependency.)
Hopefully this perspective on Software Architecture, what it is and why one should care about it, has been useful. Additionally, I hope some of these examples will be helpful whenever you're approaching your own architectural decisions.