Fundamentals 39 min read

Why OOP Struggles with Complex Game Logic and How DDD/ECS Help

This article examines the limitations of classic object‑oriented programming for intricate game rules, demonstrates a dragon‑and‑magic example, compares OOP, Entity‑Component‑System and Domain‑Driven Design approaches, and provides practical guidelines, code snippets, and design patterns to achieve extensible, maintainable architecture.

Alibaba Cloud Developer
Alibaba Cloud Developer
Alibaba Cloud Developer
Why OOP Struggles with Complex Game Logic and How DDD/ECS Help

Exploring the Dragon and Magic World Architecture

In a DDD architecture, the design of the domain layer directly influences the code structure of the entire system, including the application and infrastructure layers. Designing the domain layer is challenging, especially for complex business logic where each rule must be carefully placed in an Entity, Value Object, or Domain Service to avoid poor extensibility or over‑engineering.

1 Background and Rules

To illustrate the problem with a light‑hearted example, we define a minimal set of rules for a dragon‑and‑magic game world:

Players can be Fighter, Mage, or Dragoon.

Monsters can be Orc, Elf, or Dragon; monsters have health.

Weapons can be Sword or Staff; weapons have an attack type (physical 0, fire 1, ice 2).

Players can equip one weapon; the weapon type determines damage type.

Attack rules:

Orc receives half damage from physical attacks.

Elf receives half damage from magical attacks.

Dragon is immune to all attacks unless the attacker is a Dragoon, in which case damage is doubled.

2 OOP Implementation

A straightforward OOP implementation uses inheritance (non‑core code omitted for brevity):

public abstract class Player {
    Weapon weapon;
}
public class Fighter extends Player {}
public class Mage extends Player {}
public class Dragoon extends Player {}

public abstract class Monster {
    Long health;
}
public class Orc extends Monster {}
public class Elf extends Monster {}
public class Dragon extends Monster {}

public abstract class Weapon {
    int damage;
    int damageType; // 0‑physical, 1‑fire, 2‑ice
}
public class Sword extends Weapon {}
public class Staff extends Weapon {}

The rule logic is placed in the monster hierarchy:

public class Player {
    public void attack(Monster monster) {
        monster.receiveDamageBy(weapon, this);
    }
}

public class Monster {
    public void receiveDamageBy(Weapon weapon, Player player) {
        this.health -= weapon.getDamage(); // basic rule
    }
}

public class Orc extends Monster {
    @Override
    public void receiveDamageBy(Weapon weapon, Player player) {
        if (weapon.getDamageType() == 0) {
            this.setHealth(this.getHealth() - weapon.getDamage() / 2); // Orc physical resistance
        } else {
            super.receiveDamageBy(weapon, player);
        }
    }
}

public class Dragon extends Monster {
    @Override
    public void receiveDamageBy(Weapon weapon, Player player) {
        if (player instanceof Dragoon) {
            this.setHealth(this.getHealth() - weapon.getDamage() * 2); // Dragoon bonus
        }
        // otherwise no damage – Dragon immunity
    }
}

Unit tests verify the behavior:

public class BattleTest {
    @Test
    @DisplayName("Dragon is immune to attacks")
    public void testDragonImmunity() {
        Fighter fighter = new Fighter("Hero");
        Sword sword = new Sword("Excalibur", 10);
        fighter.setWeapon(sword);
        Dragon dragon = new Dragon("Dragon", 100L);
        fighter.attack(dragon);
        assertThat(dragon.getHealth()).isEqualTo(100);
    }
    // other tests omitted for brevity
}

3 Analysis of OOP Design Flaws

The OOP code works until new constraints appear, such as:

Fighters can only equip swords.

Mages can only equip staffs.

These constraints cannot be enforced by Java's type system without introducing hidden fields that cause bugs (e.g., a Fighter's weapon being overwritten by a Staff). Protecting the setter reduces API flexibility and violates the Liskov Substitution Principle.

Adding a rule that both Fighters and Mages can equip daggers breaks the existing strong‑type design, forcing a refactor.

Inheritance also leads to tight coupling with parent logic, violating the Open‑Closed Principle (OCP). Extending the system with new weapon types (e.g., a sniper gun that bypasses all defenses) would require changes across multiple classes.

4 Where Should the Interaction Logic Reside?

There is debate whether Player.attack(monster) or Monster.receiveDamage(weapon, player) should contain the core rule. Placing it in Monster works for health reduction but fails for side effects like self‑damage from a double‑edged sword. The principle is to keep behavior where it naturally belongs while avoiding duplication.

5 Code Duplication for Similar Behaviors

When multiple objects share similar actions (e.g., moving), OOP often leads to duplicated code:

public abstract class Player {
    int x; int y;
    void move(int targetX, int targetY) { /* logic */ }
}
public abstract class Monster {
    int x; int y;
    void move(int targetX, int targetY) { /* logic */ }
}

A common solution is a shared parent class, but adding more capabilities (jump, run) quickly hits the single‑inheritance limit.

