GitHub Repo

This morning I’m wondering whether I made a mistake yesterday. Well, I must have made some mistake, but I have a particular possibility in mind.

Yesterday, I consolidated the Ship class and the brand new, not a day old, Asteroid class, into a single class, FlyingObject. Today I’m wondering whether that was premature. I know of some ways in which ships and asteroids are different, and when missiles come along, and maybe the Alien Ship with its special missiles, they’ll behave differently as well. One key way that we express the different behavior of objects is to have them in separate classes, and I’ve just taken two kinds of objects and merged them into one.

Now, the code is currently simpler. We’ve removed an entire class. That might pay off in the future: as we elaborate the system, it is quite likely that Ship and Asteroid will need equivalent changes. That might have led to more and more duplication. And certainly, I treat duplication as one of the key signs that the code needs improvement.

I honestly don’t know whether waiting to converge those two classes would have been “better”. I am sure of one thing, though: when I want to know how things move, there’s just one place to look. That’s a good thing. Will I have to do more work this way than the other? Impossible to know.

I think my rule of thumb would be “see duplication, remove it”, unless there’s an obvious reason not to. And “might be better later” doesn’t count as an obvious reason.

I do know for certain that the objects of the game will have different behavior. So we will encounter situations that will differentiate the objects, and we can think at that time about how this change affects us. But at any given time, a smaller simpler system is surely superior, so sticking with this situation is certainly satisfactory1.

Let’s see what’s next for our little game. Here are some possibilities.

  1. Asteroid-ship collision splits asteroid, kills ship.
  2. Missiles fired from ship can split asteroids.
  3. Additional missile behavior: can kill each other and ship.

Asteroid splitting is interesting. It leads us to the observation that the game, or the universe, needs to know all the objects in it, so that it can give them the opportunity to update and to draw.

We could do asteroid.split separately from anything else. Might be a good warmup. And look: it immediately gets us in trouble with our single-class design: ships don’t split. They explode. Possibly a new ship is spawned later. Let’s not worry about that. Let’s implement naive split behavior, on the assumption that we’ll only call it when we should. (Very poor assumption for the longer term, but for the morning it’s probably OK.)

We’ll begin with a test.

Aside
Have you noticed that what I’ve been doing the past few days has not included looking at the program running? In fact it couldn’t really run. We’re doing all the behavior of our objects with tests and code, so far. I’ve never done a video-game type thing that way before, and so far it seems to be going well.

As I start to write the test …

    @Test
    fun `asteroid can split`() {
        
    }

I begin to recognize some issues. First, one in two out. How will that work? Let’s say this. When an asteroid is told to split, it returns a collection of two asteroids. One might be the original, or it might not, to be defined. We’ll say that the behavior of a user of split, if they’re the game, should be to tell A to split, receive B and C, remove A from the universe, then add B and C. More accurately, add the contents of the returned collection. If the asteroid has split beyond its limit, it is expected to return an empty collection, which will result in its removal from the game.

Hm. Sounds good to me. Write the test that way:

    @Test
    fun `asteroid can split`() {
        val asteroid = FlyingObject.asteroid(
            pos = Vector2.ZERO,
            vel = Vector2.ZERO
        )
        val splits: List<FlyingObject> = asteroid.split()
        assertThat(splits.size).isEqualTo(2)
    }

That’s enough to get us moving. IDEA informs me that FOs don’t know how to spit. I knew that.

    fun split(): List<FlyingObject> {
        val newGuy = FlyingObject.asteroid(
            this.position,
            this.velocity
        )
        return listOf(this, newGuy)
    }

Will this pass the test? I think so. Yes, green. Now it turns out that asteroids can only split twice. When we split a big one we get two medium ones, each of which can split once more time. When one of those is split, the remaining asteroid will die if it is shot again. They reduce in size by 1/2 each time through as well.

