Combat DX - Combat Extension
Project Overview
Brief: Originally written in Lua then completely redone in C#, this mod adds a style grading system to the combat of Sonic Frontiers, rewarding varied combos while also retooling the combat system to make fights more engaging without becoming overly slow.
Sonic Frontiers was notably the first Sonic game in 14 years with combat that went beyond "press button to insta-kill enemy."
At the time of release, I had been developing a mod for a different Sonic game that added in-combat scoring for combos, so when faced with a new title that both heavily integrated Lua and had a proper combat system, I set to work on writing a port.
This would go on to become its own unique project, and it remains one of the most downloaded mods for the game, with nearly 18,000 downloads.
While plenty of combat mods were promptly released for the game, they often prioritized extending fight length via higher health and lower damage rather than changing how the player approached battles. For my part, I wanted longer fights, but I also wanted to highlight the oft-overlooked strengths of the system the developers had put so much time into.
For my mod, I took cues from character action games like Devil May Cry. I wanted stylish combos to be rewarded both extrinsicly with a ranking system and intrinsicly by speeding up combat. That way, each new ability would feel like a new tool in the player's repertoire (rather than a different flavor of insta-kill), and mastery of combat would preserve the game's snappy pace.
To that end, these were my two general goals:
- Devil May Cry-esque Grading: Every attack should grant invisible score contributing to a visible rank.
- Repeated attacks "stale", granting less score, while "fresh" attacks unstale other moves.
- Specific score is invisible due to technical limitations (no way to display a meter smoothly). Instead, a letter grade is given using the built in notification function.
- Score, and thus rank, decays over time. At the end of combat, exp is awarded based on score.
- Snappy combat: It's fine if mindlessly mashing buttons is ineffective, but a skilled player should be able to tear through enemies.
- Speed should be directly tied to combo performance. This provides tangible improvement to the player as they progress through the game.
- Existing attack speed or inputs should not be adjusted unless absolutely necessary. Guide the player towards learning advanced techniques like animation canceling instead of simple mashing.
The initial release of Combat DX happened while modding for Frontiers was still in its infancy. At the time, Lua seemed like the best option as another member of the community had just published a framework for injecting new Lua-callable functions (written in C++) into the game.
Being very inexperienced with the this process at the time, I reached out to another community member for help, and we were able to add functions for getting/setting various combat attributes, getting the player's animation name, and detecting whether they were in combat. With that, I began researching how the game handled Lua.
After a bit of digging, I found Frontiers has a function that spins up an entirely new instance of Lua then shifts control over to it until an Exit
function is called. Notably, this truly is a new Lua instance. Any global variables are set to their declaration, and any changes made to them do not persist in the original instance. This was helpful as it meant I could just store my code in one script then run the function on it to guarantee a fresh state for every combat encounter.
Unfortunately, I also discovered that the game does not implement Lua's table library. While not a major issue, it did mean my code would have to be structured around parallel arrays or lookup tables when it came to Scoring and the like.
Lua was kept as the base until the game's final content update released which brought with it 3 new playable characters, each of which would require support for my mod's systems. By this point, Frontiers modding had grown to include a robust scripting system in C# which allowed for far more control over the game with far less effort than code injection paired with Lua, so I finally bit the bullet and did a full rewrite.
This let me improve much of the code's structure while also adding new features, but generally the design of each mechanic remained the same. This will be talked about in more detail in a later section.
While conceptually this step was easy enough, implementation had two roadblocks: How to detect an attack and how to grade it.
Even though I could check if the player was in combat, I didn't have a way to tell if they actually struck an enemy, much less the specific attack used.
My initial thought was to check the player's state and work off of that (practically every ability in Frontiers is guaranteed to hit the target),
but the game's state system didn't make that practical as several actions can share the same base state.
Instead, I chose to go by animation name. This had the extra benefit of letting me track how long a player stayed in a move (thus better inferring whether they attacked the enemy or canceled it early) as most abilities have _START, _LOOP and _END animations.
As for the grading system, I assigned each move a table holding an array of 5 (decreasing) values along with a field for indexing the array based on how "stale" the attack is.
These tables then get stored in an array which is accessed by a lookup table pairing the player's animation to an index, effectively letting multiple types of attack share the same staling.
The code in Lua looked something like this:
AnimLookup = {
PUNCH = 1,
KICK = 1,
CRASHER = 2,
--... And so on
}
MoveTable = {
[1] = {5, 4, 3, 2, 1, count = 1},
[2] = {7, 5, 3, 2, 1, count = 1},
}
Putting the two together, this was the general process:
- Player uses a standard Punch against an enemy.
- Get MoveTable index (
my_idx = AnimLookup[player_animation]
). - Increment a Score variable (
Score += MoveTable[my_idx][MoveTable[my_idx].count]
). - Increase that move's
count
field by 1. - Iterate through MoveTable, reducing the
count
field of all other moves by 1.
The actual grading calculation and implementation would get enhanced and refined over the mod's lifetime, but this loop of "Animation -> Index -> Value -> Update Staling" would remain the same.
Reconciling low damage with fast combat may seem difficult, but Frontiers actually offered a solution right out of the box.
Phantom Rush is one of the first abilities the player unlocks, and it adds a meter which drains over time and fills when the player deals damage.
Once full, every attack deals 20% extra damage. Even though this is presented as a static boost, every move supports its own PR modifier,
so I decided to center my combat around this mechanic.
I dramatically lowered the base damage of the player's moves but provided a high multiplier for PR (with certain moves being designated as "finishers", possessing even higher damage).
Furthermore, I removed natural Phantom Rush gain and modified my code to fill the meter whenever the player earned score.
This way, combat would have to be varied to fill the meter and thus end fights in a timely manner, which ties directly to the idea of stylish combat.
However, while testing this I found it didn't work well in the game's Titan fights. These are large scale, spectacle events at key parts in the story where Sonic transforms into Super Sonic and fights a behemoth.
All the normal combat rules and systems are still at play, so technically everything worked, but for such climactic moments, they didn't feel special.
Furthermore, the Titans are largely immune to combos; there's plenty of downtime in these fights which meant trying to charge Phantom Rush and maintain a good score quickly became tedious.
Eventually, I found an elegant solution to both these problems by turning Phantom Rush from a depleting damage buff into a "special move" meter.
Instead of draining over time, PR would now increase over time, with the rate rising at higher Style rankings. Once full, the meter would not deplete on its own.
Instead, each attack would consume a set amount of it to deal massive damage.
Getting hit would still reset the bar, so the player would have to remain careful,
but inexperienced players would now effectively have a win condition via defensive play while veterans could dramatically speed up the battle through risky, aggressive combat.
It's inevitable in action games that the player will find a series of moves which look very cool but aren't worth doing outside of showboating. I wanted some way to reward player creativity, but simply applying buffs for "cool" combos was far too loose of an idea to be practical.
That's when I turned to the game's enemies.
Any good character action game has enemy behavior be a crucial part of the fight. Ideally, each encounter has both the player and their opponent taking turns to deal damage, with player skill affecting how long their turn lasts.
Since Frontiers makes it pretty easy to combo enemies indefinitely, I wanted to encourage the player to let enemies attack.
By combining this with the previous idea, I came up with the "Skill Link" system.
- After a successful Parry, your next three abilities are tracked.
- Once a link finishes, a temporary buff will be applied (e.g., faster meter/score gain, an immediate ring injection, preventing meter decay, etc.)
- The first move determines the type of buff.
- The second controls the potency.
- The third handles duration.
- If the buff type isn't duration based, Potency and Duration control its strength.
- Links can be extended by finishing them with a Quick Cyloop, allowing 3 more abilities to be used and enhancing the buffs available.
- This also increases the number of Quick Cyloops the player can use consecutively.
- Links start at level 1 and cap at level 3.
- Quick Cyloop was chosen as the extender since it's a quick, meter-limited ability that either launches enemies upward or downward, or disables their gimmicks.
This allowed me to easily reward any combination of attacks while still providing some consistent rules for the player to follow. It's far from a mandatory system to enjoy regular fights, but it provides an extra layer of engagement for those who want it.
This mechanic really shines in the Super Sonic fights, however. While the player is invincible as Super Sonic, their rings gradually drain over time, with the fight immediately ending in defeat if the count hits 0. Normally not an issue, this became a very real threat in Combat DX (especially after scaling starting rings by difficulty) as the encounters are prolonged.
Sonic Frontiers' final content update brought with it 3 new playable characters, each with their own unique gameplay and attacks. Through some datamining, I'd managed to uncover and load all three characters early in the game's penultimate update, at which point I realized I'd have to leave Lua behind.
By this point, the game's mod manager supported "HMM Codes", scripts written in C# and leveraging libraries written by other community members.
This brought along with it a slew of features that never made it to the Lua Extensions project, including input detection, hot-swapping character parameters, and more.
By swapping to C#, I would be able to do far more than I could in the old Lua framework.
Luckily enough, these scripts even supported calling Lua functions through C#. It wasn't a flawless system - anything involving a coroutine yield call wouldn't work - but it did speed up the process a bit as I could still my DLL Lua functions.
Since the mod would now have to support 4 unique gameplay styles (5, counting Super Sonic), I decided to use a more object oriented approach.
Mechanics such as score points, skill links and ranks were separated into their own classes, with a base character class grouping them all.
Each character then became their own class (inheriting from the base), leaving me free to adjust properties as needed.
Any system that wouldn't need to change between character (like storing and adding score), was handled statically. Even though the base game doesn't support swapping characters mid-combat, a patch was promptly released which enabled that behavior, so I tried my best to accommodate it.
Overall, the C# rewrite was an improvement, but it wasn't without its flaws. For instance, resetting state between fights in Lua was trivial due to how scripts were instanced. With HMM Codes, however, everything is constantly active; there was no clean way to reset variables in the script, even when loading a new save file, so it took some trial and error to write effective checks.