Corrupting the Open Policy Agent to Run My Games
When I was younger, whenever I encountered a new technology that I wanted to learn, I would ask a simple question — “Can I game with this?” My method of learning was to conscript whatever new library, language, or tool I encountered into my own personal army of game development.
I discovered that a shockingly large number of enterprise tools had decent applications playing supporting roles for online multiplayer back-end systems. It’s also far more fun to learn an otherwise dry tech by involving our imagination a little bit.
For whatever reason, I stopped doing this. In my head (since I now possess only fictional amounts of spare time), I am building an interactive fiction/2D MMO hybrid game that needs a way of quickly resolving combat rules.
I’ve seen countless ways of defining combat rules. Many games, even some really big ones, tend to blur the lines between the game engine and the game content, and don’t loosely couple the enforcement of rules (doing damage, granting buffs, etc) from the rule decisions. This leads to difficult to maintain spaghetti code.
As I experimented with OPA for all manner of boring things like a Kubernetes admission controller and a role-based authorization manager for a microservice ecosystem, it dawned on me that a system like OPA might be ideal for doing combat rule resolution tasks on the back-end.
When you build a game’s back-end, speed and throughput are king. As a result, game designers often hard-code damage tables or dramatically simplify the math required to determine things like hit chance, damage done, etc. The more you optimize a game’s back-end, the more shortcuts you take in service of speed, the more inscrutable the code gets (yes, there are exceptions, but you get the idea).
What if we could completely delegate all the combat rules and all of the supporting data tables like damage mitigation by armor type, level bonuses, “proc triggers” (chances of special effects firing), and everything else to a rules engine with an easy to read language and a cloud-native model that allows for dynamic data updates and blazing fast queries?
Since OPA felt like the right tool for this job, I decided to experiment with it. At the bottom layer of OPA’s document system are the data documents. So, I started by modeling some raw data in JSON to see what that felt like:
This is some armor data that, by virtue of its location in the data tree, will appear in the data.armor
package. The first value, mitigation_matrix
, defines the percentage of damage mitigation a class of armor provides against a class of damage. The mapping shows, for example, that cloth armor actually adds 5% damage versus fire while leather armor mitigates 10% of incoming piercing damage (arrows & daggers).
There’s also a stats
lookup table that maps the unique ID of a given piece of armor to its class. This ID might come from a level editor, a full game editor, or an ID used by an ECS (Entity Component System) library.
I took a similar approach to modeling the raw weapons data, providing a mapping to convert a weapon ID into a damage class, which we can then use when resolving raw damage for an attack:
What I find really appealing about OPA’s tiered nature is that you can define rules on top of the raw data which produce more refined, purpose-driven data. For example, I can derive the list of all armor classes by taking the keys from the mitigation matrix. In the following rules file (a .rego
file), I take the approach of progressively building up slightly more useful information until I reach a query called effectivedamage
which would be called by my core game engine for every attack that hit (“to hit” calculation isn’t shown in this blog post).
In the rules file below, I separately calculate the mitigation_factor
(percent of damage reduction according to weapon and armor class), the rawdamage
(attacker’s 100-sided die as a percentage of weapon base damage), ultimately producing effectivedamage
, which is the raw weapon damage minus mitigated damage.
So now I can supply an input document that looks like the following and query the value of effectivedamage
after every attack:
An important thing I’m doing here is passing in the value of the die rolls as part of the input document. I’m not sure if Rego has built-in random numbers, but by passing die rolls as input, I can write unit tests against my combat rules. This lets me see how my system behaves on a larger scale, allowing me to tweak the rules and balance as I see fit.
As game designers, we like to see the graphs of our calculations so we can see how the data “moves” along certain axes. I suspect it would be very easy to produce graphs by running OPA queries and plotting the results. If we don’t like the graph, we can tweak the rules until the graph plots the shape we need. This kind of thing is especially powerful when determining damage, experience, and level scaling.
There’s another subtly powerful thing going on with OPA — it’s dynamic and real-time. This means that my game engine can change the stats of any weapon, armor, or spell at runtime and all of the corresponding rules will immediately be updated to reflect the new value.
This might not seem all that interesting, but the more logic I offload from the game engine into OPA, the more power and flexibility I get in return. Let’s take an example that I’ve seen in various forms in other games — mana zones or “nodes”. In games with this feature, there are certain regions of the game world where magic is more powerful. If a player is standing in one of these, their spells might do 5% more damage or fire spells might do double-damage, etc. If I just pass the player’s location into the OPA query, then my combat policies can handle all of the mana zone logic, including zones that dynamically appear and disappear or shift from one place to another.
The moral of the story is that OPA is like a “deferred brain” to which I can pass relevant information and get decisions in return. The benefits of loose coupling and deferred logic extend far beyond just enabling cleaner code. I can use some pretty powerful, scaling patterns like sidecars or in-memory library usage to get sub-millisecond replies from my “spare brain”.
The second moral of the story is that this experiment with OPA has made me lament my lack of spare time, because now I really want to go work on my game project.