There’s more: they get random velocity, adjusted from the velocity of the original asteroid, but we’ll deal with that anon. For now, let’s do the split count test.

    @Test
    fun `asteroid can split`() {
        val full = FlyingObject.asteroid(
            pos = Vector2.ZERO,
            vel = Vector2.ZERO
        )
        val halfSize: List<FlyingObject> = full.split()
        assertThat(halfSize.size).isEqualTo(2)
        val half = halfSize.first()
        val quarterSize = half.split()
        assertThat(quarterSize.size).isEqualTo(2)
        val quarter = quarterSize.first()
        val eighthSize = quarter.split()
        assertThat(eighthSize.size).isEqualTo(0)
    }

I think that tells the story. Test should fail on the last assertion, because we’ll split a quarter sized one now.

expected: 0
 but was: 2

Perfect. Now how to do this? Whatever attribute we put into the asteroid FlyingObject will apply only to asteroids. Really makes me wish it was a separate class. Instead, I’m just going to put the stuff into FlyingObject and see whether we wind up caring. My first version of this is going to be just to set everyone’s splitCount to 2 and count it down.

    var splitCount = 2

    fun split(): List<FlyingObject> {
        splitCount -= 1
        if (splitCount< 0) return listOf()
        val newGuy = FlyingObject.asteroid(
            this.position,
            this.velocity
        )
        return listOf(this, newGuy)
    }

Test is green. Commit: Asteroids split twice, no more.

Now what? Welp, their kill radius should be radius, radius/2, radius/4. Let’s be testing that while we’re here. The test is getting kind of long but honestly it kind of tells a story.

    @Test
    fun `asteroid can split`() {
        val full = FlyingObject.asteroid(
            pos = Vector2.ZERO,
            vel = Vector2.ZERO
        )
        val radius = full.killRadius // <---
        val halfSize: List<FlyingObject> = full.split()
        assertThat(halfSize.size).isEqualTo(2)
        val half = halfSize.first()
        assertThat(half.killRadius).isEqualTo(radius/2.0) // <---
        val quarterSize = half.split()
        assertThat(quarterSize.size).isEqualTo(2)
        val quarter = quarterSize.first()
        assertThat(half.killRadius).isEqualTo(radius/4.0) // <---
        val eighthSize = quarter.split()
        assertThat(eighthSize.size).isEqualTo(0)
    }

That’ll fail looking for 500.

expected: 500.0
 but was: 1000.0

Perfect. Fix it:

    fun split(): List<FlyingObject> {
        splitCount -= 1
        if (splitCount< 0) return listOf()
        val newGuy = FlyingObject.asteroid(
            this.position,
            this.velocity
        )
        killRadius /= 2.0
        newGuy.killRadius = killRadius
        return listOf(this, newGuy)
    }

I reduce my own radius, then set the newGuy to match mine. We could make the radius a creation parameter, but I don’t want to make the creation sequence any harder than it already is.

I expect green. Yes. Commit: split asteroid reduces radius in half.

But now …
Now we’re up against an interesting question. When asteroids split, they change direction, randomly. (I thought that they changed speed, but that’s not the case.)

How are we to test this random behavior? I think what I’ll do is just test that they have the right speed, and, OK, that their velocity isn’t what it was. Odds are with me on that.

I think I’ll do a new test for this, the one we have is getting cumbersome. I start with this:

    @Test
    fun `asteroids get new direction on split`() {
        val startingV = Vector2(100.0,0.0)
        val full = FlyingObject.asteroid(
            pos = Vector2.ZERO,
            vel = startingV
        )
        var v = full.velocity
        assertThat(v.length).isEqualTo(100.0, within(1.0))
        assertThat(v).isNotEqualTo(startingV)
        val halfSize: List<FlyingObject> = full.split()
    }

This far in I realize that we’re going to want to make the starting velocity of our asteroids be some provided universal constant. And we of course want them to go in a random direction: why not let them choose that. So the full asteroid will have the same speed, but not the same velocity as we provided. Test will fail on the vector match.

Expecting actual:
  Vector2(x=100.0, y=0.0)
not to be equal to:
  Vector2(x=100.0, y=0.0)

