Engineering Tools for Designers with Legends of Runeterra
Hello! We’re Patrick Conaboy and Jeff Brock and we’re senior software engineers on the Legends of Runeterra Card Design team. The engineers on our team are responsible for building tools, writing game logic, and supporting card designers. In this article, we’ll be covering how engineers on LoR built out a new scripting system that enables designers to iterate and experiment with new card ideas easily.
As we’ve mentioned in the past, our primary goal as engineers on LoR is to function as a support for designers and artists. We aim to build tools and systems that make it possible for designers to jump straight into the codebase without needing to rely on an engineer. Keeping these priorities in mind, we’ll be giving an overview of how our new card scripting system works, and diving deep into an example of a time this flexibility was critical by taking a look at a certain mushroom enthusiast…
Building Out Our New Scripting System
Back in the early development days, LoR leveraged a lot of tech from League of Legends to make experimentation quick and easy. During prototype, we evaluated several different strategy genre games before landing on a card game. Until we committed to a game direction, it wasn’t worth building out custom systems, so we relied on existing tech that Riot designers had familiarity with from League, like our BlockBuilder tool for designing abilities.
Once LoR took shape and began pre-production, we knew we had to come up with a new system for designing cards. Using League tech was great for early development, but we quickly ran into several limitations.
The main problem had to do with champion abilities. League had defined really clear blocks for its needs - for example, abilities can be put into a subset of blocks like inflicting a status effect, causing a knock-up, or dealing damage. But for a card game, we found ourselves needing a looser set of rules for actions, because cards often intentionally break rules by doing things like shuffling decks or completing actions at future turns.
Basically, designers would keep coming up with cool ideas, and then get bottlenecked because they needed an engineer to create a specific custom block in our visual scripting language. This turnaround became a growing limitation as the design team expanded and more designers wanted to experiment with new playstyles.
Leveraging IronPython for Flexibility
Our engineering team iterated on some ideas, and eventually landed on a scripting-based solution for designers, integrating with IronPython.
A huge benefit of IronPython is that we can access C# objects and call C# methods directly from a script. It’s the glue that connects our C# game engine to our Python card scripts. If a designer has a unique, new gameplay action they'd like to try out, they don't need an engineer to build something first.
Example: Accessing Logic
When a buff is applied to a card, designers have access to:
which card is getting the buff
which card is applying the buff
what is the buff being applied
what is the entire state of the game when that buff is applied
Let’s take a look at a specific example - the K/DA “Out of the Way” card.
In this case, a designer wants to create a card that makes all allied buffs permanent. With our Python implementation, they can see all the necessary objects directly.
Since they have all that data in their scripting language, they can listen for buffs being added, and directly change the duration with just two lines of code:
buffEffect.Duration = duration.Indefinite
For all you Python programmers out there, the "##" is how our engine understands that this card is listening for an event - in this case, EventMutateEffectBeforeAdd.
This allows us to keep that card's "allied buffs are permanent" gameplay logic out of the C# game engine by storing it in a place where designers can tweak and balance.
Example: Building Libraries
In the past, if a designer wanted certain functionality in a library, they needed to wait for an engineer to have time to build it out in the game engine. With our Python solution, designers build code libraries that live entirely within the script, so they can create entire gameplay systems without ever needing to get another developer involved.
For example, designers spun up the history system that tracks everything that happens over the course of a game. This history system is a Python script that tracks events and stores data that can be referenced later. It also exposes an API for designers to directly retrieve that data and use it in card scripts. This is important for complicated cards like champions, which often require keeping a count of values like “how many spells have been cast?”
A card that relies on the “history” value.
The tradeoff here is that designers need to be more technical - at many studios, designers only use visual scripting systems and rarely need to dive into the code. We found it incredibly helpful to work with particularly technical designers, who ended up training other designers and onboarding new hires quickly.
Each card has its own small Python script associated with it. We’re able to call Python from C# and pass along C# object references.
We also have markup in the C# code, and a separate script that generates fake Python that we use for autocomplete when the Python script writers deal with these alien C# objects. Without this bit, they wouldn't know what methods they were allowed to call on the C# objects without reading the code directly.
Finally, we wrote a plugin for Visual Studio Code that gives designers direct access to available methods. This gives them handy references for stuff like game events they might want to reference, and quick access to all other script files.
Within each card script, designers call out events they’re interested in. For example, if a card needs to make you draw a card whenever anyone takes damage, then you can have the script listen for when damage is dealt and add modifiers.
This is a very simple 2-line script file:
Note that “game” here is a reference to our C# game state that was passed to Python.
A Minion On Cards
A foundational aspect of our scripting system is how we handle card storage. Designers store everything on cards - kind of like that joke about how everything in League is a minion, everything in LoR is stored on cards. For example, the Nexus is actually a card, and it stores the history tracking we mentioned earlier. When a unit is played, the Nexus has a script that listens and increments the count on its own storage, and those values can be referenced by other cards.
You’re saying those tiny things store WHAT?
This is a hugely critical piece of how LoR stores data. Designers can synthesize entirely new gameplay mechanics without requiring engineers to build anything new, because they can trigger off of any change in the game state, store any value in card storage, and have access to all of the basic blocks of game state manipulation that engineers do (moveCard, doDamage, attachScript, etc).
Story Time: Making the Transition
Switching an underlying game system is a massive undertaking. Our new scripting system definitely lived up to our expectations, but the transition had a couple bumps along the way.
One story that really demonstrates the complexity of shifting an underlying system happened around three years ago during a big internal playtest. We had just wrapped up getting a build of the game working with Python, but those code paths were only taken when there were actually Python scripts to load. This was done so that it wouldn’t impact the playtest, which was still running on the original League block system. We kicked off a deploy of the block script build, and walked over to visit the playtest presentation in person, confident that the build was stable and the Python-based system was separate on our testing environments.
Imagine this - the presentation wraps up, right, and a crowd of Rioters are heading back to their desks to play, when someone says, “hey, uh… everything is broken.” Total nightmare material. This made no sense, because nobody had actually made any changes - we had just done a rebuild and redeploy of existing content we had tested already.
So what happened?
We have a set number of shared build nodes that we reuse, and try to only clean up what’s strictly necessary to avoid copying over duplicate assets every time. The Python builds had been running on a couple of them because we were iterating earlier, but once the Python build had run on the node, it effectively infected it, leaving the Python scripts behind. The block script-based rebuild/redeploy we did for the playtest happened to run on the nodes that had been polluted by the Python build. So some of the cards were loading Python scripts, and some of the cards were loading block scripts, and some did both so nothing would happen.
Once we remoted in and realized there was both Python and block data, it just took a few hours to patch up and the playtest went on. But it was definitely a spooky event that demonstrated the complexity involved when transitioning over an underlying system in a live game.
Our biggest takeaway? While it can be an elegant solution to use the existence of new data to control which system is activated, sometimes just having a good ol’ toggle is an important safeguard when replacing a legacy system.
Taking a Look at Teemo
Teemo and his related Puffcap mushroom cards presented complex technical challenges that really highlighted the impact of our new scripting system.
This set of cards is based on a gameplay system that exists almost entirely in our Python scripting environment. Designers are able to quickly iterate on these kinds of ideas - like Puffcaps being objects placed on existing cards instead of individual cards placed in a deck. We built up a Python library of Puffcap functions which defined a really clear API for how cards could plant mushrooms into an opponent’s deck. The Puffcap’s representation in the game, how to add them, and what they do are all defined within the Python scripting system.
Iterating on Shrooms
We had a couple Puffcap iterations as Teemo’s shrooms became cooler and cooler over time. As we moved from spells in our block system to spells in Python, designers were increasingly able to resolve bugs themselves instead of requiring engineering time.
The Puffcap cards were originally planted in your deck by Teemo or his followers, and when you drew the cards, they’d cast a spell and you’d take damage. The Teemo level 2 card doubles the number of existing Puffcaps in a deck - which is simultaneously very exciting and very dangerous - so the Puffcap cards had the ability to crash entire games if enough were placed into a deck. With the new Python scripting language, we were able to port over the Puffcap system smoothly and simultaneously do some code cleanup, making it possible to switch the Puffcaps from individual cards to traps on existing cards.
Not So Funsmith
One example of a problem with the original card iteration had to do with other cards that amplified damage. The card Funsmith from the original Foundations base set would increase any damage from spells or skills. But if you had a Funsmith and an enemy put a Puffcap in your deck, you'd be the one drawing the card - in other words, the source of the damage was technically you, so the damage would be amplified. Turning the Puffcaps into traps on specific cards gave us the chance to do some damage source redirection, which we use to clarify that this damage comes from the enemy and is not self-inflicted.
Once we had switched the Puffcaps into traps on cards, we ran into another issue - performance. The original Puffcap system would plant traps into the enemy’s deck, and the game would go through each Puffcap one by one, randomly generating a number between 0 and the deck size, and placing the trap on that card specifically. This resulted in totally random distribution, which was great.
But as we were preparing for the Friend Challenge, we realized that if two friends can play against each other, there will probably be players who want to work together to see how many Puffcaps can possibly be placed in a deck. This would cause the server to fall over due to the sheer amount of shrooms that needed to be assigned a number, so one game could feasibly crash all the other games running on that server. We tweaked the Puffcap insertion algorithm to instead go card by card in the deck and estimate how many mushrooms should be planted on each card.
An easy way to calculate this would have been to just divide the number of shrooms by the number of cards and fudge the number a bit so it feels random, even though they’re evenly dispersed. We knew that would feel really bad and go against the intended sense of randomness that Puffcap cards promise, so we looked at statistical models to figure out what a more realistically random system would be.
To accomplish this, we generate a standard distribution for how many Puffcaps should be planted on each card, and then we randomly pick a point on that curve for each card. After that, we do some slight tweaking to make sure the number of Puffcaps we placed equals the total number of intended Puffcaps.
As we went through the transition to the new Python system, we were able to do a lot of code cleanup. For example, at one point, the traps themselves were a first class citizen in the C# game engine, as they were their own type of card. For just one mechanic that was unique to Teemo and his followers, that was pretty overkill.
While removing the Puffcap cards and turning them into traps, we thought a lot about future-proofing. If designers later want multiple trap-type cards that mimic the Puffcap mechanic of actions that live on other cards in a deck, they can easily iterate on their ideas by extending the “traps” Python library we’ve built out.
One of the best parts about working on LoR is how integrated engineering and design are. The development environment is heavily collaborative, which allows engineers to get a better sense of what designers will need in the future. We can be more creative with our solutions; we’re not just handed a tech spec, we’re part of the conversation about the problem that needs to be solved.
The ratio of engineers vs designers on a card team really reflects this. Early on, we had two designers and four engineers. This shifted over time as we built out tools that could better support the designers - we now have around 15 designers and just three engineers.
Engineers on LoR are constantly measuring our progress against this goal - make designers’ lives easier. With early prototyping, it’s clear how many ways artists and designers rely on tooling tech to be able to do their jobs... and how this can slow them down. We need to build tools and processes to make things go smoothly, reducing engineering bottlenecks and encouraging creativity and experimentation. It’s their job to find the fun, and it’s our job to build enough highly usable tech that designers only have to talk to us when they want to.
Thanks for reading! If you have any questions or just want to tell us about your favorite cards, feel free to comment below.