GitHub Repo

In Asteroids, after your ship dies, if you are allowed any more, a new one appears … somewhere. Let’s see how to make that happen.

I’ll review my Codea Lua version to see if I can glean the spec from the code. I see a couple of methods that seem germane:

function Ship:safeToAppear()
    for k,o in pairs(U.objects) do
        if self:inRange(o) then return false end
    end
    return true
end

function Ship:tryToAppear()
    if self:safeToAppear() then
        U:addObject(self)
        self:dropIn()
    else
        tween.delay(3,self.tryToAppear,self)
    end
end

function Ship:dropIn()
    self.scale = 10
    tween(1, self, {scale=2})
end

The ship always appears at screen center (I had been thinking that it was random) and only appears if there’s nothing too close. The function inRange is the same as we use for collisions, actual distance < the sum of the kill radii of the two objects in question.

The ship reappears after 3 seconds, unless it can’t, in which case it seems to wait another three seconds. Note that it is possible to appear with something about to hit you: just not if it would hit you the instant you appear.

The dropIn function is supposed to make a largish ship appear and seem to drop down onto the screen. I suppose we should do that as well. Running my game on the iPad, I don’t see that effect working. Why has no one sent me a bug report?

There are more details that I see in the Lua code: I see that Score spawns the ships, presumably ScoreKeeper in our version, because it knows how many ships you have. A small issue for us is that we don’t have the ability to find any of our special flying objects, but we’ll sort something out. Currently we’re not creating a new ship at all, so one possibility is for it to know how many copies it can make. And ShipMonitor is where all our action needs to take place.

Enough history, let’s design.

Design? Up front?? Are you MAD???

Of course design up front. We think about design all the time and one good time is before we type in code, lest we just type it into some random spot. Our first design action should be to study the ShipMonitor, because it knows when the ship is gone.

class ShipMonitor(val ship: Flyer) : IFlyer {
    override val mutuallyInvulnerable = false
    override val killRadius: Double = -Double.MAX_VALUE
    var state: ShipMonitorState = HaveSeenShip

    override fun interactWith(other: IFlyer): List<IFlyer> {
        if (state == LookingForShip) {
            if (other == ship)
                state = HaveSeenShip
        }
        return emptyList() // no damage done here
    }

    override fun interactWithOther(other: IFlyer): List<IFlyer> {
        return interactWith(other)
    }

    override fun update(deltaTime: Double): List<IFlyer> {
        var toBeCreated: List<IFlyer> = emptyList()
        state = when (state) {
            HaveSeenShip -> LookingForShip
            LookingForShip -> {
                toBeCreated = listOf(ship)
                HaveSeenShip
            }
        }
        return toBeCreated
    }
}

So. This object interacts with all the other objects. In update, it generally starts in LookingForShip state and if it sees a ship goes to HaveSeenShip state. Currently, if it enters update still in LookingForShip, it takes the ship (which it was created to know) and returns it, to be added back into the mix.

I think we’ll need a third state, maybe WaitingToCreate. When we enter update with Looking still set, we’ll set Waiting and set a time. Thereafter, in update, we’ll just stay in Waiting until the timer elapses, and, for now, pop the ship back in the mix.

That makes sense to me. Let’s try it. Do we have some tests we could build on? I certainly hope so.

I have good news and bad news. The tests are extensive, and they are story tests, which makes them pretty long. Let’s just see if we can write one that isn’t quite so long. It may require us to add some capabilities to the ShipMonitor to let us get at it. We’ll see.

I am also almost certain that this test will break the others. We’ll decide what to do about that in due time.

Here’s what I think we want to test.

  1. Call update, not finding a ship. State should be Looking.
  2. Call update again, state should be Waiting.
  3. Call update again, three seconds later, update should return a ship, state HaveSeen.

Instead of setting up all the flyers, I think we can just call update repeatedly and check its behavior.

    @Test
    fun `delayed creation of ship`() {
        val ship = Flyer.ship(Point(10.0, 10.0))
        val mon = ShipMonitor(ship)
        assertThat(mon.state).isEqualTo(ShipMonitorState.HaveSeenShip)
        mon.update(1.0/60.0)
        assertThat(mon.state).isEqualTo(ShipMonitorState.LookingForShip)
        mon.update(1.0/60.0)
        assertThat(mon.state).isEqualTo(ShipMonitorState.WaitingToCreate)
    }

I am reliably informed by IDEA that there is no state WaitingToCreate. IDEA adds it for me. Now I expect the test to fail on the last assertion. Let’s see what happens.

What happens is that it won’t compile because my when statements aren’t exhaustive. Just another red bar, these strict languages are so helpful.

    override fun update(deltaTime: Double): List<IFlyer> {
        var toBeCreated: List<IFlyer> = emptyList()
        state = when (state) {
            HaveSeenShip -> LookingForShip
            LookingForShip -> {
                toBeCreated = listOf(ship)
                HaveSeenShip
            }
        }
        return toBeCreated
    }