6 Entity‑Component‑System (ECS) Overview

ECS separates data (Components) from behavior (Systems). An Entity is merely an ID; components hold state such as position, health, etc. Systems process homogeneous component collections, offering high performance and easy parallelism.

Entity: just an identifier.

Component: pure data (e.g., Position, Health).

System: operates on component arrays (e.g., MovementSystem updates all Position components).

Example pseudo‑code:

public class Entity {
    public Vector position; // Component reference
}
public class MovementSystem {
    List<Vector> list;
    public void update(float delta) {
        for (Vector pos : list) {
            pos.x = pos.x + delta;
            pos.y = pos.y + delta;
        }
    }
}

ECS excels in performance and flexibility but sacrifices strong data consistency, making it less suitable for commercial business domains.

7 DDD‑Based Solution

Returning to DDD, we model the domain with explicit entities, value objects, and domain services.

7.1 Domain Entities

public class Player implements Movable {
    private PlayerId id;
    private String name;
    private PlayerClass playerClass; // enum
    private WeaponId weaponId;
    private Transform position = Transform.ORIGIN;
    private Vector velocity = Vector.ZERO;
}

public class Monster implements Movable {
    private MonsterId id;
    private MonsterClass monsterClass; // enum
    private Health health;
    private Transform position = Transform.ORIGIN;
    private Vector velocity = Vector.ZERO;
}

public class Weapon {
    private WeaponId id;
    private String name;
    private WeaponType weaponType; // enum
    private int damage;
    private int damageType; // 0‑physical, 1‑fire, 2‑ice
}

Enums replace inheritance, and IDs keep aggregates consistent.

7.2 Value‑Object Componentization

public interface Movable {
    Transform getPosition();
    Vector getVelocity();
    void moveTo(long x, long y);
    void startMove(long velX, long velY);
    void stopMove();
    boolean isMoving();
}

public class Player implements Movable { /* implementation */ }
public class Monster implements Movable { /* implementation */ }

Movable has no setters; state changes happen through explicit methods, preserving invariants.

7.3 Domain Services

Equipment Service decides whether a player can equip a weapon:

public interface EquipmentService {
    boolean canEquip(Player player, Weapon weapon);
}

Implementation uses a strategy list of policies (FighterPolicy, MagePolicy, etc.).

Combat Service handles cross‑entity attack logic:

public interface CombatService {
    void performAttack(Player player, Monster monster);
}

public class CombatServiceImpl implements CombatService {
    private WeaponRepository weaponRepository;
    private DamageManager damageManager;
    @Override
    public void performAttack(Player player, Monster monster) {
        Weapon weapon = weaponRepository.find(player.getWeaponId());
        int damage = damageManager.calculateDamage(player, weapon, monster);
        if (damage > 0) {
            monster.takeDamage(damage);
        }
        // side‑effects such as experience gain are handled via domain events
    }
}

Damage Manager uses a prioritized list of DamagePolicy objects to compute damage without mutating entities directly.

7.4 Domain Policies (Strategy Objects)

public interface DamagePolicy {
    boolean canApply(Player player, Weapon weapon, Monster monster);
    int calculateDamage(Player player, Weapon weapon, Monster monster);
}

public class DragoonPolicy implements DamagePolicy {
    @Override
    public boolean canApply(Player player, Weapon weapon, Monster monster) {
        return player.getPlayerClass() == PlayerClass.DRAGOON &&
               monster.getMonsterClass() == MonsterClass.DRAGON;
    }
    @Override
    public int calculateDamage(Player player, Weapon weapon, Monster monster) {
        return weapon.getDamage() * 2;
    }
}

Adding a new rule only requires a new policy class, preserving OCP.

7.5 Domain Events for Side Effects

When a monster dies, a LevelUpEvent or experience‑granting event can be dispatched via an in‑process EventBus, decoupling side‑effects from core logic.

public class LevelUpEvent implements Event {
    private final Player player;
    public LevelUpEvent(Player player) { this.player = player; }
    public Player getPlayer() { return player; }
}

public class Player {
    private int exp;
    public void receiveExp(int value) {
        this.exp += value;
        if (this.exp >= 100) {
            EventBus.dispatch(new LevelUpEvent(this));
            this.exp = 0;
        }
    }
}

8 Practical Guidelines

Prefer enums or type‑objects over inheritance for mutable rules.

Keep entities immutable except through explicit behavior methods.

Use domain services for cross‑entity operations.

Apply the Open‑Closed Principle via policy/strategy objects.

Handle side effects with domain events to maintain separation of concerns.

By following these DDD conventions, the architecture remains extensible, testable, and easier to evolve as business rules grow.

Feel free to discuss or ask questions via the author's email or DingTalk ID provided at the end of the original article.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Design PatternsDDDECSOOP
Alibaba Cloud Developer
Written by

Alibaba Cloud Developer

Alibaba's official tech channel, featuring all of its technology innovations.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.