The Tangled Web of "Effects"

Oct 29, 2020

Dev Log - Tangledeep 2

Tangledeep 1 & Effects - A Primer

In the original Tangledeep, an Effect (or EffectScript in the game's code) served as the functional unit of almost any thing that could happen in the game. From passive skills and status conditions to special attacks and even map objects like flames and caltrops, Effects drove just about all behavior. Abilities, conditions, ground object behavior, and passives were all, essentially, just a bundle of Effects.

I figured this would be a reasonably good approach that could maximize code reuse, and make modding easier. Say a Fireball consisted of two Effects: fireball_dmg and fireball_add_burn. It would then be trivial to make a Sword that also attached those same effects, or passive skill that triggered fireball_add_burn when you get struck.

From a code perspective this basically meant making a core EffectScript class which various child classes inherited from, like EffectScript_Damage or EffectScript_MoveActor. Anything that needed to run an Effect would then just call effect.Execute(), pass in any contextual data, and the Effect would handle the rest! Clean, right?

Well...

While this system worked reasonably well overall, things became much more complex than I originally intended. What happens when an Ability has two Effects, each of which affect completely different tiles or actors? For example, an Ability that moves the user next to the target, and then damages the target; how does each Effect know who or what is being targeted? The parent Ability could pass in some data about general targeting, but the children still require their own sub-logic on how to handle that targeting data.

Then there are conditions, which further complicate how and when an Effect actually triggers. Say you have an Effect that should deal 25% of Weapon Power as damage back to the attacker... but only if the attacker is using a projectile weapon. Or, a shield Effect where incoming damage is reduced, but only if that damage is (1) physical, and (2) coming from an angle opposite the direction of the shield.

As you can imagine, this all led to a number of large, messy classes and a huge amount of combinatorial complexity. It worked, but I could do better.

The Tangledeep 2 Approach

In the sequel, I'm taking the same fundamental approach of using Effects as the basis to implement 'things that happen', but with a number of key changes.

1. Separating branching logic into separate classes.

A "move" Effect can be anything ranging from stepping to a specific space, to pushing something in a given direction, pulling an actor, swapping places, or teleporting at random. In Tangledeep 1, I represented these within a single class, with loads of int & bool parameters, plus a hellacious master logic function that spanned hundreds of lines.

In TD2, each Effect class should do one thing. Sure, there are common elements to moving things around: those common elements now live in EffectScript_MoveActor, a parent class. The logic for swapping the position of the user and the target is now implemented in EffectScript_MoveActor_SwitchPlaces. A push Effect becomes EffectScript_MoveActor_Push. And so on, and so forth.

This approach lets each Effect class focus narrowly on a relatively simple behavior, with (ideally) a small number of relevant parameters. Easier to read, maintain, and debug. When I catch myself trying to shoehorn too many "if" statements into an Effect class, I spin it off into a new one.

2. Data messages for context

Every time an Effect is called in TD2, there is now a simple data structure called a TDMessage that contains a big chunk of context detailing how that call happened. No Effects can be executed without some sort of TDMessage generated before the execution begins. I did a little bit of this in TD1, but it wasn't consistent. Data context lived in multiple classes and managers, which increased coupling and made it frustrating to figure out where an Effect was getting its data from.

Now, the TDMessage is the 'one source of truth'. What was the source that kicked off the execution? Where was it located? What was it trying to target? Is there any logging required? The TDMessage contains all of this. Furthermore, once an Effect is completed, the message is passed on to the next Effect in sequence (if any). For example, if an Ability causes movement followed by damage, the damage Effect will receive data on what happened in the movement by means of the TDMessage.

The other interesting part here is that Effects can create their own copies of the TDMessage if they need to pass information to any asynchronous code. This matters mainly for visual effects; in a three-part ability, we might want to show an explosion on the position of an actor as it existed in part 1, even though by part 3 it may have moved away. Creating lightweight copies of the message lets us do that.

3. Concrete conditionals

Conditionals such as "only trigger in melee" or "only trigger if defender is at less than half health" were previously represented as a list of Enums and a bunch of fields in the parent class (like percentDefenderMaxHealth, percentAttackerMaxHealth, etc... yuck!) So, clearly a better solution was needed.

Effects can now have any number of conditional logic data structures (EffectTriggerLogic in the game code) which consist of a single conditional and the data supporting it. This way, the Effect does not really need to know or care what the conditionals are or directly contain data about them. It just passes context to all the EffectTriggerLogic it contains and receives a 'valid' or 'invalid' result in return.

This is better because encapsulation is good! I can change the logic that evaluates conditions, or the data those conditions require, without touching Effects at all. Hooray!

Will It Work?

So far, things are substantially cleaner in the code base. Everything is much more readable at a glance - or at least, it is to me. But, I have to be careful of some potential pitfalls as I get more into content design.

One of the most complicated things right now is the visual effect system, which is maybe a bit over-engineered. Each Effect can dictate how and when it triggers visuals, which could be anything from explosion sprites to movement functions, damage numbers, log text, or a combination of things. But even though I wrote the system, the sheer number of options makes it a bit overwhelming when I'm in content design mode.

My solution to this has been to create visual effect templates that I can use when designing content. e.g. SpawnSinglePrefab does what it says on the tin. The Effect will generate a single FX prefab on whatever target actor or tile is being affected. This is not actually a function or class: it's a set of functions and rules like, "affects entities", "spawns damage numbers", "writes to log", "generates prefab at position", etc.

Still, with so many animation and visual possibilities, I'm sure I'm going to need to keep a close eye on this and see if I can keep content design relatively svelte, while the complexity can live safely scattered across small modular classes...