Kotlin 124: Boo!
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:
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
andcollisionWithNonCollider
. 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
andcollisionDamageWithOther
. 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:
We really need to do something about those missiles lasting forever. Those babies are dangerous!
-
It’s Halloween, one of the modern high holidays here where I live. ↩