Getting into the Guts of Berserk

Hey there tech enthusiasts and deadly poison gas fans! Jeffrey Doering (aka Nekomaru) here today to talk to you about the dangers of aggression-inducing chemicals and adding them to your video game systems. As the primary engineer on the Renata Glasc pod, I’ve been working with Blake “Squad5” Smith (the game designer behind Renata) to bring the Chem-Baroness to the Rift for the better part of a year now. Much of that time was spent on the nuances of her ultimate and the new form of crowd control it introduces. In this article, I’ll cover how we went from a hacky prototype spell to a game-changing ultimate that’s built to last.

Early Bids for a Hostile Takeover 

Renata’s ultimate is a slow cloud of fumes that applies a new type of crowd control called “Berserk” to enemies it passes through. Squad5 started out with a simple goal for the ability: force Renata’s enemies to attack each other. We referred to this behavior as forcing the enemies to go berserk, and with the blessing of our narrative and localization teams, the name stuck.

As an initial prototype, Squad5 had put together a nifty little hack to “taunt” enemies onto an invisible minion at their ally’s location. The spell tracked the damage dealt to the minion, then applied that damage to their ally. This simulated a baseline for what he wanted Renata’s ultimate to be, alongside a basic goal: Renata makes her enemies fight each other. 

Although his prototype helped us validate that gameplay goal, it wasn’t sufficient to reach that goal, so engineering was pulled in to figure out how we were actually going to make this work. To reach a shippable quality, we knew we would need to ensure a few things like:

  • Berserk champions apply on-hit effects and other modifiers when attacking allies. 

  • Kill and assist credit is attributed to Renata Glasc when a unit kills an ally.

  • Designers can create and easily modify how a Berserk unit prioritizes which unit to attack.

In Which I Explain the Hurricane Vayne Ally Pain

When you first imagine the idea of allies attacking each other, your mind goes to whatever would be most effective (or funny), so I immediately started looking at Vayne’s Silver Bolts. League of Legends scripts (which power actions in-game) operate in an event-based buff system. All logic lives within buffs that have owners, and all buffs have their logic split into discrete events with specific triggers. Silver Bolts, for example, is run mostly by a buff named VayneSilveredBolts that lives on Vayne. This buff has an event called OnDealHit that runs all of the logic contained within any time its owner (Vayne) hits a target with a basic attack. 

This functionality doesn’t play nice with our original prototype: it was simulating an attack by applying damage to a minion instead of actually forcing Vayne to attack, so it would never trigger the OnDealHit event for Silver Bolts. A similar issue occurs with any buff that contains an OnLaunchAttack event, since that won’t fire without a real basic attack either. An example of a buff that uses OnLaunchAttack is Runaan’s Hurricane, so our prototype wouldn’t be able to fire Runaan’s extra bolts. So our Vayne with Runaan’s Hurricane (I know it’s not good on her, but it rhymes) may deal her attack damage to allies, but most of her actual threat exists in other systems that we need to support for Berserk.

Runaan’s Hurricane also raises another issue: who should the Runaan’s bolts fire at and how were we supposed to make that work? We can’t just go into every spell or item that interacts with attacks and add logic just for Berserk. That’s a never-ending job and not a particularly fun one. Whatever we did with Berserk, it had to work naturally with the scripts as they already existed. So toss another problem on the heap.

Diagrams used to help illustrate our options for Runaan’s interactions.

Never Underestimate the Complexity of the Scout’s Code

So now we have a solid understanding of what issues we need to tackle in the moment of the attack, but what about everything that comes afterwards? Once you Berserk a champion, they aren’t going to stay that way forever, but some of their attacks could have lasting consequences. Let’s take Teemo for example. The little rascal puts a poison on whoever he attacks that lasts for several seconds, meaning it could theoretically kill one of his allies well after Teemo is no longer Berserk. Now our kill attribution requirement has gotten significantly more complicated, as the simple fix of “if you get a kill while Berserk then give it to Renata” is no longer viable.

I Mean, It’s One Refactor. How Long Could it Take? Ten Months? 

