I had written a post on LinkedIn talking about a mistake I made in my game’s architecture. The short story is that because I was busy working on a prototype, I decided to keep things as simple as possible. I didn’t want to spend time crafting the most elegant code possible, but rather focus on validating the game mechanics as soon as I could. The issue came up when I decided to start moving the “core” code from the game code.
Before I continue however, I wanted to make a note that most of my posts tend to be pretty long and verbose. I’ll try and keep this one short and to the point.
The Issue
I had originally defined several structs and classes around damage and character attributes. This made implementation very straightforward. No need for data mapping logic or asset registries. I intentionally decided on this because in the past I had gotten so carried away with architecture that I barely made progress on a prototype after 6 months + of development.
However, when I decided to start splitting out the “core” code from the “game” code, this problem became immediately apparent. NecroticDamage and NecroticResistance are game specific properties. However, abilities, effects, weapons, characters, etc. all knew about these properties in one way or another. For example:
public class DamageModifier {
private enum ModifierType type;
private DamageAttributeType damageType;
private float value;
}
public enum DamageAttributeType {
PHYSICAL,
NECROTIC,
...
}
I played around with several more ideas such as making everything in the core generic (e.g. templates). However, this becomes messy when a class is composed of multiple generic types; you then have to pass in multiple concrete types. My next idea was to accept a certain level of tech debt by isolating as much as I could into another core package. I figured that if I reuse this code later, I’d just need to clean up the one package and be done. However, this idea really didn’t sit right with me.
The Solution
If you’re familiar with Unity, you probably already know what I’m about to say... ScriptableObjects. I was avoiding this solution because I both hate the idea of having hundreds of tiny assets in my project and because it makes both reasoning and debugging more difficult. I thought on this for about 2 days before decided to rip the band-aid off and get to work.
Instead of DamageInfo defining each specific damage property, or CharacterAttributes defining every game specific character stat, they’d instead simply hold a list of DamageAttribute and CharacterAttribute which are tiny SOs that contain the attribute name and a few additional properties such as DisplayName.
So now I had a clean separation between core and game code. It’s data driven through SO assets. However, my original code wasn’t a total loss. I then created two new ScriptableObject classes:
- DamageAttributeRegistry
- CharacterAttributeRegistry
These both live in the game package and bridge the gap between assets and logic.
public class CharacterAttributeRegistry {
...
[SerializeField] private CharacterAttribute health;
[SerializeField] private CharacterAttribute dexterity;
...
[SerializeField] private CharacterAttribute physicalDamageMultiplier;
[SerializeField] private CharacterAttribute physicalDamageResistance;
...
public CharacterAttribute Get(string name) {...}
public CharacterAttribute Get(CharacterAttributeType type) {...}
...etc.
}
Benefits
This refactor forced me to re-think several things, specifically separation of concerns. Previously my CharacterAttributeManager was implementing damage scaling, damage reduction (CalculateFinalDamage), etc. Abilities were applying damage modifiers to the DamageInfo instance, etc. Not to sound apologetic, but again - I was moving fast and focusing on getting my prototype finished.
Now I have a dedicated DamageProcessor component that takes care of scaling damage, applying critical hits, and calculating the final damage to be taken after resistance stats are applied. My DamageInfo class which was originally just meant to be a simple data package to transfer damage information from the attacker to the target, now also takes care of applying damage modifiers.
What I Learned
It’s common for large studios to throw away prototypes after the initial validation because of the fact that the code tends to be messy with little production value. However, as a solo indie developer, I don’t have the luxury of time or resources to simply restart from scratch months into development. I had initially gone into this project with the mindset that I’ll “just focus on getting this prototype done and not worry about perfection” but in reality, taking the extra time now to write clean, production ready code will not only give me a solid foundation to develop upon, but will also drastically increase how quickly I can create the next prototype down the road.
Validating The Refactor
I’ll admit that because I was moving quickly and focusing on implementing the core mechanics as quickly as possible, I skipped writing formal tests. Instead, I created a series of test scenes with information panels that print out detailed information about the attack information, including what ability is being used, how the ability modifies the damage, how the damage was scaled, how it was reduced by the target based on their stats, etc.
This combined with a dev UI that allows me to test different scenarios, and the ability to tweak values in the scriptable objects, has so far been enough to provide the necessary information to ensure things are working as expected. Eventually I’ll create formal unit tests as well as playmode tests, but I tend to do that once I’m happy with where things are at. Otherwise I’ve found that I’m spending more time fixing and refactoring test code than I am working on mechanics.
Conclusion
The refactor was a lot of work. It set me back by about a week. However, I’m glad I took the time to do it. Not only did I dramatically increase reusability, but found a much better architecture in general. I’m still not sure if this was the best move I could have made at this point, but now that it’s done and out of the way, I have a much stronger, cleaner foundation to build on top of.