Crits. Critical hits. Periodic huge damage. They’re a staple of game design and have always been one of my favourite things to chase when playing any kind of game that lets me optimise them.
So when it came to designing how damage works in Aria, it was a no-brainer that I would try putting crits into the system. Aria’s take on crits works like this: certain situations will allow a character to gain advantage over another. Maybe the attacker is striking from stealth, maybe the target is stunned. Maybe the target is trapped in a gigantic vortex of water.
Whatever the cause is, it can occur that one character has advantage over another when they mean to attack. When this happens, the attack is a critical attack and they do bonus critical damage.
Why did I decide to try this style of crit vs a more classic proc system? Well, it’s a design that favours coordination and communication between players. It’s a setup and payoff system, i.e. “I’ll stun him, you wallop him!” and so fits into Aria’s overall design goal of taking every opportunity to enrich the local coop experience.
So that’s all well and good. I have my crit design. Now onto the simple matter of implement…ing…it. Huh.
Here’s the thing about the design I just described: whenever we want to calculate damage for any attack from any source, we need to know a bunch of things:
- Is the attacker in a state of advantage (e.g. are they cloaked)?
- Is the target granting advantage (e.g. are they stunned)?
- How much crit bonus damage should we add (e.g. are there any bonuses active)?
You might think, well that’s easy enough. We’ll just add an attacker.HasAdvantage() check, and a target.IsGrantingAdvantage() check and we’ll add an attacker.critDamage property. You mean like this?
Looks nice and succinct, right? Good readability. Seems like a good solution!
Well, not so fast there cowboy. It’s true that this is how I would have dealt with this in the past, but Aria presents a more wrinkled version of the problem that made such an approach feel dangerous.
Think about it – each one of these functions needs to collate together information from any number of sources elsewhere in the code. We need to find any bonuses we might have active, find a way to ask all of the bonuses if any of them grant advantage. There might be actions being executed such as being stunned, or conditions that are active such as being cloaked.
Not to mention Aria is built around players tacking on all manner of weird and wonderful perks to their characters. So do we add a perk.GivesAdvantage() function to our Perk class? What else will we need to add that to? Are we going to end up with a whole bunch of these random question-answering functions sprinkled through our codebase just to appease our crit system?
Let’s take a closer look at our attacker.HasAdvantage() function:
This goes along with having to make a whole bunch of interfaces like IGivesAdvantageEvaluator. Urgh.
Specifically this method leads to the following problems:
- It violates the Open-Closed principle and
- We create deep-running dependency chains all throughout the codebase
Such a system could quickly devolve into a spider web of dependencies. And not one of those nice symmetrical spider webs with the morning dew hanging off of it, I mean a nasty mangled attic spider web with 8 dead bugs in it.
So I didn’t want to do that. I didn’t want to do any of that stuff, really. All I wanted to do was have my damage system ask a question like “Does this character have advantage?” and get an answer “Yes” or “No”, without having to know where the answer came from.
Enter the query dispatcher. The query dispatcher is much like an event dispatcher: sections of code can subscribe to events and other sections of code can trigger that event, thereby activating whatever code was subscribed. And no section of code has to know any of the other sections of code exist.
Where the query dispatcher differs is that we aren’t just firing off an event to go and run whoever’s-listening code. We are throwing a question out to our code base and receiving an answer to that question. So we have answers subscribing to questions and askers dispatching those questions to get answers.
The query dispatcher can do the following things:
- Answer Yes or No questions (e.g. Does Character X have advantage?)
- Tally numbers (e.g. How much Crit Bonus Damage should Character X receive?)
- Call Votes (e.g. What colour should Character X’s fireball be?)
All without any dependencies. Sounds pretty neat, right? So what does the code for our crit system end up looking like? Let’s return to our question-asking code:
I’ll admit it is ever-so-slightly less readable than our attic-web-with-dead-bugs-in-it version, but let’s see what our answering code looks like. We’ll use the StunnedAction as an example:
What this does is grant StunnedAction the ability to answer yes (or specifically true) to any question asking if its actor grants advantage by subscribing to all QUERY_ADVANTAGE_GRANTED queries. Note that the Begin() and End() functions already exist to begin and end our stunned state. As such our subscription code is slotting naturally into how the StunnedAction class already works.
We can add similar subscription code anywhere we need to in our codebase to implement our advantage mechanic. Our answering method has become a come-as-you-need setup. With a little more answering code in other modifiers and states, we have our crit mechanic up and running!
The system is still in its infancy, I will refine it over the next few milestones after which I’ll make my query dispatcher implementation available for use, should you wish to give it a try. Stay tuned!