Now that we have a good understanding of all our problem spaces, it’s time to start investigating solutions. First we need to actually allow allies to attack each other while Berserk. We have no issues telling a unit to attack their ally, and the unit will even move into range to attempt to attack, but once they get to that stage they’ll just kind of…sit there. Enter the AutoAttack file and its Start function. This function is responsible for everything that occurs when a basic attack begins firing, and if we take a look inside…

Yeah, that’ll do it. The first thing this function does is check to see if the target is on your team, and if so, it shuts the whole thing down. Luckily, we have something called a Character State that tracks all sorts of things like CanAttack, CanMove, and all of our Crowd Controls like Stun! Sounds like a perfect place to add whether or not a unit can attack their allies, and we’re already making changes in there to track whether or not you’re Berserk, so we’re able to launch a big ol’ boulder at all these problem birds.

 

Problem solved! Now we only shut down the attack if the target is on the same team as the unit and the unit is unable to attack allies, which we can turn on/off when they enter/exit the Berserk state. Similar changes were required for other systems, like allowing units to crit their allies, so we had to hunt those down as well. But now the easy work is done, and the worms are out of the can.

The Origins of a Solution

On to the hard part. We now have Berserk units attacking their allies and applying all kinds of wild effects, but at the end of it all they’re also getting gold for the deaths of their friends. Meanwhile, Renata Glasc gets nothing. Sounds like a pretty bad deal for a successful business woman to make. We needed a way to trace a source of damage back to its origin to see if it came from an attack fired while a unit was Berserk. Luckily, this is very similar to a system already present in League: Spell Origination!

Spell Origination tracks parameters like a spell’s Cast ID, Cast Time, and Spell Slot for the entirety of its lifetime. These parameters are passed through any extended effects of the spell, which is what allows a rune like Electrocute to only stack once per button press, even if a spell has knock-on effects such as Brand’s Blaze passive. Spell Origination is created during the initial spell cast, and it turns out that at this stage we already know if the spell is a basic attack. Sounds like the perfect hook!

Each unit in a game of League has a network ID that allows it to be tracked and referenced easily, so this was the perfect data to pass around. When a unit enters the Berserk state, we now store the network ID of the Berserk Instigator (the unit who applied the debuff – in this case, Renata Glasc). At the start of an attack, when the Spell Origination is created, we grab that ID and bundle it in with the other Origination parameters as the BerserkInstigatorID, or just leave it as zero if there isn’t an ID present. This allows us to perform two important functions:

  • The Instigator can be accessed at any point during the spell’s lifetime.

  • The presence of a non-zero BeserkInstigatorID can be used in lieu of a boolean to tell if the source of the current event came from a Berserk attack or not.

From here, it’s as simple as hooking into the kill attribution flow and checking against our BerserkInstigatorID. If a valid ID is present, we override the killer with the unit referenced by the ID. Now even if Teemo’s poison kills an ally well after he is no longer Berserk, we still know that the original application of that poison came from a Berserk attack and can correctly credit Renata Glasc as the killer.

Proc You Like a Berserk Vayne

Now that we have all of this working, our Hurricane Vayne can attack an ally, deal damage from her Silver Bolts, get the kill, and Renata will get all the credit. The same will happen with any of Runaan’s extra bolts, assuming they fire at its owner’s enemies. But Runaan’s wasn’t firing at enemies, so we had another problem. 

Runaan’s searches for nearby targets within the OnLaunchAttack event in its scripts, using something known as a ForEachUnit check. These checks have several different permutations, but they all essentially boil down to a radius to check in and a list of types of valid targets. The check in Runaan’s Hurricane is searching within a radius of the attack’s target to find the two closest enemies. Even though Vayne is Berserk and can attack her allies, they’re still her allies. So Runaan’s wasn’t registering them as valid targets for its effect.

As I mentioned way back at the beginning, we can’t just go around modifying every script that performs checks like this. We need a solution that just works out of the box. Luckily, our Spell Origination solution also helps here! Area checks and pretty much all targeting checks in League of Legends end up in a single shared function that lives within the aptly named TargetHelper file. This check takes in the list of valid target types, such as EnemyChampions, as well as a few other flags to check if the target is targetable or invulnerable. Because it’s so broadly shared, it’s the perfect place to check for Berserk!

