Why “combat logic” gets messy
Most combat systems start life as a tangle: input triggers an animation, an animation triggers a hitbox, the hitbox triggers damage, damage triggers knockback, knockback triggers particles, and somewhere in the middle we also apply stamina, status effects, invulnerability frames, and critical hits. It works—until you try to change one thing and accidentally break five others.
The fix isn’t a single magic pattern. It’s a design approach: treat combat like rules and outcomes, not like a chain of events glued to visuals.
In this post we’ll build a “rules first” structure you can apply in Unity, Unreal, Godot, or any engine. The key outcome is that your combat behaviour becomes small, testable scripts: you can run them without a scene, without animations, and without waiting for a frame to tick.
The three layers: Rules, Effects, and Presentation
When combat gets hard to reason about, it’s usually because logic is mixed across different responsibilities. Split it into three layers:
- Rules: deterministic decisions like “can this attack be executed?”, “what damage modifier applies?”, “should the target be stunned?”
- Effects: state changes caused by rules, such as subtracting HP or applying a status timer
- Presentation: animations, VFX, sound, camera shake, UI popups
Rules decide what happens. Effects apply it. Presentation reacts to the results. This separation makes balancing easier (tweak rules), prevents side-effects from leaking into tests (effects are explicit), and keeps art from constraining logic.
Make “combat state” a plain data object
Before writing scripts, decide what your combat system needs to know. A common minimal state for a turn-like action game is:
- HP / stamina / energy
- position (or at least relative distance)
- defence/guard state
- active status effects (timers and stacks)
- invulnerability frames / i-frames window
- current “stance” (attacking, recovering, blocking)
Represent this as a plain data structure (a struct/class in C#, a resource in Godot, or a plain struct in C++). The important part: rules should read from it and produce an outcome without needing to touch engine objects.
For example, a rule might accept:
- attacker state
- defender state
- attack definition (damage, range, hitstun, status chances)
…and return a “combat outcome” object:
- hit confirmed or whiffed
- damage amount and damage type
- status effects to apply (with durations/stacks)
- knockback impulse parameters
- which events to emit for presentation
Keep the outcome data-driven. You want to be able to print it, snapshot it, and assert it in tests.
Use an effect pipeline, not a monolithic function
Instead of a single “PerformAttack()” that does everything, use a small pipeline:
- Validation rule (can attacker act?)
- Target selection rule (which target(s) are eligible?)
- Hit determination rule (range, facing, i-frames, guard?)
- Damage calculation rule (base damage + modifiers + randomness)
- Status decision rule (proc chances, immunity, stacking rules)
- Outcome aggregation (compose results into one outcome)
Then, separately:
- Apply effects (reduce HP, set i-frames, add status timers)
- Emit events for UI/VFX
This pipeline makes it obvious where to add features. If you’re adding “burn on fire weapons”, you likely only touch the status decision rule and the effect application for burn.
Determinism: make randomness injectable
Combat almost always has randomness: crits, status procs, damage variance. If your rules are hard to test, it’s often because you’re calling the engine RNG directly.
Solution: inject a random provider into the rule pipeline. In unit tests, feed a predictable sequence.
Even if you’re not going fully deterministic for networking yet, deterministic combat outcomes are still valuable for QA and balancing.
Example approach (conceptual):
Outcome EvaluateAttack(attacker, defender, attackDef, IRng rng)
In production, implement IRng with your engine RNG. In tests, use a fixed-seed or pre-recorded sequence RNG.
Define attack and status as data
To keep combat rules flexible, model attacks and status effects as data. In Unity, that might mean ScriptableObjects; in Godot it might mean Resources; in Unreal it might mean DataAssets or data tables. The engine choice doesn’t matter as long as:
- attack definitions are immutable during resolution
- status definitions contain stacking and duration rules
- rules read from definitions and write only to combat state via effects
When attack tuning changes, you shouldn’t rewrite logic. You should edit data.
Keep presentation out of rules (and out of tests)
A classic failure mode: rules directly trigger animations or spawn VFX. That makes your “rules” untestable because they require a scene, and it creates timing problems (“did we spawn the hit effect before damage applied?”).
Instead, have rules output intent:
- event “OnHit” with damage summary
- event “OnStatusApplied” with status id, stacks, duration
- event “OnWhiff”
Presentation listens to those events and does the visuals. In a headless test, you simply ignore the listeners and assert the outcome/state.
Practical example structure (pseudo-code)
Here’s a concrete way to structure the call flow, engine-agnostic:
function ResolveAttack(attackerState, defenderState, attackDef, rng) -> Outcome
if not ValidationRule(attackerState, attackDef):
return Outcome{ success=false, reason="invalid" }
targets = TargetSelectionRule(attackerState, defenderState, attackDef)
for each t in targets:
hit = HitRule(attackerState, t.state, attackDef)
if not hit.confirmed:
continue
damage = DamageRule(attackerState, t.state, attackDef, rng)
statuses = StatusRule(attackerState, t.state, attackDef, rng)
outcome.addHit(t.id, damage, statuses)
return outcome
function ApplyOutcome(combatState, outcome)
for each hit in outcome.hits:
combatState.defender.hp -= hit.damage.amount
for each status in hit.statuses:
combatState.defender.statuses.addOrRefresh(status)
return combatStateUpdated
This is deliberately boring. Boring is good: it’s predictable, and it’s easy to test.
Testing strategy: assert outcomes, then spot-check integration
You don’t need a huge test suite to get value. Start with “pure” tests for rules.
- Damage modifiers: verify armour/guard reduces damage as expected
- Status rules: verify immunities and proc chances using injected RNG
- Guard / i-frames: verify invulnerability prevents damage application
- Stacking: verify refresh vs stack behaviour
Then add a small number of integration tests (or manual test scripts) to ensure presentation events are emitted and effects are applied in the right order. Keep those tests sparse. The majority of your correctness should live in the pure rules.
Balancing workflow: use “outcome diffs”
Once combat is data-driven and rule outputs are explicit, balancing becomes iterative. A useful habit is to compare outcomes before/after changing a parameter.
For example:
- Change crit chance from 10% to 12%
- Run a fixed-sequence RNG test (same inputs)
- Compare the emitted outcomes: crit occurrences, damage totals, and status procs
This catches unintended knock-on effects—like “crit also increases status proc chance” if you accidentally tied them together.
Where to go next
If you want to level up further, consider:
- Ability cooldown rules treated like any other validation rule
- Area-of-effect resolution as a separate target selection strategy
- Network readiness: keep resolution deterministic and replicate outcomes, not every intermediate step
For engine-specific approaches, the general principles map cleanly onto most stacks. If you’re using Unity, it’s worth reviewing how data assets and unit testing fit together; if you’re using Godot, resources and signals can help with presentation separation; in Unreal, data tables and gameplay-style eventing can do the same job.
If you’d like a starting point for general engine guidance, see:
Rule of thumb: if you can’t write a unit test for it without loading a scene, it probably lives in the wrong layer.
A final checklist
- Rules read state + definitions and return an outcome.
- Effects apply outcome to state in one place.
- Presentation listens to events; rules don’t spawn VFX.
- RNG is injected so tests are repeatable.
- Attacks and statuses are data, not hard-coded branches.
Once you have this structure, you’ll find combat tweaks stop feeling like surgery. They become parameter edits, and when something breaks, you’ll know exactly which rule did it.