Right. Improve FlyingObject. No. We can’t just let him randomize on his own. The reason is that missiles should NOT randomize their velocity. So if we put the randomization at the top, we’ll be in trouble later on. So therefore the test needs to change:

    @Test
    fun `asteroids get new direction on split`() {
        val startingV = Vector2(100.0,0.0)
        val full = FlyingObject.asteroid(
            pos = Vector2.ZERO,
            vel = startingV
        )
        var v = full.velocity
        assertThat(v.length).isEqualTo(100.0, within(1.0))
        assertThat(v).isEqualTo(startingV)
        val halfSize: List<FlyingObject> = full.split()
        halfSize.forEach { 
            val v = it.velocity
            assertThat(v.length).isEqualTo(100.0, within(1.0))
            assertThat(v).isNotEqualTo(startingV)
        }
    }

This will fail on the vector not equal in the loop.

Expecting actual:
  Vector2(x=100.0, y=0.0)
not to be equal to:
  Vector2(x=100.0, y=0.0)

Change split:

    fun split(): List<FlyingObject> {
        splitCount -= 1
        if (splitCount< 0) return listOf()
        val newGuy = FlyingObject.asteroid(
            this.position,
            this.velocity.rotate(random()*360.0)
        )
        killRadius /= 2.0
        velocity = velocity.rotate(random()*360.0)
        newGuy.killRadius = killRadius
        return listOf(this, newGuy)
    }

This is getting a bit messy, but I expect a green on the test. Yes. Commit: split asteroids get new direction, same speed.

Now let’s see what we can clean up here.

In the test, we might do well with slightly better names.

    @Test
    fun `asteroids get new direction on split`() {
        val startingV = Vector2(100.0,0.0)
        val full = FlyingObject.asteroid(
            pos = Vector2.ZERO,
            vel = startingV
        )
        var fullV = full.velocity
        assertThat(fullV.length).isEqualTo(100.0, within(1.0))
        assertThat(fullV).isEqualTo(startingV)
        val halfSize: List<FlyingObject> = full.split()
        halfSize.forEach {
            val halfV = it.velocity
            assertThat(halfV.length).isEqualTo(100.0, within(1.0))
            assertThat(halfV).isNotEqualTo(startingV)
        }
    }

That’s better. Now let’s analyze the split:

    fun split(): List<FlyingObject> {
        splitCount -= 1
        if (splitCount< 0) return listOf()
        val newGuy = FlyingObject.asteroid(
            this.position,
            this.velocity.rotate(random()*360.0)
        )
        killRadius /= 2.0
        velocity = velocity.rotate(random()*360.0)
        newGuy.killRadius = killRadius
        return listOf(this, newGuy)
    }

We could make the killRadius an optional parameter to the asteroid constructor. Ah! It already is:

    fun asteroid(pos:Vector2, vel: Vector2): FlyingObject {
        return FlyingObject(
            position = pos,
            velocity = vel,
            acceleration = Vector2.ZERO,
            killRad = 1000.0
        )
    }

So let’s do this:

Oh! Reading this code, I think we have a bug. Let me change the test for split limits:

    @Test
    fun `asteroid can split`() {
        val full = FlyingObject.asteroid(
            pos = Vector2.ZERO,
            vel = Vector2.ZERO
        )
        val radius = full.killRadius
        val halfSize: List<FlyingObject> = full.split()
        assertThat(halfSize.size).isEqualTo(2)
        val half = halfSize.last() // <---
        assertThat(half.killRadius).isEqualTo(radius/2.0)
        val quarterSize = half.split()
        assertThat(quarterSize.size).isEqualTo(2)
        val quarter = quarterSize.last() // <---
        assertThat(half.killRadius).isEqualTo(radius/4.0)
        val eighthSize = quarter.split()
        assertThat(eighthSize.size).isEqualTo(0)
    }

I’ve just changed to take the last replacement asteroid rather than the first. I expect this test to fail looking for zero and getting two.

expected: 0
 but was: 2

