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

The Problem

ProblemDescription
Engine-entangled combat codeCombat logic is tangled with rendering and physics APIs — porting to another engine means full rewrite
Balance re-verificationSwitching engines changes damage formulas, timing, and hitbox behavior — balance must be re-tested
Team bottleneckOne combat programmer knows the engine-specific code — nobody else can contribute
No unit testsCombat 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

EngineBuild ToolCore Inclusion
PhaserVite/webpackDirect import (TS → JS)
Unitynpm + UnityFSPort core to C# or run via Jint (JS interpreter)
GodotGDNativePort core to C++ or use Godot 4 JavaScript modules
UnrealUnreal Build ToolPure 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

Unity (C#)

Godot (4.x)

Unreal Engine (5.x)

Common Mistakes

Failure ModeCauseMitigation
Over-abstractionEvery engine feature gets an interface — adapter code exceeds coreKeep adapters as thin as possible; never put logic in adapters
Ignoring data orientationVirtual calls everywhere — cache misses, poor cache localityProfile critical paths; use engine-native optimizations for hot paths
Event bus overloadAll combat events on a single bus — debugging nightmare, no orderingSeparate channels: DamageEvent, StatusEvent, AUIEvent
Porting overheadTS core ported to C# — type/generic mismatches, double maintenanceAutomate with TypeStat/QuickType; or write core in C# directly for Unity-first teams

Checklist

References

  1. Nystrom, Robert. Game Programming Patterns. Genever Benning, 2014. Chapter 7: Event Queue; Chapter 14: Component.
  2. Ford, Timothy. "Overwatch Gameplay Architecture and Netcode." GDC 2017.
  3. Acton, Mike. "Data-Oriented Design and C++." CppCon 2014.
  4. Entitas ECS Framework. GitHub repository, 2023. (https://github.com/sschmid/Entitas)
  5. Phaser 3 API Documentation. 2024. (https://photonstorm.github.io/phaser3-docs)
  6. Riot Games. "League of Legends: Architecture." Engineering Blog, 2020.