GitHub Repo

I have in mind some learning to do about Interfaces, and possibly abstract classes. I want to work on scoring, and I have in mind a way to do it with a flyer. Spoiler: He shoots! He scores!!1

We have this interesting architectural experiment going on — and going well so far — where everything interesting in the universe is done by game objects interacting casually. By and large they don’t know each other, but they interact in pairs and do interesting things. Today, the interesting thing is scoring.

When you break an asteroid, the big ones score 20, the middle-sized ones score 50, and the small ones score 100. Why? Tradition.

The way I’d usually do this, there’d be a well-known object, scorekeeper or scorecard or something, and when asteroids were destroyed, they’d send the score to the well-known object, which would get its turn to be drawn, and thus the score would appear.

Here, we’re going to have a score-keeping object in the mix of flyers, and when it interacts with things, it’ll ask them for their score. If they have a non-zero score, it will accumulate the score and schedule them to be discarded.

What I like about this structure is that the objects mostly do not know each other. There are no distinguished locations or singletons. Each object worries about its own concerns and that’s all. There are conventions, of course, as we’ll see shortly.

For this scheme we need two objects. I’ll create them with tests, to the extent that I can. Because the ScoreKeeper object and the Score-bearing object interact, I will probably want to produce both of them at once. That’ll be odd, but let’s get started.

    @Test
    fun `scorekeeper starts low`() {
        val keeper = ScoreKeeper()
        assertThat(keeper.score).isEqualTo(0)
    }

I suppose another TDDer might have stopped with the creation. When I have a story in mind, like “ScoreKeepers start with zero score”, I often type it in.

IDEA produces the naked class for me:

class ScoreKeeper {

}

Just because I like to feel involved, I’ll put score in all by myself:

class ScoreKeeper {
    val score = 0
}

This might pass. It does. Another TDDer might have seen it fail just to be sure everything was in order. I’m sure we’ll see failure soon enough. Shall I write another tiny test? Sure. In the original game, the score was a five digit number with leading zeros. Let’s test that, separately.