Why? Because we update the splitCount in the old asteroid but the new one still gets 2. Let’s fix that and the review the split code for improvement. I might rearrange things a bit this time through, since I think the tests are solid. First, I add the names to the call arguments:

    fun split(): List<FlyingObject> {
        splitCount -= 1
        if (splitCount< 0) return listOf()
        
        val newGuy = FlyingObject.asteroid(
            pos = this.position,
            vel = this.velocity.rotate(random()*360.0)
        )
        killRadius /= 2.0
        velocity = velocity.rotate(random()*360.0)
        newGuy.killRadius = killRadius
        return listOf(this, newGuy)
    }

Nice feature that Kotlin has there. Now let’s update our killRadius up where we do the splitCount:

    fun split(): List<FlyingObject> {
        if (splitCount < 1) return listOf()

        splitCount -= 1
        killRadius /= 2.0
        velocity = velocity.rotate(random()*360.0)
        
        val newGuy = FlyingObject.asteroid(
            pos = this.position,
            vel = this.velocity.rotate(random()*360.0)
        )
        newGuy.killRadius = killRadius
        return listOf(this, newGuy)
    }

Note that I consolidated all the updates to the current asteroid, after the check for being done splitting. I changed the test to reflect that I’m decrementing after deciding.

Now, I’m just going to create the new guy the way I’d like to:

    fun split(): List<FlyingObject> {
        if (splitCount < 1) return listOf()

        splitCount -= 1
        killRadius /= 2.0
        velocity = velocity.rotate(random()*360.0)

        val newGuy = FlyingObject.asteroid(
            pos = this.position,
            vel = this.velocity.rotate(random()*360.0),
            killRad = this.killRadius
        )
        return listOf(this, newGuy)
    }

I just wish I could create newGuy with the same killRadius as we have. Then I make it so:

