Object-Oriented Design – When to Introduce a New Layer of Abstraction in Class Hierarchy

abstractiongame developmentobject-oriented

Suppose I'm creating a game played on a 2D coordinate grid. The game has 3 types of enemies which all move in different ways:

  • Drunkard: moves using type 1 movement.
  • Mummy: moves using type 1 movement, except when it's near the main character, in which case it will use type 2 movement.
  • Ninja: moves using type 3 movement.

Here are the ideas I've come up with in organizing the class hierarchy:

Proposal 1

A single base class where each enemy is derived from:

abstract class Enemy:
    show()   // Called each game tick
    update() // Called each game tick
    abstract move() // Called in update

class Drunkard extends Enemy:
    move() // Type 1 movement

class Mummy extends Enemy:
    move() // Type 1 + type 2 movement

class Ninja extends Enemy:
    move() // Type 3 movement

Problems:

  • Violates DRY since code isn't shared between Drunkard and Mummy.

Proposal 2

Same as proposal 1 but Enemy does more:

abstract class Enemy:
    show()            // Called each game tick
    update()          // Called each game tick
    move()           // Tries alternateMove, if unsuccessful, perform type 1 movement
    abstract alternateMove() // Returns a boolean

class Drunkard extends Enemy:
    alternateMove(): return False

class Mummy extends Enemy:
    alternateMove() // Type 2 movement if in range, otherwise return false

class Ninja extends Enemy:
    alternateMove() // Type 3 movement and return true

Problems:

  • Ninja really only has one move, so it doesn't really have an "alternate move." Thus, Enemy is a subpar representation of all enemies.

Proposal 3

Extending proposal 2 with a MovementPlanEnemy.

abstract class Enemy:
    show()   // Called each game tick
    update() // Called each game tick
    abstract move() // Called in update

class MovementPlanEnemy:
    move() // Type 1 movement
    abstract alternateMove()

class Drunkard extends MovementPlanEnemy:
    alternateMove() // Return false

class Mummy extends MovementPlanEnemy:
    alternateMove() // Tries type 2 movement

class Ninja extends Enemy:
    move() // Type 3 movement

Problems:

  • Ugly and possibly over-engineered.

Question

Proposal 1 is simple but has a lower level of abstraction. Proposal 3 is complex but has a higher level of abstraction.

I understand the whole thing about "composition over inheritance" and how it can solve this whole mess. However, I have to implement this for a school project which requires us to use inheritance. So given this restriction, what would be the best way to organize this class hierarchy? Is this just an example of why inheritance is inherently bad?

I guess since my restriction is that I have to use inheritance, I'm really asking the broader question: in general, when is it appropriate to introduce a new layer of abstraction at the cost of complicating the program architecture?

Best Answer

I've built a 2D roguelike from pretty much scratch, and after lots of experimentation, I used an entirely different approach. Essentially an entity component architecture.

Each game object is an Entity, and an Entity has many attributes which control how it responds to stimuli from the player and the environment. One of these components in my game is a Movable component (other examples are Burnable, Harmable, etc, my GitHub has the full list):

class Entity
    movable
    harmable
    burnable
    freezable
    ...

Different types of enemies are distinguised by injecting different basic components at object creation time. So something like:

drunkard = Entity(
    movable=SometimesRandomMovable(),
    harmable=BasicHarmable(),
    burnable=MonsterBurnable(),
    freezable=LoseATurnFreezable()
    ...
)

and

ninja = Entity(
    movable=QuickMovable(),
    harmable=WeakHarmable(),
    burnable=MonsterBurnable(),
    freezable=NotFreezable()
    ...
)

Each component stores a reference to its owner Entity for information like position.

The components know how to receive messages from the game world, process them, then generate more messages for the results. These messages land in a global queue, and there is a main loop each turn which pops messages off the queue, process them, then pushes and resulting messages back on the queue. So, for example, a Movable component does not actually edit the position attributes of the owning entity, it generates a message to the game engine that they should be changed, along with the position in which the owner should be moved to.

There's essentially no class hierarchy for the basic game entities, and I did not find myself missing it. Behavior is distinguished entirely by what components a entity has. This works for every entity in the game world, player, enemy, or object.