How convenient, this is just where we would have had to make our change anyway. I guess we’ll just go all the way … well, most of the way. I type this:

    override fun update(deltaTime: Double): List<IFlyer> {
        var toBeCreated: List<IFlyer> = emptyList()
        state = when (state) {
            HaveSeenShip -> LookingForShip
            LookingForShip -> WaitingToCreate
            WaitingToCreate -> {
                toBeCreated = listOf(ship)
                HaveSeenShip
            }
        }
        return toBeCreated
    }

I think the test should pass … and that if we were to look, it would have returned a ship. Test.

My current test passes, and sure enough, one of the others fails, because it expects a ship right away. I’m going to just comment them out as they fail and decide when we’re done here what to do about them.

Green. Now to upgrade the new test to deal with the delay:

    @Test
    fun `delayed creation of ship`() {
        val ship = Flyer.ship(Point(10.0, 10.0))
        val mon = ShipMonitor(ship)
        assertThat(mon.state).isEqualTo(ShipMonitorState.HaveSeenShip)
        var created = mon.update(1.0/60.0)
        assertThat(mon.state).isEqualTo(ShipMonitorState.LookingForShip)
        assertThat(created).isEmpty()
        created = mon.update(1.0/60.0)
        assertThat(mon.state).isEqualTo(ShipMonitorState.WaitingToCreate)
        assertThat(created).isEmpty()
        created = mon.update(1.0)
        assertThat(mon.state).isEqualTo(ShipMonitorState.WaitingToCreate)
        assertThat(created).describedAs("too soon").isEmpty()
        created = mon.update(2.1)
        assertThat(mon.state).isEqualTo(ShipMonitorState.WaitingToCreate)
        assertThat(created).describedAs("on time").contains(ship)
    }

I expect “too soon”. I get this:

expected: WaitingToCreate
 but was: HaveSeenShip

If I had put the other test first it would have said too soon. I’ll describe both:

        created = mon.update(1.0)
        assertThat(mon.state).describedAs("too soon").isEqualTo(ShipMonitorState.WaitingToCreate)
        assertThat(created).describedAs("too soon").isEmpty()
        created = mon.update(2.1)
        assertThat(mon.state).describedAs("on time").isEqualTo(ShipMonitorState.WaitingToCreate)
        assertThat(created).describedAs("on time").contains(ship)

Test again demanding “too soon”. Ha:

[too soon] 
expected: WaitingToCreate
 but was: HaveSeenShip

Perfect. So we need our state machine to set and check some time.

    override fun update(deltaTime: Double): List<IFlyer> {
        elapsedTime += deltaTime
        var toBeCreated: List<IFlyer> = emptyList()
        state = when (state) {
            HaveSeenShip -> LookingForShip
            LookingForShip -> {
                elapsedTime = 0.0
                WaitingToCreate
            }
            WaitingToCreate -> {
                if (elapsedTime >= 3.0) {
                    toBeCreated = listOf(ship)
                    HaveSeenShip
                } else WaitingToCreate
            }
        }
        return toBeCreated
    }

I rather expect this to work. It doesn’t, because the test is wrong:

[on time] 
expected: WaitingToCreate
 but was: HaveSeenShip

In the same cycle as when we create the ship, we have to say that we’ve seen it, to get back to nominal. So the test needs to say:

        created = mon.update(1.0)
        assertThat(mon.state).describedAs("too soon").isEqualTo(ShipMonitorState.WaitingToCreate)
        assertThat(created).describedAs("too soon").isEmpty()
        created = mon.update(2.1)
        assertThat(mon.state).describedAs("on time").isEqualTo(ShipMonitorState.HaveSeenShip)
        assertThat(created).describedAs("on time").contains(ship)

We’ll be green now, I think. Green. I’m sure the game will reflect this but I can’t resist running it. Works as advertised. Which reminds me, we should be setting the ship to the middle of the screen, so let’s add that to the test.

        created = mon.update(2.1)
        assertThat(mon.state).describedAs("on time").isEqualTo(ShipMonitorState.HaveSeenShip)
        assertThat(created).describedAs("on time").contains(ship)
        assertThat(ship.position).isEqualTo(Point(5000.0, 5000.0))

Should fail. Does:

expected: Vector2(x=5000.0, y=5000.0)
 but was: Vector2(x=10.0, y=10.0)

It seems to me that that Point(5000.0, 5000.0) counts as a magic number. We don’t have any really good handling for those constants yet. Make it work, then make it right.

    WaitingToCreate -> {
        if (elapsedTime >= 3.0) {
            ship.position = Point(5000.0, 5000.0)
            toBeCreated = listOf(ship)
            HaveSeenShip
        } else WaitingToCreate
    }

Green? Green. Commit: After ship dies, it is reincarnated at universe center after 3 seconds.