I write this much and find an error:

    @Test
    fun `scorekeeper formats interestingly`() {
        val keeper = ScoreKeeper()
        keeper.score = 123

IDEA tells me that I should have said var. Right. Although I think it might be a good practice to default to val and only change things when one has to. We’ll pretend I did that strategically. Yeah, that’s the ticket. Anyway, to the format:

    @Test
    fun `scorekeeper formats interestingly`() {
        val keeper = ScoreKeeper()
        keeper.score = 123
        assertThat(keeper.formatted()).isEqualTo("00123")
    }

Right. Now a truly classical TDDer might return “00123” literally and then check another number. We’re here to use TDD practically, so, to me, this isn’t too large a step. If it turns out that a test is too hard to make run, we can and will make simpler tests. But I’m pretty good at getting them right-sized.

    fun formatted(): String {
        return ("00000" + score.toShort()).takeLast(5)
    }

Test runs. What we have above is the easiest way I know to get an umber with leading zeros. If you know a better one, do drop me a line.

Let’s do the interaction now. ScoreKeeper, like ShipMonitor, wants to capture all pairings, ScoreKeeper::other or other::ScoreKeeper. So we’ll test both paths.

    @Test 
    fun `scorekeeper captures this::other`() {
        val score = Score(20)
        val keeper = ScoreKeeper()
        val discards = keeper.collisionDamageWith(score)
        assertThat(discards.size).isEqualTo(1)
    }

Every line of this is red. I need a Score class. I need the collision method. I need both of these buys to be IFlyer. I need to satisfy that interface. I’ve taken a very large bite. Perhaps too large, but again I had the story in mind, so I typed it in. There’s a good chance that there’s something inherently wrong with the test code, but we’ll surely find out. I’ll make a score object.

Ah … I feel an issue coming along. We’ll need to enhance the IFlyer interface. We’ll see.

class Score(public val score: Int): IFlyer {

}

I’m not sure about the public val because I’m not that good with Kotlin yet, but I think it’s right. I also see that since I declared it to be an IFlyer, I have to start defining things like killRadius. Let’s try to fix that up without having to add all those things. Here’s IFlyer:

interface IFlyer {
    abstract val killRadius: Double
    abstract val position: Vector2
    abstract val ignoreCollisions: Boolean
    fun collisionDamageWith(other: IFlyer): List<IFlyer>
    fun collisionDamageWithOther(other: IFlyer): List<IFlyer>
    fun draw(drawer: Drawer) {}
    fun move(deltaTime: Double) {}
    fun finalize(): List<IFlyer> { return emptyList() }
    fun update(deltaTime: Double): List<IFlyer>
}

I think that we can safely provide defaults for things like killRadius, which will simplify the concrete classes. Let’s try it.

interface IFlyer {
    val killRadius: Double
        get() = 20000.0
    abstract val position: Vector2

That appears to be going to work: IDEA is now complaining about position. We can default that, too.

interface IFlyer {
    val killRadius: Double
        get() = 20000.0
    val position: Vector2
        get() = Vector2.ZERO

Right! Now it’s complaining about ignoreCollisions. I don’t remember how that’s used, on it’s in asteroids true otherwise false. I think we’ll default it to true. Now we have:

interface IFlyer {
    val killRadius: Double
        get() = 20000.0
    val position: Vector2
        get() = Vector2.ZERO
    val ignoreCollisions: Boolean
        get() = true
    fun collisionDamageWith(other: IFlyer): List<IFlyer>
    fun collisionDamageWithOther(other: IFlyer): List<IFlyer>
    fun draw(drawer: Drawer) {}
    fun move(deltaTime: Double) {}
    fun finalize(): List<IFlyer> { return emptyList() }
    fun update(deltaTime: Double): List<IFlyer>
}

And the things that IDEA wants Score and ScoreKeeper to have are pretty legit. I’ll let it create the stubs now. I think this will do:

class Score(public val score: Int): IFlyer {
    override fun collisionDamageWith(other: IFlyer): List<IFlyer> {
        return other.collisionDamageWithOther(this)
    }

    override fun collisionDamageWithOther(other: IFlyer): List<IFlyer> {
        // cannot occur
    }

    override fun update(deltaTime: Double): List<IFlyer> {
        // this space intentionally blank
    }
}

It’s tempting to see what else we could default. But we’re here to make our test run. We’ll look for optimizations later. Now I think we have to make ScoreKeeper into an IFlyer.

I want this:

    override fun collisionDamageWith(other: IFlyer): List<IFlyer> {
        score += other.score
    }

IDEA informs me there is no such thing in IFlyer. We could check the type, but that’s not what we want: we want never to check type. We’re paying the cost of everyone looking at everyone for the simplified architecture. (Yes, this is “inefficient”. My belief is that we have all the cycles in the world.)

I add this to IFlyer:

    val score: Int
        get() = 0

I am informed by IDEA that I have to return a list, so I rewrite:

    override fun collisionDamageWith(other: IFlyer): List<IFlyer> {
        if (other.score > 0) {
            score += other.score
            return listOf(other)
        }
        return emptyList()
    }

The compiler is happy. I need to review my test, that was literally minutes ago.

    @Test 
    fun `scorekeeper captures this::other`() {
        val score = Score(20)
        val keeper = ScoreKeeper()
        val discards = keeper.collisionDamageWith(score)
        assertThat(discards.size).isEqualTo(1)
    }

I think that might just run. I am told I must use override. Makes sense:

class Score(public override val score: Int): IFlyer {
	...

This needs a return. That happened Saturday as well I think:

    override fun collisionDamageWithOther(other: IFlyer): List<IFlyer> {
        // cannot occur
    }

I am tempted to provide a default for this but we need to think. For now:

    override fun collisionDamageWithOther(other: IFlyer): List<IFlyer> {
        // cannot occur
        return emptyList() // satisfy the rules
    }

    override fun update(deltaTime: Double): List<IFlyer> {
        // this space intentionally blank
        return emptyList() // satisfy the rules
    }
Aside
It turns out collisionDamageWithOther that can occur, as we’ll find out while making tests run. The comment has been removed.

I did update as well, since it had the same problem. Test.

class ScoreKeeper: IFlyer {
    var score = 0

No, we can’t use that word, because we just defined it into the Score object. Rename to totalScore.

Now, suddenly, it complains about the “::” in the nest name. Fine:

    @Test
    fun `scorekeeper captures this vs other`() {

Test is green. Extend it a bit:

    @Test
    fun `scorekeeper captures this vs other`() {
        val score = Score(20)
        val keeper = ScoreKeeper()
        val discards = keeper.collisionDamageWith(score)
        assertThat(discards.size).isEqualTo(1)
        assertThat(discards).contains(score)
        assertThat(keeper.formatted()).isEqualTo("00020")
    }

Test, expecting success. Green. Could commit. Let’s do. Initial ScoreKeeper and Score. Not complete.

New test, for the other direction:

    @Test
    fun `scorekeeper captures other vs keeper`() {
        val score = Score(20)
        val keeper = ScoreKeeper()
        val discards = score.collisionDamageWith(keeper)
        assertThat(discards.size).isEqualTo(1)
        assertThat(discards).contains(score)
        assertThat(keeper.formatted()).isEqualTo("00020")
    }

Have I put enough in for this to succeed? Let’s test to find out. Right:

An operation is not implemented: Not yet implemented

Scorekeeper:

    override fun collisionDamageWithOther(other: IFlyer): List<IFlyer> {
        return this.collisionDamageWith(other)
    }

I expect green. I get it. Commit: ScoreKeeper goes both ways.

Now I’d really like the score to show up on the screen. To do that we “just” need to implement a draw method. And, after a lot of fiddling:

    override fun draw(drawer: Drawer) {
        drawer.translate(100.0, 500.0)
        drawer.stroke = ColorRGBa.GREEN
        drawer.fill = ColorRGBa.GREEN
        drawer.text(formatted(), Vector2(0.0, 0.0))
    }

In support of this, I also needed:

    program {
        val font = loadFont("data/fonts/default.otf", 640.0)
        ...

        extend {
            val worldScale = width/10000.0
            drawer.fontMap = font
            drawer.scale(worldScale, worldScale)
            game.cycle(drawer,seconds)
        }

And now we have this nice picture:

score

Now there is the matter of the actual scoring. When an Asteroid splits, it needs to record its store, by creating a Score object with the right value. One issue is that we don’t really know whether we are an asteroid over in Flyer. Currently we have this:

    override fun finalize(): List<Flyer> {
        if (splitCount < 1) return listOf()
        val meSplit = asSplit()
        val newGuy = meSplit.asTwin()
        return listOf(meSplit, newGuy)
    }

Now the list we return is stuff that’ll be added to the Flyers, so we can pitch a Score in. What troubles me is what we know and when we know it. I think that we still have our current killRadius as we get here, so, naively …

    override fun finalize(): List<Flyer> {
        if (splitCount < 1) return listOf()
        val score = getScore()
        val meSplit = asSplit()
        val newGuy = meSplit.asTwin()
        return listOf(meSplit, score, newGuy)
    }
    
    private fun getScore(): Score {
        val score = when (killRadius) {
            500 -> 20
            250 -> 50
            125 -> 100
            else -> 0
        }
        return Score(score)
    }

I don’t like these literals, but I think this may just work. Two problems. First, we have to return an IFlyer, not a Flyer:

    override fun finalize(): List<IFlyer> {
        if (splitCount < 1) return listOf()
        val score = getScore()
        val meSplit = asSplit()
        val newGuy = meSplit.asTwin()
        return listOf(meSplit, score, newGuy)
    }

Second, we have to test against Double, not Int:

    private fun getScore(): Score {
        val score = when (killRadius) {
            500.0 -> 20
            250.0 -> 50
            125.0 -> 100
            else -> 0
        }
        return Score(score)
    }

I really hate using Doubles there. I don’t trust them to be equal to specific values. But I think it’ll work.

Playing the game tells me that in fact it doesn’t work. I could debug but on the screen it’s hard to tell what’s happening. Let’s do a test.

You can tell that I didn’t run my tests after adding a Score into the Flyers, because I get a couple of places where I was expecting List<Flyer> that needed to be List<IFlyer>, and also I’m checking velocity here and it’s not defined:

    @Test
    fun `new split asteroids get new directions`() {
        val startingV = Vector2(100.0,0.0)
        val full = Flyer.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 = full.finalize()
        halfSize.forEach {
            val halfV = it.velocity
            assertThat(halfV.length).isEqualTo(100.0, within(1.0))
            assertThat(halfV).isNotEqualTo(startingV)
        }
    }

Velocity is not included in the IFlyer interface, and I’d have preferred that it wasn’t. For now, I have a defect, so let’s add it.

    val velocity 
        get() = Vector2(100.0, 0.0)

Test to get things running. Now we have failures on tests counting returns, because we have a new flyer in the mix. This test, adjusted, will show the bug:

    @Test
    fun `asteroid splits on finalize`() {
        val full = Flyer.asteroid(
            pos = Vector2.ZERO,
            vel = Vector2.ZERO
        )
        val radius = full.killRadius
        val halfSize= full.finalize()
        assertThat(halfSize.size).isEqualTo(3) // two asteroids and a score
        val half = halfSize.last()
        assertThat(half.killRadius).isEqualTo(radius/2.0)
        val quarterSize = half.finalize()
        assertThat(quarterSize.size).isEqualTo(3)
        val quarter = quarterSize.last()
        assertThat(half.killRadius).isEqualTo(radius/4.0)
        val eighthSize = quarter.finalize()
        assertThat(eighthSize.size).isEqualTo(1)
    }

I had to adjust the size checks to 3 … and the last one to check for 1 … but it’s going to fail with zero, because when we don’t split, we also don’t score. Check to be sure it fails. It does. Fix in finalize:

    override fun finalize(): List<IFlyer> {
        val result: MutableList<IFlyer> = mutableListOf(getScore())
        if (splitCount >= 1) {
            val meSplit = asSplit()
            result.add(meSplit.asTwin())
            result.add(meSplit)
        }
        return result
    }

That looks like a lot of change, but in fact I made a very minimal adjustment and then let IDEA refactor for clarity.

My test should run, but there are others … here’s one:

    @Test
    fun `ships do not split on finalize`() {
        val ship = Flyer.ship(Vector2(100.0,100.0))
        val didShipSplit = ship.finalize()
        assertThat(didShipSplit).isEmpty()
    }

I truly don’t like this. This is a side effect of all the current actual Flyers being a single class, with no distinction of type.

However, I’m feeling the pressure to make this work. Then, I tell myself, we’ll have time to do it right. Make it work, make it right, make it fast, amirite?

Let’s not add the Score in if it is zero. Tweak the code a bit:

    override fun finalize(): List<IFlyer> {
        val result: MutableList<IFlyer> = mutableListOf()
        val score = getScore()
        if (score.score > 0 ) result.add(score)
        if (splitCount >= 1) {
            val meSplit = asSplit()
            result.add(meSplit.asTwin())
            result.add(meSplit)
        }
        return result
    }

This is getting nasty. But I need it to work before I can make it right. Not my best morning. More tests need tweaks.

I’ve got one that I don’t understand:

It’s my monster story test, wouldn’t you just know it, failing near the very end:

    @Test
    fun `ship monitor correctly adds a new ship`() {
        val sixtieth = 1.0/60.0
        val ship = Flyer.ship(Vector2(1000.0, 1000.0))
        val asteroid = Flyer.asteroid(Vector2.ZERO, Vector2(1000.0,0.0))
        val monitor = ShipMonitor(ship)
        val game = Game()
        game.add(ship)
        game.add(asteroid)
        game.add(monitor)
        assertThat(game.flyers.size).isEqualTo(3)
        assertThat(game.flyers.flyers).contains(ship)
        assertThat(monitor.state).isEqualTo(ShipMonitorState.HaveSeenShip)

        // nothing colliding
        game.update(sixtieth)
        game.processInteractions()
        assertThat(game.flyers.size).isEqualTo(3)
        assertThat(game.flyers.flyers).contains(ship)
        assertThat(monitor.state).isEqualTo(ShipMonitorState.HaveSeenShip)

        // ship colliding, make two asteroids and lose ship
        ship.position = Vector2.ZERO
        game.update(sixtieth)
        game.processInteractions()
        assertThat(game.flyers.size).isEqualTo(4) // two asteroids, one score, one monitor
        assertThat(game.flyers.flyers).doesNotContain(ship)
        assertThat(monitor.state).isEqualTo(ShipMonitorState.HaveSeenShip)

        // remove asteroids to avoid multiple collisions (hack)
        game.flyers.forEach { it.move(1.0)}

        // now we discover the missing ship
        game.update(sixtieth)
        game.processInteractions()
        assertThat(game.flyers.size).isEqualTo(4) // still two asteroids, one score, one monitor?
        assertThat(game.flyers.flyers).doesNotContain(ship)
        assertThat(monitor.state).isEqualTo(ShipMonitorState.LookingForShip)

        // There has been no ship. Update should add it.
        // thus adding in ship and monitor.
        game.update(sixtieth)
        assertThat(game.flyers.flyers).contains(ship)
        assertThat(monitor.state).describedAs("just switched").isEqualTo(ShipMonitorState.HaveSeenShip)
        game.processInteractions()
        assertThat(game.flyers.flyers) -- <=== this one is not finding the ship. 
        	.describedAs("after interactions after adding")
        	.contains(ship)
        assertThat(game.flyers.flyers).contains(monitor)
        assertThat(monitor.state).isEqualTo(ShipMonitorState.HaveSeenShip)
    }

For this to have happened, something has to have collided with the ship, or so it seems to me. It’s new, so it must be the Score. What does a Score do on collision? Well, it defers to the other, so it’ll defer in this case to the ship. The ship will inquire about its collision radius. This is why we have that flag.

Ha!

This code:

    override fun collisionDamageWithOther(other: IFlyer): List<IFlyer> {
        if ( this === other) return emptyList()
        if ( this.ignoreCollisions && other.ignoreCollisions) return emptyList()
        val dist = position.distanceTo(other.position)
        val allowed = killRadius + other.killRadius
        return if (dist < allowed) listOf(this,other) else emptyList()
    }

Ah. The ignoreCollisions exists if both Flyers have the flag true. It’s used so that asteroids hitting asteroids don’t crack up. We have no real respite but to move the Score object to a location where it cannot collide with anyone. Should do that with ShipMonitor … and all the special guys in fact.

Ah. The defect is trivial once seen. The default kill distance, which just defaulted this morning to 20000.0, needs to be Double.MAX_VALUE. That ensures that you can never be close enough, and if that’s the case, the position won’t matter. We’ll still leave them outside, I think.

Tests are green. We can commit: Game keeps proper score of killed asteroids.

We need to make this code better. But I am tired. Time to sum up and take a break.

Summary

The notion of special-purpose objects in the mix is holding up. We’ve just added two, a ScoreKeeper that looks to interact with objects responding to score (which all do), and the Score object, the only one that has a non-zero Score. While this is arguably inefficient, you can think of it as analogous to a general announcement being made “Someone Scored 200” and everyone getting the message and ignoring it except for the ScoreKeeper.

However, there are things to be concerned about:

Number of Interactions

Every time we add one of these things, we increase the number of interactions in the main cycle. We interact each item with every other (with one-way interactions). So there are N*(N-1)/2 interactions. Every time we add one, we add N-1 to the number of times we go through the loop. I remain unworried about this. On an Intel 8008 I might gave been worried. On an M1 Mac, I’m not.

Tricky Collision Handling
There’s a bit of trickiness to the handling of the collisions. All our special objects get first shot at processing, but our Score object has to defer back, because it wants to be processed only by the ScoreKeeper. So it dispatches back to the other possible colliders, asteroids, ships, and the like. In fact, it wouldn’t be terribly hard to get a recursion going here, if we’re not careful. And, because the order of processing isn’t guaranteed, we might not run into the problem in tests.

It might be better if we had enough type information to allow us to have custom-made interactions for each pair that can occur. I think we’re getting a scent of that, and that the things we’ve done to avoid asteroids destroying asteroids and such are perhaps a signal that we need more types.

For now, I want to continue on this path, but there’s surely some improvement to be made to keep things simple. And it might turn out that some or all of this idea of everyone just flying about doing their own thing … well, it might not be a good enough idea. I do like it … but not at the cost of understanding. To that end …

Double Dispatch
Normally in a double dispatch situation we’d be passing type information with the dispatch. We’ve just got the one case. Maybe we should be sending something like collisionWithCollider and collisionWithNonCollider. That might be a nice refinement.
Tricky Split Logic
The split logic for Score is tricky, because all it knows is the splitCount of the objects in hand. The ship and missile are always 0, while asteroids start at 2 and tick down 2, 1, 0 … and while the ship doesn’t score when it is at 0, the asteroid does. That needs improvement. Separate types for the actual flyers would probably help. Maybe we should inject a splitting strategy. That would allow us to do without specialized types.
Names
The real names of those methods are collisionDamageWith and collisionDamageWithOther. And that still doesn’t fully express what happens. The logic interacts the two elements of the pair and if whichever one is running so chooses, it can return something to be finalized and removed. She ScoreKeeper returns the Score, for example. The “standard case” is probably missile or ship vs asteroid, which returns both as ready to be finalized and removed.

I’d like the names to reflect that we’re just doing interactions, not collisions, and that the things that return are to be finalized and removed. That makes for a long name, which makes me think the concept may not be quite the thing. We’ll see.

Overall, I think we’re getting a bit crufty, perhaps more than a bit. We need some cleanup. And yet, it’s working quite nicely. The scheme of objects collaborating with no god object is holding water pretty well.

We’ll see about improving this over the next few times. For now: He shoots! He scores! Here I am with a relatively high score:

higher score

We really need to do something about those missiles lasting forever. Those babies are dangerous!



  1. It’s Halloween, one of the modern high holidays here where I live.