Building a turn-based combat system threw a lot of interesting problems at me, but my first boss fight was this: how do you support an ever-changing roster of abilities and effects—damage, status conditions, reactions, etc.—without rewriting and redeploying code a million times per playtest? That’s the problem I’ll walk through in this post, plus a bonus problem that’s even more fun—one I’m hoping to solve in my latest game.

A few years ago I was building a live-service turn-based game from scratch at a startup. This was pre-ChatGPT, so my resources were conflicting designs from sparse devblogs and educational videos pulled from different games with different goals. I’d come from about five years of building mobile UI components and REST endpoints that piped big-tech data streams around, so none of it had prepared me for game combat.

My initial solution was a state machine driven by player decisions, plus abstract data structures representing the possible state changes—abilities, effects, status conditions, and so on. I mostly got there by imagining how the turn-based games I was playing at the time (Magic: The Gathering and Summoners War—don’t ask how much I’ve spent) would have been implemented.

Naive Solution: Hardcoding the Engine

The naive first version was to hardcode characters’ abilities as concrete implementations of those abstract data structures. Character #123, ability #1: a “deal damage” effect whose “damage” property is a method that calculates 120% of the character’s attack stat.

“Why worry about scaling?” we told ourselves. “How many abilities do we even need at launch?”

After the first few playtests, it was obvious this naive solution couldn’t even make it past internal testing. Abilities were being tweaked multiple times per playtest—not just per game update—and new, more complicated effects kept getting added. Our designer had to wait on me to write and test every change he came up with.

The Pivot: Abilities as Data

So the fun part became: how do I migrate away from hardcoded IDs?

I flipped the question. Instead of asking “what does character #123 do?”, I started asking “what are abilities made of?” The natural answer I came up with was three kinds of building blocks:

- The effect: The actual thing that happens. “Deal damage.” “Apply a status condition.” “Heal.” I made these real classes (well, implementations of interfaces): - DealDamageEffect,- ApplyStatusConditionEffect, etc. An ability became a list of them, resolved in a specific order.

- The value evaluator: To produce a number. “150% of the owner’s attack stat”, “forty plus half the target’s max health”.

- The condition evaluator: To make the effect dynamic. “Is the target below half health?”, “did this ability crit?”, “is the owner stunned?”

So an effect was roughly: if this condition is true, do this action using this value. “Deal damage equal to 150% of my attack” is one effect. “Then, only if the target dropped below half health, apply defense-down” is the next.

The state machine I’d already built got one job: walk through the list of effects and execute them. It didn’t need to know what any given ability did, because the ability was no longer code. It was data—a row in a database, assembled from those blocks. The status condition itself (defense-down, poison, whatever) was also just a row in a table, not a class.

Getting all of that into a database—and reading and writing it within the context of a multiplayer game—was a rabbit hole of its own. If that sounds interesting, remind me to write a post about it. I used MongoDB by the way, which was a natural fit.

Which meant the whole thing became a type-safe pseudo-language, and designers wrote the programs. New abilities stopped being my Jira tickets and became the designer’s. He started stringing effects together and testing as he went, no engineer involved, mostly.

That was the payoff, and it felt great. The scary part was still ahead of me.

Where Trouble Got Deeper: The State Machine Trap

The data side worked well enough and we were happy with it for the most part. The engine was another story.

The engine was really one large state machine with a bunch of smaller state machines nested inside it—one per effect, one per ability, one per turn phase, all stacked up. For most abilities, that model fit fine. A straightforward damage ability flows through a predictable sequence of states: pre-damage calculations, apply damage, resolve on-damage effects, end.

The problems showed up with abilities that had weird timings and triggers. Counters. Reactions. Effects that fire after something else dies. Abilities that interrupt another ability mid-resolution. Suddenly there was no clean boundary for where one state ended and the next began. Two state machines would fight over the same transition, and I’d bang my head against the desk trying to figure out whether the game was in “applying damage” or “waiting for the trigger.”

A few of these forced partial rewrites of the engine. And when the state did get corrupted—which it did—the bugs were genuinely hard to trace. The system was a messy stack of overlapping state machines that only I understood.

It wasn’t just the engine, either. The pseudo-language I was so proud of got unwieldy as it grew. Every new effect type and evaluator was a little harder to write than the last, because each one had to play nice with everything that came before it. What started as a clean little vocabulary turned into a pile of bespoke rules you either knew by asking me about it, or you spent a day reading documentation and then asked me about it anyways. Had we employed a few more designers, they’d have spent days just learning our made-up language—the kind of proprietary learning that doesn’t scale.

The Event Bus Detour

At some point I actually started rewriting the engine to be an event-based system using event-bus and pub-sub, patterns I knew well from my mobile dev days. The idea was seductive: instead of a stack of nested state machines, effects would just fire events—“damage dealt,” “target dropped below half health”—and anything that cared would subscribe and react. No more two machines fighting over the same transition.

The problem, in hindsight, is obvious: a plain event bus doesn’t promise anything about ordering, and in turn-based combat order is everything. Does the defense-down resolve before or after the damage? Whatever subscriber got there first. And because one handler firing an event can trigger another, you get cascades that fold back on themselves—a counter that counters a counter, a loop you only notice when the battle soft-locks mid-resolution.

My memory of the specifics is honestly fuzzy now since it was never finished, but the conclusion was that fire-and-forget wasn’t a good fit.

The Next Iteration

The lesson was one I learned from college textbooks and corporate meetings but finally felt the sting of intimately: flexibility breeds complexity, and complexity breeds bugs. Fully hardcoding abilities was never an option—not with how fast the designs were changing—but there’s probably a better solution between “all code” and “all data” than the one I’d found.

So what would I do today, after a bit more system design experience and AI-assisted learning? An event-driven state machine—one where events queue up state changes, and the machine advances once the queue drains. That’s the piece the plain event bus never gave me: ordering, and a clear “everything’s settled” moment.

I’m pretty confident it’s a better direction than anything I did before and I’m kind of embarrassed I didn’t make the natural leap myself back then (startup stress I guess but excuses, excuses). In any case, I haven’t built or tested it yet so maybe I’m speaking too soon. Ask me again after the next game ships.