When I was initially writing my latest game design doc, I decided to keep things simple; there would only be a single damage type that all attackers would deal, and then for player progression, there would be 3-4 stats they could boost over time, making them move a little faster, take a little less damage, and giving their attacks a bit more punch. Many successful indie games did exactly that while still providing a fun and rewarding experience.
However, over time I began realizing that in order to achieve the things that make my game unique, I really didn’t have much choice but to at least make things a little more complicated. I found myself trying to force ideas to work within the constraints I had set, but from a player’s perspective, would have made very little sense.
My Approach
I still wanted to try and keep things as simple as possible while creating a foundation that would not only just address the current game design, but could grow as it evolved. In order to achieve this, I decided to set my focus on designing a flexible yet simple damage and progression system.
My criteria were:
- It should be editor and designer friendly.
- Everything should flow through the same pipeline.
- It should be manageable and maintainable.
- As few special case conditions as possible.
In addition, I also wanted to avoid solutions I’ve seen where everything is a scriptable object. I already had enough work on my plate as it was. I wanted to stay focused on the task at hand, not getting lost in the weeds trying to design the worlds most elegant solution.
What I ultimately decided on were 4 core parts:
DamageInfoa simple struct that would be built by the attacker and processed by the target.DamageAttributesanother simple struct that components could expose as editor fields which would allow a designer to configure the various damage properties. This could be used within weapons, abilities, or really anything else that plays a role in creating damage.CharacterAttributesattributes that are used by all characters, including the player that define things such as health, dexterity, damage type multipliers, damage resistances, etc.AttributeManagera component that can be extended and assigned to any character type and deals with things such as scaling damage, computing the final damage values after resistances are applied, etc.
The Trade-off
There are always many ways to go about solving any given problem. There’s always trade-offs made with every decision. My decision making process was largely based on the idea that I’m building a prototype. I didn’t want to spend weeks building the “perfect solution” only to later find it wasn’t as perfect as I thought. The trade-off I made with this decision was that I would need to touch a few files whenever I wanted to add a new field. Does this violate the criteria I laid out? Let’s see.
- Every field can be configured in the editor. ✅
- Everything flows through the same pipeline (more on this later). ✅
- It took me all of about 2 minutes to add the “Abyssal” damage type and related properties. ✅
- Yet to be seen, but there are currently zero special cases. ✅
The alternative I was considering would have heavily relied on scriptable objects. Essentially every attribute from health to abyssal damage, would have been an instance of a scriptable object. However, the core problem with this approach would have been that nothing was explicit. Designers could create whatever new stats they want which sounds good in theory, but designing a system to be that generic is not an easy feat. Debugging would be hell to say the least. Maybe eventually I’ll decide to tackle this, but for the sake of actually finishing my prototype, I believe my decision was the right call.
Traps & Hazards
Because of the fact that everything flows through the same pipeline, creating traps and environmental hazards is trivial
as far as dealing damage goes. It can be as simple as listening for a trigger event, creating a DamageInfo instance and
then passing it to the triggering character. We can also utilize the DamageAttributes struct to define what the damage
values are.
Abilities & Effects
The other major piece of the puzzle was the abilities and effects system. While I had already written this prior to knowing exactly how I wanted everything to work, integration was trivial. I introduced two new structs:
DamageModifierAttributeModifier
These are both stored within a list within the Ability base class. DamageModifiers are applied when an attack is made by
modifying the DamageInfo object based on a flat modifier, additive percent modifier, or multiplicative modifier which are
sorted, summed, and applied in order. Effects then use attribute modifiers for buffs, debuffs, etc. but also re-use the
DamageAttributes struct to define damage over time values, which is the same struct used by weapons to define the base
damage values.
Effects Are Not SOs
Using [SerializeReference] and [SubclassSelector] from Mackysoft -
any class that inherits from Effect can be selected and added to a list within the abilities. This allows me to easily create
new ability assets (which ARE scriptable objects) and then add whatever effects to them I want. I can easily create new
types of effects down the road and simply add them to whatever abilities I want. I can also duplicate an ability asset which copies
over the references allowing me to tweak them instead of re-creating similar abilities from scratch each time.
The tradoff here of course is that if I do create a new ability and want to reuse effects, I do have to select them in the editor and then configure them accordingly, but then there’s really no difference than creating new effects as scriptable objects that I then need to create a dozen instances of each time I want an ability to use a slightly different variant.
AttributeManager
The last thing I want to discuss for now is the attribute manager. Every character has an attribute manager component attached to it. The “base” attribute manager is entirely data driven and implements the core functionality:
- Getting current “live” stat values after applying any current stat modifiers.
- Cloning stats from the character data to prevent SO value sharing.
- Adding and removing stat modifiers.
- Getting the base values (e.g. max health vs current health).
- Calculating the final damage values after applying damage resistance stats.
- Scaling damage using damage multiplier stats.
- Actually applying the final summed damage to the health.
- Healing utility methods.
- Notifying subscribers about stat changes.
- Etc.
Specific characters, especially the player character, have their own custom attribute manager which extends the base. For example, the PlayerAttributeManager deals with player progression (e.g. stat increases), scaling dash force and cool down, etc.
Damage Multipliers
Maybe this is common — I’m not sure, but I felt this was clever. Since I decided I wanted everything to flow through the same
pipeline as much as possible, damage and defense (e.g. resistance) values are simply multipliers within the CharacterAttributes
class. If a boss needs to increase their defense or damage during a “second phase” all they need to do is increase their
damage and resistance multipliers. When the player increases their stats defense and damage related stats, all that actually
changes is the stat level and damage / resistance values. These are then used for scaling damage and computing final damage
to be taken.
Final Thoughts
This was a lot of work with many decisions to make. Not only did I need to consider the technical aspects but also how everything would work in context with the game design. I didn’t want to over engineer anything, but I also didn’t want to under engineer things. I’m pretty happy with the final results. Everything is editor configurable, it’s extremely easy to debug, there’s really not that much code, and most importantly, it does what I need it to do.