companion object {
    fun asteroid(pos:Vector2, vel: Vector2, killRad: Double = 1000.0): FlyingObject {
        return FlyingObject(
            position = pos,
            velocity = vel,
            acceleration = Vector2.ZERO,
            killRad = killRad
        )
    }

I expect my test to run green. but it doesn’t, because I haven’t updated the split count yet. We’ll need to deal with that. I forgot why I came in here, put my hat back on and left.

grandpa Simpson comes in takes off hat turns around puts on hat and leaves

Let’s go full on weird here and allow splitCount as part of the FlyingObject creation.

class FlyingObject(
    var position: Vector2,
    var velocity: Vector2,
    private val acceleration: Vector2,
    killRad: Double,
    splitCt: Int = 0,
    private val controls: Controls = Controls()
) {
    var killRadius = killRad
        private set
    var pointing: Double = 0.0
    var rotationSpeed = 360.0
    var splitCount = splitCt

And let’s update the companion:

    fun asteroid(pos:Vector2, vel: Vector2, killRad: Double = 1000.0, splitCt: Int = 2): FlyingObject {
        return FlyingObject(
            position = pos,
            velocity = vel,
            acceleration = Vector2.ZERO,
            killRad = killRad,
            splitCt = splitCt
        )
    }

Now I can provide the split count when I create the new guy:

    val newGuy = FlyingObject.asteroid(
        pos = this.position,
        vel = this.velocity.rotate(random()*360.0),
        killRad = this.killRadius,
        splitCt = this.splitCount
    )

Now green? Yes. Commit: Fixed problem where split-off asteroid could split forever.

Let’s review the split method again.

    fun split(): List<FlyingObject> {
        if (splitCount < 1) return listOf()

        splitCount -= 1
        killRadius /= 2.0
        velocity = velocity.rotate(random()*360.0)

        val newGuy = FlyingObject.asteroid(
            pos = this.position,
            vel = this.velocity.rotate(random()*360.0),
            killRad = this.killRadius,
            splitCt = this.splitCount
        )
        return listOf(this, newGuy)
    }

There are four things going on in this method:

  1. Exit if split is not possible;
  2. Update the survivor to half size;
  3. Make another asteroid like this one;
  4. Return both.

Let’s express it this way:

Select the survivor update and extract to method asSplit, which we’ll pretend returns a new object, though in fact it’ll return this.

    fun split(): List<FlyingObject> {
        if (splitCount < 1) return listOf()

        asSplit()

        val newGuy = FlyingObject.asteroid(
            pos = this.position,
            vel = this.velocity.rotate(random()*360.0),
            killRad = this.killRadius,
            splitCt = this.splitCount
        )
        return listOf(this, newGuy)
    }

    private fun asSplit() {
        splitCount -= 1
        killRadius /= 2.0
        velocity = velocity.rotate(random() * 360.0)
    }

Change asSplit to return an asteroid:

    fun split(): List<FlyingObject> {
        if (splitCount < 1) return listOf()

        val meSplit = asSplit()

        val newGuy = FlyingObject.asteroid(
            pos = this.position,
            vel = this.velocity.rotate(random()*360.0),
            killRad = this.killRadius,
            splitCt = this.splitCount
        )
        return listOf(this, newGuy)
    }

    private fun asSplit(): FlyingObject {
        splitCount -= 1
        killRadius /= 2.0
        velocity = velocity.rotate(random() * 360.0)
        return this
    }

Test to be sure we haven’t broken anything. Now that newGuy code. What is it? I think it’s computing our twin. Let’s extract:

    fun split(): List<FlyingObject> {
        if (splitCount < 1) return listOf()

        val meSplit = asSplit()
        val newGuy = twin()
        return listOf(this, newGuy)
    }

    private fun twin() = asteroid(
        pos = this.position,
        vel = this.velocity.rotate(random() * 360.0),
        killRad = this.killRadius,
        splitCt = this.splitCount
    )

    private fun asSplit(): FlyingObject {
        splitCount -= 1
        killRadius /= 2.0
        velocity = velocity.rotate(random() * 360.0)
        return this
    }

Test. Green, of course. I think twin would be better as a constructor or something, but this will do for now.

If we wanted to, we could make the asSplit return a new asteroid as well. We have no reason to do that but if we wanted them to be invariant, we could make it happen easily. But they update their position anyway, so, for now, I’m happy. The split method is tested for killRadius and split count, and the code is now rather simple:

    fun split(): List<FlyingObject> {
        if (splitCount < 1) return listOf()
        val meSplit = asSplit()
        val newGuy = twin()
        return listOf(this, newGuy)
    }

I’m happy with the guard clause at the top. YMMV. You might want to do it another way. If you were here we’d chat about it and pick a house style. Currently, we’re looking at it.

Let’s sum up.

Summary

After a bit of looking around, we test-drove the implementation of FlyingObject.split, which creates and returns a collection of two objects where there was only one. They have scaled down, and counted down the number of times they can split. They have the same speed, and a very high probability of a new direction. (I think it is conceivable that the random number generator would return a zero, in which case the new direction would be the same. And if that happened in the test, it would fail. Intermittently. That will drive someone crazy in the future, if there is a future.)

The FlyingObject class has a handful of methods now. more than either of its concrete instances needs. I’ll ask the gang about this class tonight.

There remains the issue of the actual desired values of things like the object radii, the speed of light, and so on.

We have three uses of Vector2 and they are not the same. We have position, velocity, and acceleration. These have entirely different units in the world, namely USDU, USDU/second, and USDU/second/second2. Our code manages them correctly, insofar as we know, but you really shouldn’t be able to add a position to a position, etc. So we should be thinking about whether we should cover these types. On the other hand, we should be recognizing that really dealing with all the vector types is a fool’s errand. It’s probably Master’s Thesis level work to get it right. And we have no need of it all.

At this writing, I think that we could create a “universe” or “game”, and populate it with asteroids and a ship, and start letting them collide and such. I think we’ll do missiles first, however. Probably next time.

I’ll see you then!



  1. Something sure seems somewhat strangely sibilant. 

  2. Universal Space Distance Units. We’ve discussed this. Yes, it will be on the final exam.