Our Particle class was a triumph because every particle is the same kind of thing. But real games are full of things that are similar but not identical. A game might have wandering enemies, chasing enemies, and flying enemies. They are all enemies—they all have a position, they all need to move and draw each frame, they can all be destroyed—but each moves in its own way.
We could write three completely separate classes and repeat the shared parts in all of them. But repetition is exactly what good programming tries to avoid. What we want is to say "here is what all enemies share," write it once, and then "here is what makes a chaser special," adding only the differences. That is inheritance. And once we have it, we get a second, almost magical power for free: the ability to keep a mixed bag of different enemy types in a single list and treat them all the same. That is polymorphism.
These two ideas together are what make object-oriented programming so powerful for games, and they are the heart of Act 3.
Code for this chapter: the complete code is in the
Chapter 18folder of the accompanying repository at github.com/EliteIntegrity/Learning-Kotlin-by-Building-Android-Games.
In this chapter, we will:
- Use inheritance to build a new class on top of an existing one.
- Understand Kotlin's
openkeyword and why classes are closed by default. - Override an inherited method to give a subclass its own behavior.
- Call the parent's version of a method with
super. - Use polymorphism to treat different subclasses through one shared type.
- Define an abstract class as a template that must be completed.
- Sketch the enemy hierarchy for the next project.
- Try an optional AI exercise on class hierarchies.
Let's build a family of classes.
Inheritance: Building on What Exists
Inheritance lets one class be based on another. The class being built upon is the base class (or parent, or superclass); the class building on it is the subclass (or child). The subclass automatically gets all the properties and methods of the base class, and can then add its own.
Let's start with a base Enemy class that holds what every enemy has in common:
open class Enemy(var x: Float, var y: Float) {
var health = 100
fun takeDamage(amount: Int) {
health -= amount
}
}
Notice the new keyword: open. In Kotlin, classes are closed by default, which means you cannot inherit from them. This is a deliberate safety choice—a class that was not designed to be built upon stays sealed. To allow a class to be a parent, you must explicitly mark it open. (This is why our Particle class in Chapter 17, which nothing inherited from, needed no open.)
Now we can create a subclass. A goblin is an enemy with a little extra:
class Goblin(x: Float, y: Float) : Enemy(x, y) {
var hasSpear = true
}
The : Enemy(x, y) part is the inheritance. It says "Goblin is an Enemy," and it passes the x and y along to the Enemy constructor so the base class can set up its part of the object. From that single line, Goblin inherits everything Enemy has:
val goblin = Goblin(100f, 200f)
println(goblin.x) // 100 — inherited from Enemy
goblin.takeDamage(30) // inherited method works
println(goblin.health) // 70 — inherited property
println(goblin.hasSpear) // true — Goblin's own addition
We wrote the position, the health, and the takeDamage method exactly once, in Enemy, and Goblin got all of it for nothing, then added its own hasSpear. If we now make Orc and Dragon subclasses too, they all share that same foundation without a single line of repetition. That is inheritance earning its keep.
The relationship is often called an "is-a" relationship, and it is a good test for whether inheritance fits: a goblin is an enemy, so Goblin inheriting from Enemy makes sense. If you cannot say "a so-and-so is a such-and-such," inheritance is probably the wrong tool.
Overriding: Changing Inherited Behavior
Inheriting shared behavior is useful, but the real point is letting each subclass differ. Suppose every enemy needs to move, but each type moves differently. We give the base class a move method, and let each subclass replace it.
For a method to be replaceable, it—like the class—must be marked open:
open class Enemy(var x: Float, var y: Float) {
var health = 100
open fun move() {
y += 5f // by default, an enemy drifts straight down
}
}
Now a subclass can provide its own version using the override keyword, which you have known since Chapter 1 (it is the very keyword we used on onDraw):
class ChaserEnemy(x: Float, y: Float) : Enemy(x, y) {
override fun move() {
x += 8f // a chaser moves sideways instead
y += 3f
}
}
override fun move() replaces the inherited move with the chaser's own. The override keyword is required—it makes your intent explicit and lets the compiler catch mistakes, like misspelling the method name. When you call move() on a ChaserEnemy, you get the chaser's version; call it on a plain Enemy, and you get the default drift. Each type behaves as itself.
Calling the Parent with super
Sometimes a subclass wants to add to the parent's behavior rather than replace it entirely. For that, super lets you call the parent's version from inside your override—again, exactly as we have done all along with super.onDraw(canvas):
class PoisonEnemy(x: Float, y: Float) : Enemy(x, y) {
override fun move() {
super.move() // do the normal downward drift first...
health -= 1 // ...then lose a little health each step (poisoned!)
}
}
super.move() runs the Enemy version (the downward drift), and then we add the poison effect on top. super means "the parent's version of this," and it lets you extend behavior without rewriting what the parent already does well.
Polymorphism: Many Types, One List
Here is the idea that makes all of this pay off spectacularly. Because a ChaserEnemy is an Enemy, and a WanderEnemy is an Enemy, Kotlin lets you treat them all simply as Enemys when you want to. In particular, you can put every kind of enemy into a single list of the base type:
val enemies = mutableListOf<Enemy>()
enemies.add(ChaserEnemy(100f, 0f))
enemies.add(WanderEnemy(300f, 0f))
enemies.add(ChaserEnemy(500f, 0f))
All three go into one MutableList<Enemy>, even though they are different specific types. And now the magic:
for (enemy in enemies) {
enemy.move()
}
When this loop calls enemy.move(), each object runs its own version of move. The chasers chase, the wanderer wanders—from one identical line of code. We do not check what type each enemy is. We do not write if (enemy is ChaserEnemy) ... else if .... We just ask every enemy to move, and each one does the right thing for what it actually is.
This is polymorphism—from the Greek for "many forms." The same instruction, enemy.move(), takes many forms depending on the real type of the object. It is the feature that lets a game manage a chaotic mix of dozens of different entity types with a single, calm update loop. Add a brand-new enemy type next month? As long as it inherits from Enemy, the existing loop handles it without changing a line. This is the quality that makes object-oriented games scale to real size.
Abstract Classes: Templates That Must Be Completed
There is a wrinkle in our Enemy class. We gave move a default of drifting downward—but is there really such a thing as a generic enemy? Probably not. Every real enemy is a chaser, or a wanderer, or something specific. The base Enemy exists only to define what they share; it should never be created on its own.
Kotlin lets us say exactly that, with an abstract class. An abstract class is a class you cannot create directly—you can only inherit from it. And it can contain abstract methods: methods with no body at all, which every subclass is required to provide.
abstract class Enemy(var x: Float, var y: Float) {
var health = 100
// Every enemy MUST have these, but the base class refuses to guess how.
abstract fun move()
abstract fun draw(canvas: Canvas, paint: Paint)
// Shared behavior can still have a real body
fun takeDamage(amount: Int) {
health -= amount
}
}
In the preceding code, abstract fun move() declares that every enemy must have a move method, but pointedly does not say how it moves—there is no body, just the promise. The same goes for draw. Meanwhile takeDamage is a normal method with a real body, shared by all. (Notice abstract members do not need the open keyword—being abstract already implies they are meant to be overridden.)
Now this is forbidden:
val enemy = Enemy(0f, 0f) // ERROR — you cannot create an abstract class
And every subclass is forced to fill in the abstract methods, or it will not compile:
class WanderEnemy(x: Float, y: Float) : Enemy(x, y) {
override fun move() { /* wander logic */ }
override fun draw(canvas: Canvas, paint: Paint) { /* draw a square */ }
}
This is a wonderful safety net. The abstract class is a contract: "any enemy must be able to move and draw itself." The compiler holds every subclass to that contract. You can never accidentally create an enemy that forgot how to draw, because the code simply will not build until you provide it. Abstract classes are how you design a clean family of related types where the base lays down the rules and each subclass fills in the specifics.
OOP in Games: The Enemy Hierarchy
Let's preview the next project. We are going to build a small defense game with two kinds of enemy, both inheriting from an abstract Enemy:
ChaserEnemyhomes in on a target, moving directly toward it.WanderEnemydrifts downward while weaving unpredictably from side to side.
Both will live together in a single MutableList<Enemy>. The game loop will call enemy.update() and enemy.draw() on every one of them, and polymorphism will ensure each behaves as itself—the chasers chasing, the wanderers wandering—all from one update loop and one draw loop. Adding a third enemy type later would mean writing one new class and changing nothing else. That is the architecture nearly every real game is built on, and you are about to build it yourself.
Summary
You learned the two ideas at the core of object-oriented design. Inheritance lets a subclass build on a base class, gaining its properties and methods for free and adding its own—remembering that Kotlin classes and methods must be marked open to be inherited from or overridden, because they are closed by default. You can override an inherited method to specialize it, and call the parent's version with super. Polymorphism then lets you hold many different subclasses in a single list of the base type and treat them uniformly: one call to enemy.update() runs the right behavior for each object's real type. And an abstract class defines a template that cannot be created on its own and forces every subclass to provide the methods it declares.
In the next chapter we put all of this to work in the Varied Enemies project—a defense game where chasers and wanderers share an Enemy base, live together in one list, and are updated and drawn by a single polymorphic loop. It is the moment inheritance and polymorphism stop being theory and start running on screen.
AI Exercise (Optional)
Class hierarchies are a rich topic for an AI conversation, because designing a good hierarchy is a genuine skill with real trade-offs. Optional as ever.
Open your AI chatbot and paste in this prompt:
"I am learning Kotlin OOP. I understand classes, inheritance (with the
openkeyword), overriding methods (withoverrideandsuper), polymorphism (holding subclasses in a List of the base type), and abstract classes with abstract methods. Design a small class hierarchy for a 2D game's pickups. There should be an abstract base class Pickup with a position and an abstract method onCollected() (what happens when the player grabs it), plus a shared concrete method describing its position. Then create three subclasses: CoinPickup (adds score), HealthPickup (restores health), and KeyPickup (unlocks a door). Show how I'd store all three in a single MutableList<Pickup> and loop through calling onCollected() on each. Explain why onCollected() is abstract rather than having a default body."
This asks the AI to make exactly the design decisions you just learned: what belongs in the abstract base, what each subclass overrides, and why a method should be abstract. The final question—why onCollected() is abstract—is the one that tests real understanding.
When the answer comes back, scrutinize the hierarchy. Is the shared data (position) in the base class, and the differing behavior in the subclasses? Does the polymorphic loop call onCollected() without checking each pickup's type? Does the AI explain that onCollected() is abstract because there is no sensible default—each pickup does something genuinely different? If it gave the base a default body instead, push back and ask whether that risks a subclass silently forgetting to do anything. Arguing the design through is where the learning is.