I can’t resist playing the game and when I do I discover that the ship’s velocity has not been reset. So change the test and the code. (So tempting to change just the code, isn’t it? But the test serves to explain what is supposed to happen.)

        assertThat(mon.state).describedAs("on time").isEqualTo(ShipMonitorState.HaveSeenShip)
        assertThat(created).describedAs("on time").contains(ship)
        assertThat(ship.position).isEqualTo(Point(5000.0, 5000.0))
        assertThat(ship.velocity).isEqualTo(Velocity.ZERO)

        WaitingToCreate -> {
            if (elapsedTime >= 3.0) {
                ship.position = Point(5000.0, 5000.0)
                ship.velocity = Velocity.ZERO
                toBeCreated = listOf(ship)
                HaveSeenShip
            } else WaitingToCreate
        }

Test. Green. Commit: Ship respawns with velocity zero.

Let’s reflect.

Reflection

    override fun update(deltaTime: Double): List<IFlyer> {
        elapsedTime += deltaTime
        var toBeCreated: List<IFlyer> = emptyList()
        state = when (state) {
            HaveSeenShip -> LookingForShip
            LookingForShip -> {
                elapsedTime = 0.0
                WaitingToCreate
            }
            WaitingToCreate -> {
                if (elapsedTime >= 3.0) {
                    ship.position = Point(5000.0, 5000.0)
                    ship.velocity = Velocity.ZERO
                    toBeCreated = listOf(ship)
                    HaveSeenShip
                } else WaitingToCreate
            }
        }
        return toBeCreated
    }

This code is rather more open than one might like. We could extract the ship stuff but it’s just a couple of lines. We could extract the whole WaitingToCreate block. Let’s try that just to see if we like it. No, we can’t because it needs to have two returns, the new state and the update to toBeCreated, which it couldn’t see from inside. We could do the inside bit:

    override fun update(deltaTime: Double): List<IFlyer> {
        elapsedTime += deltaTime
        var toBeCreated: List<IFlyer> = emptyList()
        state = when (state) {
            HaveSeenShip -> LookingForShip
            LookingForShip -> {
                elapsedTime = 0.0
                WaitingToCreate
            }
            WaitingToCreate -> {
                if (elapsedTime >= 3.0) {
                    toBeCreated = listOf(shipReset())
                    HaveSeenShip
                } else WaitingToCreate
            }
        }
        return toBeCreated
    }
    
    private fun shipReset(): IFlyer {
        ship.position = Point(5000.0, 5000.0)
        ship.velocity = Velocity.ZERO
        return ship
    }

That’s arguably better. Green. Commit: Refactor update with separate shipReset function.

We commented out a test, what about that? It’s a huge long test that goes through a million steps to test: test ship monitor correctly adds a new ship

What we know from the test we just wrote is that once there is no ship, things proceed as they should. Would it suffice to test that the state changes correctly depending on whether there is or isn’t a ship?

That’s this code:

    override fun interactWith(other: IFlyer): List<IFlyer> {
        if (state == LookingForShip) {
            if (other == ship)
                state = HaveSeenShip
        }
        return emptyList() // no damage done here
    }

We can test that directly. Let’s do.

    @Test
    fun `correctly detect ship`() {
        val sixtieth = 1.0/60.0
        val ship = Flyer.ship(Point(10.0, 10.0))
        val mon = ShipMonitor(ship)
        mon.update(sixtieth)
        assertThat(mon.state).isEqualTo(ShipMonitorState.LookingForShip)
        mon.interactWith(ship)
        assertThat(mon.state).isEqualTo(ShipMonitorState.HaveSeenShip)
    }

I think that should run. It does. I think these simple tests tell us what we want to know.

In deleting the commented-out test, I found two others that also check collision logic, so I think we have belt and suspenders here. Green. Commit: remove redundant tests.

What else?

We still have the safety check to do, we could look at the dropIn animation, and there’s the whole issue of how many times the ship can respawn, but it is Sunday, the clocks have fallen back and if I go sit on the kitchen table like the cat does, perhaps there will be bacon.

Summary

The delay before respawn went in quite nicely, which I take as a sign that the design is holding up. There was one outright mistake, namely that I didn’t set the ship velocity to zero. Upon observing the issue, the additional test and change was easy enough.

I have a vague feeling that the state machine logic could be better. The issue is that we have two things happening on a state change, production of the new state, which is sometimes conditional, and any side action to be taken on the transition, i.e. the ship spawning. Closer adherence to “how state machines should be done” might help us, but the code is so simple now that I’m not inclined to do something more complicated in hopes of making it somehow simpler.

We may revisit this area again when we deal with how many ships you can have, including winning free ones if your score is high enough.

Relatedly, in a sense: when you kill all the asteroids, you are supposed to get a new wave, with more of them than last time. How will we determine that there are no asteroids? Right now, you can’t ask an object whether it is an asteroid. This may be interesting … but fortunately it’s not for today.

For today: a nice new feature. Next time … we’ll do something. Come visit to find out what that something is.