The affect type flags for spells and area checks are stored as bitfield enums, with each bit representing a flag. The solution boiled down to checking if the BerserkInstigatorID is non-zero, and if so, performing a few bitwise operations on the flags to mirror enemy and ally flags. If the bit for EnemyChampions is true, then set the flag for AllyChampions to true as well, all the way down the line. Now when Runaan’s Hurricane, Sivir’s Ricochet, or Volibear’s The Relentless Storm search for nearby targets, they will also include allies if the attack was launched while Berserk! No script changes necessary.

Stop Hitting Yourself

A fun side effect of all these changes manifested once we started testing the true implementation of Berserk. Things like Tiamat’s splash damage were now properly affecting allies while the attacker was Berserk, but you know who also counts as your ally? Yourself! So if Renata hit you with her ultimate, suddenly your Tiamat attacks would damage you as well as everyone around you.

The solution to this problem already existed, as one of the affect type flags discussed in the previous section is one called NeverSelf. It’s pretty self-explanatory: whatever checks performed with this flag enabled would never return the owner as a valid target. It just so happened that this flag didn’t matter for most effects before because they were only searching for enemies. This issue was very simple to fix, but required us to go through and set the flag in pretty much every check where it had been ignored before.

Some of you may be thinking “Hold on a second, you said one of your goals was to avoid changing a bunch of scripts!” You are correct and I’m flattered you’ve been paying such close attention, but there’s a key difference here. The work done for these flags involved going back and fixing a problem that had always been lying dormant. So in this situation, we were reducing tech debt rather than increasing it by adding specific script logic for Berserk. Sometimes you have to do this kind of cleanup in order to open up exciting new opportunities.

Investing in Designer Futures

Hooray! We’ve done it! Berserk is working and meets all of our functional requirements! But that doesn’t mean anything if designers can’t implement it and easily make tweaks for testing. The final step was getting the tools to do so into the hands of non-coding people.

Crowd control application in League is well over a decade old at this point, and to be frank it’s quite a mess. When implementing an ability, a designer must specify that a buff being applied is a certain buff type (BUFF_Stun for example), then call a specific chunk of logic like ApplyStun, and then also make sure to constantly update whether or not a unit is able to attack or move. Missing any of those steps means the stun won’t work. This involves lots of different files talking back and forth in multiple different coding languages just to make sure a player can’t move for a few hours when hit by Dark Binding. Not great. 

For Berserk we had an opportunity to build something better. During last year’s Dr. Mundo rework, Iris “NyanBun” Zhang (Mundo’s engineer and current Champions team engineering manager) and I had done some brainstorming on what a more ideal crowd control system would look like from a designer’s perspective. This came pretty late in the development cycle, so there was only time for a proof-of-concept refactor of the Charm crowd control before Mundo’s release. 

Since we planned around Berserk from the onset of Renata Glasc’s development, we were able to budget some time to build the new crowd control system as a stretch goal.

In the new system, all that needs to be done is setting the buff to type BUFF_Berserk and the code handles the rest from there. From a designer’s view, the new system is much simpler than the previous one! Once we validated that this system worked for Berserk, we also moved Charm over as well to make sure converting existing crowd controls was a viable option. The refactored Charm went to live servers with patch 12.3, and hopefully nobody has noticed a difference!

The second part of designer authoring for Berserk involves the ability for a designer to easily adjust the targeting priorities for the system. If these priorities are all hard-coded into the system logic itself then only engineers can access them and a bottleneck is created for iteration. For this we largely copied an interface designers were already familiar with from setting up spell targeting and copied it into a new system called AICCBehaviors. Here designers are able to create sets of targeting parameters and an associated acquisition range, then order these sets so that the Berserk system goes through them in order while searching for the first valid target. Allowing designers to make quick changes to this system saved a lot of time as they were able to test different settings and fix bugs without having to wait on engineers to have spare time to help.

We’ve Renata Time…

And that about covers it! There were several other issues we ran into, such as some empowered attacks that were actually not attacks at all, or champions like Urgot who acquire targets around them automatically, but those are stories for another tech blog. I hope you enjoyed this look behind the curtain of Renata’s noxious fumes as much as you enjoy watching the enemy team’s fed Caitlyn headshot her “support” Lux!

Posted by Jeffrey Doering