Chapter 29

Final Project — Star Defender (Part 2): Enemies & Waves

Part 1 gave us a complete loop with a clean state machine, but the gameplay was thin: one kind of enemy, trickling down forever. This chapter gives the game depth. We add two new enemy types that behave in genuinely different ways, and we replace the endless trickle with proper waves — rounds of enemies that get larger and nastier the longer you survive.

This is where the architecture from Part 1 pays off, and where the lessons of Act 3 shine. Because our enemies share an abstract Enemy base, adding new behaviors means writing new subclasses and nothing else — the game loop that updates and draws them does not change at all. That is polymorphism doing exactly what Chapter 19 promised, now on a real game.

In this chapter, we will:

Let's add some muscle.

Two New Enemies, Zero Loop Changes

Our Part 1 game has StraightEnemy, which falls straight down. Real shooters have variety, so let's add two more behaviors. The beauty is that each is just a new subclass of Enemy, overriding update with its own movement. We touch nothing else.

First, an enemy that weaves from side to side as it descends. Create ZigZagEnemy.kt:

package com.example.stardefender

// Weaves from side to side while descending.
class ZigZagEnemy(x: Float, y: Float, size: Float) : Enemy(x, y, size) {

    private val downSpeed = (Math.random() * 100 + 150).toFloat()
    private var velX = if (Math.random() < 0.5) -260f else 260f

    override fun update(deltaSeconds: Float, playerX: Float) {
        y += downSpeed * deltaSeconds
        x += velX * deltaSeconds
        // Occasionally reverse direction to weave
        if (Math.random() < 0.02) {
            velX = -velX
        }
    }
}

It drifts down while also moving sideways, and on roughly two percent of frames it flips its horizontal direction, producing an unpredictable weave. Like StraightEnemy, it ignores playerX — it does not care where you are.

The second new enemy does care where you are. Create ChaserEnemy.kt:

package com.example.stardefender

// Descends slowly but steers sideways to home in on the player's ship.
class ChaserEnemy(x: Float, y: Float, size: Float) : Enemy(x, y, size) {

    private val downSpeed = (Math.random() * 60 + 110).toFloat()
    private val steerSpeed = 200f

    override fun update(deltaSeconds: Float, playerX: Float) {
        y += downSpeed * deltaSeconds
        // Steer the center toward the player's x (this is why Enemy.update takes playerX)
        val centerX = x + size / 2f
        if (centerX < playerX) {
            x += steerSpeed * deltaSeconds
        } else {
            x -= steerSpeed * deltaSeconds
        }
    }
}

Here, finally, is the payoff of that playerX parameter we built into Enemy.update back in Part 1. The chaser descends slowly but steadily slides sideways toward the player's ship — if its center is left of the player, it moves right; otherwise, left. The result is an enemy that stalks you, forcing you to keep moving. This is the homing idea from Chapter 19's chaser, reborn in a shooter.

Now pause and notice what we did not have to do. We did not touch the game loop. We did not add an if (enemy is ZigZagEnemy) anywhere. The loop in GameView still just calls enemies[e].update(deltaTime, ship.centerX()), and each enemy — straight, weaving, or chasing — runs its own behavior through polymorphism. That foresight in Part 1, designing update to take playerX so all enemies share one signature, is exactly what lets these two new types drop in for free. This is what a good architecture buys you.

Building the Wave System

A steady drip of enemies is not a game; it is a screensaver. Real shooters come in waves — a round of enemies appears, you clear it, a tougher round follows. Let's build that into GameView. The continuous-spawn logic from Part 1 is replaced with a small set of wave variables and a few functions. Here are the new fields (replacing Part 1's single spawnInterval):

    // Wave system
    private var wave = 1
    private var enemiesToSpawn = 0
    private var spawnTimer = 0f
    private var spawnInterval = 0.9f
    private var waveIntroTimer = 0f

wave is the current wave number. enemiesToSpawn counts how many enemies are still to appear this wave. spawnInterval is the gap between spawns (which shrinks as waves climb). And waveIntroTimer gives us a brief "WAVE n" pause before each round begins. Starting a wave is its own function:

    private fun startWave(n: Int) {
        wave = n
        enemiesToSpawn = 4 + n * 2
        spawnInterval = (1.0f - n * 0.05f)
        if (spawnInterval < 0.35f) spawnInterval = 0.35f
        spawnTimer = 0f
        waveIntroTimer = 1.8f
    }

Each wave spawns more enemies than the last (4 + n * 2 — so six in wave 1, eight in wave 2, and so on) and spawns them faster (spawnInterval shrinks with n, down to a floor of 0.35 seconds so it never becomes impossible). And it sets waveIntroTimer to 1.8 seconds, during which we will show a banner and hold off spawning. startGame now kicks off wave 1 by calling startWave(1).

