Sea otter in a circleSea Otter Games

#9 - Am I overengineering my code?

By Hugo on 2024-06-27
Game Development
Unity
C#

Recently, I have been working on a fight system for my game. Monsters will attack your buildings. Your troops will defend them. Buildings might give advantages. Overall the idea is quite simple. Yet, there are quite a lot of moving pieces and things to plan ahead. I will not delve any deeper into this feature yet, but rather, I want to share some interrogations I stumbled on while working on this feature.

I started this feature by creating a “fight module” that would be a component of any unit that can take part in a fight. This implements obvious data such as health, attack damage, faction etc. At this point, something struck me: for bound values such as health, we need a max value. And this is true for many things in video games: health, energy, mana, capacity, carry weight, assigned characteristic or spell points, character level etc.

During game jams and school projects, I did not think too much of it, but on this project, I wish to do things nicely -or at least to try-. This may seem uneventful but it is a recurring pattern and it pains me that a component as simple as health requires to carry along multiple variables.

Why not create a data structure to avoid this?

Specifications

Here’s the typical use case: my game has a player and the player has a health value. This health is described by my new data structure. I initialize this data structure with the value of an upper and lower bound. Then, I can use this variable in numerical operations when the player is hurt for instance. When reaching the lower bound, it should automatically run the death logic of the player.

As such, this tool would need to fulfil some specific tasks:

First, act as a numerical variable. We can therefore make our tool generic so that it works with most numerical data types.

Then, it will need to pack a maximum value. Eventually, if our variable can be negative, we can also plan a minimum value defaulting to zero.

Because this tool overloads the use of a simple numerical variable, we will need to make its access and manipulation as simple and intuitive as possible. It would be a shame if this tool were more of a burden to use than the initial system.

Finally, the main advantage of this data structure is that it encompasses values to compare a variable to. So, whenever we operate on the variable, we can do our verifications and trigger events if we reach or exceed our bounds.

Implementation

After spending an hour thinking about how I would never get this hour back, here’s what I came up with!

1public class BoundCounter 2{ 3 public double currentValue { get; set; } 4 public double minValue { get; private set; } 5 public double maxValue { get; private set; } 6 7 public UnityEvent OnMaxValueReachedOrExceeded { get; private set; } = new UnityEvent(); 8 public UnityEvent OnMinValueReachedOrExceeded { get; private set; } = new UnityEvent(); 9 10 // CONSTRUCTORS 11 public BoundCounter(double maxValue) 12 { 13 this.currentValue = 0; 14 this.minValue = 0; 15 this.maxValue = maxValue; 16 } 17 18 public BoundCounter(double currentValue, double maxValue) 19 { 20 this.currentValue = currentValue; 21 this.minValue = 0; 22 this.maxValue = maxValue; 23 } 24 25 public BoundCounter(double currentValue, double minValue, double maxValue) 26 { 27 this.currentValue = currentValue; 28 this.minValue = minValue; 29 this.maxValue = maxValue; 30 } 31 32 // OPERATOR OVERLOADS 33 public static BoundCounter operator ++(BoundCounter counter) 34 { 35 counter.currentValue = ++counter.currentValue; 36 counter.CheckBounds(); 37 return counter; 38 } 39 40 public static BoundCounter operator --(BoundCounter counter) 41 { 42 counter.currentValue = --counter.currentValue; 43 counter.CheckBounds(); 44 return counter; 45 } 46 47 public static BoundCounter operator +(BoundCounter counter, double param) 48 { 49 counter.currentValue = counter.currentValue + param; 50 counter.CheckBounds(); 51 return counter; 52 } 53 54 public static BoundCounter operator -(BoundCounter counter, double param) 55 { 56 counter.currentValue = counter.currentValue - param; 57 counter.CheckBounds(); 58 return counter; 59 } 60 61 public static BoundCounter operator /(BoundCounter counter, double param) 62 { 63 if (param == 0) throw new DivideByZeroException(); 64 counter.currentValue = counter.currentValue / param; 65 counter.CheckBounds(); 66 return counter; 67 } 68 69 public static BoundCounter operator *(BoundCounter counter, double param) 70 { 71 counter.currentValue = counter.currentValue * param; 72 counter.CheckBounds(); 73 return counter; 74 } 75 76 // OPERATION CHECK 77 private void CheckBounds() 78 { 79 if (currentValue >= maxValue) 80 { 81 OnMaxValueReachedOrExceeded.Invoke(); 82 currentValue = maxValue; 83 } 84 85 if (currentValue <= minValue) 86 { 87 OnMinValueReachedOrExceeded.Invoke(); 88 currentValue = minValue; 89 } 90 } 91}

Examples

1// INITIALIZATION 2 private void Awake() 3 { 4 health = new BoundCounter(maxHealth, maxHealth); 5 health.OnMinValueReachedOrExceeded.AddListener(actor.OnDeath); 6 } 7 8 // USE OF OVERLOADED OPERATORS 9 private void Attack(FightModule ennemyFighter) 10 { 11 ennemyFighter.health -= attack; 12 // here, if the enemy's health drop to or under 0, its OnDeath method will be called. 13 } 14 15 // GET THE CURRENT VALUE 16 private int GetHealth() 17 { 18 return health.currentValue; 19 }

Following the specifications, here’s how it works:

Thoughts

This system works very well. It increases code readability and helps compartmentalize my modules. Having events to automatically manage my min/max cases is a nice touch. Finally, I love being able to use the overloaded numerical operators.

However, the main issue of this system is that it enforces a very specific process. For instance, it works perfectly well to describe the worker capacity of my tasks. And rightfully so because it was designed with that need in mind. Yet, it did not suit that well the health of my troops because there are multiple systems that needed to be resolved before the player’s health. This required some refactoring.

That being said, I think this a typical example of over-engineering. While this system exempts me from writing a few if clauses here and there, it lacks modularity and cannot be used when the logic is constrained to a very specific order of execution. However, when planned ahead, the use of this tool can really be beneficial and as I proof read this article, I am less pessimistic about the tool than I was when first writing it.

Moral of the story: while strong and efficient foundations are a must, some end-of-the-line features should maybe be quickly iterated over. And that, even if it means going over it again later. If I were to do it again, I would implement this tool because it was not that much of a time investment and I still have a lot of work ahead that might use this. However, as a project grows closer to the release, I think you should be aware that new features and tools are greater and greater time investments.

In the end, make games and do not think too hard about optimizing that specific method through wasted time, convoluted code, and oddly specific architectures. 😃

As always, thank you for reading to the end! I hope to see you in the next post. 🦦

© Copyright 2024 - 🦦 Sea Otter Games