Engine-Agnostic Combat System Implementation — A 7-Step Workflow for Phaser, Unity, Godot, and Unreal
If you are building a combat system that needs to run on more than one engine — or if you want to prototype in Phaser and ship in Unity — the core problem is the same: how do you keep combat logic from getting tangled with engine APIs? This guide gives you a 7-step workflow and a three-layer architecture that makes the logic reusable and the engine layer thin.
Who This Helps
- Developers building for multiple platforms (web + native) who need the same combat behavior
- Tech leads who want automated balance tests that run without booting an engine
- Teams prototyping in Phaser and productionizing in Unity or Unreal
- Anyone shipping combat logic as an open-source package or asset store product
The Problem
| Problem | Description |
|---|---|
| Engine-entangled combat code | Combat logic is tangled with rendering and physics APIs — porting to another engine means full rewrite |
| Balance re-verification | Switching engines changes damage formulas, timing, and hitbox behavior — balance must be re-tested |
| Team bottleneck | One combat programmer knows the engine-specific code — nobody else can contribute |
| No unit tests | Combat logic depends on scene/node/behavior — automated testing is impossible |
The core argument: if you separate the pure logic layer from the engine layer, the logic is reusable across any language, and the engine layer becomes a thin adapter at only 5–15% of the total codebase.
Core Idea — Three-Layer Architecture
┌────────────────────────────────┐
│ Engine Layer (adapter) │ ← Phaser/Unity/Godot/Unreal specific code
│ - rendering, physics, sound │ (5–15% of total code)
│ - I/O bridge │
├────────────────────────────────┤
│ Game API Layer │ ← engine-neutral interfaces
│ - ICombatEntity, IDamagePipe │ (10–20% of total code)
│ - event bus interface │
├────────────────────────────────┤
│ Pure Logic Layer │ ← math + data, no side effects
│ - damage formulas, status, AI │ (65–85% of total code)
│ - balance tables, probability │
└────────────────────────────────┘
The pure logic layer does not import any engine. If UnityEngine, phaser, or godot appears in this layer, the separation has failed.
The 7-Step Workflow
Step 1: Define Engine-Neutral Combat Interfaces
// combat-core/interfaces.ts — no engine imports
export interface ICombatEntity {
id: string;
hp: number;
maxHP: number;
stats: Readonly<CombatStats>;
statusEffects: ReadonlyArray<StatusEffect>;
team: Team;
position: Vec2;
hitboxRadius: number;
}
export interface CombatStats {
attack: number;
defense: number;
speed: number;
critRate: number;
critMultiplier: number;
}
export interface IDamagePipeline {
calculate(rawDamage: number, source: ICombatEntity, target: ICombatEntity): DamageResult;
apply(result: DamageResult, target: ICombatEntity): void;
}
export interface IEventBus {
emit(event: CombatEvent): void;
on<T extends CombatEvent>(type: string, handler: (e: T) => void): void;
off(type: string, handler: Function): void;
}
Step 2: Implement Pure Logic
// combat-core/damage-pipeline.ts — math and data only
export class DamagePipeline implements IDamagePipeline {
constructor(private bus: IEventBus) {}
calculate(raw: number, source: ICombatEntity, target: ICombatEntity): DamageResult {
let damage = raw * (source.stats.attack / 100);
damage = damage * (100 / (100 + target.stats.defense));
for (const effect of source.statusEffects) {
damage *= effect.damageMultiplier;
}
const isCrit = Math.random() < source.stats.critRate;
if (isCrit) damage *= source.stats.critMultiplier;
damage = Math.max(1, Math.floor(damage));
return { damage, isCrit, source: source.id, target: target.id };
}
apply(result: DamageResult, target: ICombatEntity): void {
target.hp = Math.max(0, target.hp - result.damage);
this.bus.emit({ type: 'damage-applied', ...result, remainingHP: target.hp });
if (target.hp <= 0) {
this.bus.emit({ type: 'entity-defeated', entityId: target.id });
}
}
}
Step 3: Implement the Engine-Neutral Event Bus
// combat-core/event-bus.ts — pure implementation, no engine dependency
export class CombatEventBus implements IEventBus {
private handlers = new Map<string, Set<Function>>();
emit(event: CombatEvent): void {
const set = this.handlers.get(event.type);
if (set) for (const h of set) h(event);
}
on<T extends CombatEvent>(type: string, handler: (e: T) => void): void {
if (!this.handlers.has(type)) this.handlers.set(type, new Set());
this.handlers.get(type)!.add(handler);
}
off(type: string, handler: Function): void {
this.handlers.get(type)?.delete(handler);
}
}
Step 4: Write Engine Adapters
Phaser 3 adapter:
export class PhaserCombatAdapter {
constructor(private scene: Phaser.Scene, private core: DamagePipeline) {}
syncEntity(sprite: Phaser.GameObjects.Sprite, entity: ICombatEntity): void {
entity.position.x = sprite.x;
entity.position.y = sprite.y;
}
onDamageApplied = (event: DamageAppliedEvent) => {
const text = this.scene.add.text(event.x, event.y - 20, `-${event.damage}`, {
fontSize: '16px', color: event.isCrit ? '#ff0' : '#fff'
});
this.scene.tweens.add({
targets: text, y: text.y - 40, alpha: 0, duration: 800,
onComplete: () => text.destroy()
});
}
}
Unity C# adapter (pseudocode):
public class CombatMonoAdapter : MonoBehaviour {
private DamagePipeline core;
void Start() {
core = new DamagePipeline(CombatEventBus.Instance);
CombatEventBus.Instance.On<DamageAppliedEvent>(OnDamageVisual);
}
void OnDamageVisual(DamageAppliedEvent e) {
var dmgObj = Instantiate(damageTextPrefab, transform.position, Quaternion.identity);
dmgObj.GetComponent<Text>().text = $"-{e.damage}";
dmgObj.transform.DOMoveY(transform.position.y + 1f, 0.8f)
.OnComplete(() => Destroy(dmgObj));
}
}
Step 5: Balance-Test Without an Engine
// Runs in Vitest/Jest — no engine boot required
describe('DamagePipeline', () => {
it('reduces damage against high defense targets', () => {
const bus = new CombatEventBus();
const pipeline = new DamagePipeline(bus);
const attacker = makeEntity({ attack: 100 });
const tanky = makeEntity({ defense: 100 });
const squishy = makeEntity({ defense: 0 });
const tankResult = pipeline.calculate(50, attacker, tanky);
const squishResult = pipeline.calculate(50, attacker, squishy);
expect(tankResult.damage).toBeLessThan(squishResult.damage);
});
});
Step 6: Configure Per-Engine Builds
| Engine | Build Tool | Core Inclusion |
|---|---|---|
| Phaser | Vite/webpack | Direct import (TS → JS) |
| Unity | npm + UnityFS | Port core to C# or run via Jint (JS interpreter) |
| Godot | GDNative | Port core to C++ or use Godot 4 JavaScript modules |
| Unreal | Unreal Build Tool | Pure C++ classes wrapped as a Plugin |
Step 7: Cross-Engine Balance Verification
// shared/balance-spec.ts — engine-independent verification
const BALANCE_SPEC = {
scenarios: [
{ rawDmg: 50, atk: 100, def: 0, expectedRange: [48, 52] },
{ rawDmg: 50, atk: 100, def: 100, expectedRange: [23, 27] },
{ rawDmg: 100, atk: 200, def: 50, expectedRange: [125, 135] },
]
};
// Each engine's adapter test references this spec
Engine-Specific Notes
Phaser 3
- Core integrates naturally via TypeScript
import - Hit detection:
scene.physics.world.on('collisionstart')triggers interrupt checks - Damage numbers:
scene.add.text()+scene.tweens - Unit tests: Vitest/Jest directly — no engine boot needed
Unity (C#)
- Core can be ported to C# or run via Jint
- Hit detection:
Physics2D.OverlapCircle() - Damage numbers: TextMeshPro + DoTween
- Performance: Jobs + Burst for parallelization (wrap core in
IJobParallelFor)
Godot (4.x)
- Core via GDExtension (C++) or C# classes
- Hit detection:
PhysicsDirectSpaceState2D.intersect_point() - Damage numbers: Label node + Tween
- Events: Godot Signal system bridges to core event bus
Unreal Engine (5.x)
- Core as pure C++ classes (no UObject dependency) wrapped in a Plugin
- Hit detection:
UShapeComponent::OnComponentBeginOverlap - Damage numbers: UMG Widget + Anim Notify
- Performance: Mass ECS or Gameplay Ability System (GAS) integration
Common Mistakes
| Failure Mode | Cause | Mitigation |
|---|---|---|
| Over-abstraction | Every engine feature gets an interface — adapter code exceeds core | Keep adapters as thin as possible; never put logic in adapters |
| Ignoring data orientation | Virtual calls everywhere — cache misses, poor cache locality | Profile critical paths; use engine-native optimizations for hot paths |
| Event bus overload | All combat events on a single bus — debugging nightmare, no ordering | Separate channels: DamageEvent, StatusEvent, AUIEvent |
| Porting overhead | TS core ported to C# — type/generic mismatches, double maintenance | Automate with TypeStat/QuickType; or write core in C# directly for Unity-first teams |
Checklist
- [ ] Combat logic has zero engine imports in the pure logic layer
- [ ] Damage pipeline is a pure function (same input → same output, no side effects)
- [ ] Event bus is engine-neutral and testable in isolation
- [ ] Engine adapters are under 15% of total codebase
- [ ] Balance tests run without booting any engine
- [ ] Cross-engine balance spec produces identical results on all targets
- [ ] Performance-critical paths (hit detection, physics queries) use engine-native APIs
- [ ] Events are separated by type channel (not a single monolithic bus)
References
- Nystrom, Robert. Game Programming Patterns. Genever Benning, 2014. Chapter 7: Event Queue; Chapter 14: Component.
- Ford, Timothy. "Overwatch Gameplay Architecture and Netcode." GDC 2017.
- Acton, Mike. "Data-Oriented Design and C++." CppCon 2014.
- Entitas ECS Framework. GitHub repository, 2023. (https://github.com/sschmid/Entitas)
- Phaser 3 API Documentation. 2024. (https://photonstorm.github.io/phaser3-docs)
- Riot Games. "League of Legends: Architecture." Engineering Blog, 2020.