The spawning itself picks an enemy type based on the wave — gentle early on, dangerous later:

    private fun spawnEnemyForWave() {
        val ex = (Math.random() * (width - enemySize)).toFloat()
        val r = Math.random()
        val enemy: Enemy = when {
            wave >= 5 && r < 0.30 -> ChaserEnemy(ex, -enemySize, enemySize)
            wave >= 3 && r < 0.55 -> ZigZagEnemy(ex, -enemySize, enemySize)
            else -> StraightEnemy(ex, -enemySize, enemySize)
        }
        enemies.add(enemy)
    }

Notice the declared type: val enemy: Enemy. The when produces a ChaserEnemy, a ZigZagEnemy, or a StraightEnemy, but we hold whichever one in a variable of the base type Enemy, and add it to our MutableList<Enemy>. Polymorphism again — the list and the loop neither know nor care which concrete type each enemy is. Early waves are all straight-down enemies; from wave 3, weavers join the mix; from wave 5, chasers start hunting you. The game escalates.

Finally, the spawning and wave-advancing logic lives in updatePlaying. During the intro pause, we just count down; after it, we spawn this wave's enemies on the timer; and when the wave is fully spawned and cleared, we advance:

        // Wave intro pause, then spawn this wave's enemies on a timer
        if (waveIntroTimer > 0f) {
            waveIntroTimer -= deltaTime
        } else if (enemiesToSpawn > 0) {
            spawnTimer += deltaTime
            if (spawnTimer >= spawnInterval) {
                spawnTimer = 0f
                spawnEnemyForWave()
                enemiesToSpawn -= 1
            }
        }

        // ...laser and enemy updates, collisions (unchanged from Part 1)...

        // Wave cleared? Move to the next one.
        if (waveIntroTimer <= 0f && enemiesToSpawn == 0 && enemies.isEmpty()) {
            startWave(wave + 1)
        }

That last if is the loop of progression: the wave is done when there are no more enemies left to spawn and none left on screen. When that happens, we start the next wave, the banner flashes "WAVE n," and the cycle continues — harder each time — until the player finally falls. The collision code in between is exactly Part 1's, untouched, except that a kill now scores +10 instead of +1 to make the numbers feel meatier.

The drawing gains a couple of small touches — a wave counter in the HUD, and the big "WAVE n" banner while waveIntroTimer is running — and the game over screen now reports which wave you reached. Those are a few lines in the draw... methods, in the code folder.

Playing It

Type in the two new enemy files and the updated GameView, and run.

Now it is a game. "WAVE 1" flashes, and a handful of straight-down enemies fall — easy enough. Clear them and "WAVE 2" appears, with more of them, faster. By wave 3 the weavers arrive, jinking unpredictably and harder to hit. By wave 5 the chasers join, sliding relentlessly toward your ship and forcing you to dance away while still lining up your shots. Each wave you clear feels earned; each one you reach is a high-water mark. The thin shell from Part 1 has become something you actually want to beat.

Star Defender mid-wave: a mix of straight, weaving, and chasing enemy types descending toward the player's ship, with the wave number and score displayed in the HUD.
Star Defender mid-wave, with a mix of enemy types in play. The wave number and score sit in the HUD; the architecture underneath changed almost nothing to add all this variety.

Understanding the Code

The headline of Part 2 is how little had to change to add so much. Two new enemy behaviors cost us two small subclasses and not a single edit to the update or draw loops — pure polymorphism, exactly as designed. The wave system was a handful of variables and two short functions layered onto the state machine from Part 1, with the gameplay loop in between essentially unchanged. We grew the game substantially while disturbing almost nothing that already worked.

This is the deep dividend of the architecture and OOP you spent Act 3 learning. A flat, tangled program fights you every time you add a feature. A well-structured one — clean classes, a polymorphic loop, a tidy state machine — absorbs new features. You felt that here: the game got dramatically richer, and the changes were small, local, and safe. That is not luck. That is the structure working for you.

Over to You (Optional)

The enemy hierarchy is begging to be extended, which makes this an ideal AI-assisted experiment. Brief your AI with the project's structure, then try one:

Read the result critically. For the boss idea, check it against your loop: does it reduce health on a hit and only explode at zero, rather than dying in one shot? If a new enemy needs the loop to change in more than a line or two, push back — a good hierarchy should absorb it. You are the senior partner.

Summary

You gave the final game its depth. Two new Enemy subclasses — a weaving ZigZagEnemy and a homing ChaserEnemy — dropped into the existing loop with no changes to it, thanks to polymorphism and the forward-looking update(deltaSeconds, playerX) contract from Part 1. And a wave system layered onto the state machine turned an endless trickle into escalating rounds that grow in size, speed, and danger, mixing in the tougher enemy types as you climb. The game is now genuinely challenging and complete in its mechanics.

One thing is still missing, though, and it is the thing that makes a game feel good rather than merely work: polish. Right now an enemy just vanishes when shot, the screen never reacts to an impact, every explosion sounds identical, and your best score is forgotten the moment you close the app. In Part 3 — the final chapter of the build — we add the juice: particle explosions, screen shake, varied sound, and a high score that persists. Then we step back and reflect on the whole journey.