Kotlin 138: Just Count 'em
I have dedicated the next portion of my life to implementing that cool hyperspace emergence rule. It means that I have to be able to count asteroids. So be it. But how? You’d think it’d be easy. It isn’t.
The rule for odds of surviving hyperspace emergence is this:
“choose a random even number from 0-62, explode if random_number >= number_of_asteroids + 44. So, looks like hyperspace should never randomly explode if there are at least 19 asteroids on the screen. Anything less, and there’s a chance.”
In a fit of stubbornness, I want to replicate this rule, despite the fact that this program almost actively wants me not to know how many asteroids there are. I suppose that makes it an interesting problem and a test of the program’s rather unusual design:
There is no object in the game that contains only asteroids, or even is only an asteroid.
So far, all the objects in the game are implementors of ISpaceObject
, and all the visible ones are instances of SolidObject
, which has a plug-in view to make it look different, some values to control where it is, and a plug-in function to finalize it.
Before we start, let me mention my current leading idea for how to identify asteroids. I’m thinking we could add a member variable to all the SolidObject
instances, zero for everything but asteroids, and one for asteroids. Then, somehow, we could add up those values and know how many asteroids we have. My objection to this idea is just that it’s a datatype trying to be born. It’s a binary flag saying “I am an asteroid”, just slightly disguised. When a datatype wants to be born, it behooves us to let that happen. So this idea, my current best one, is a hack. But it would be easy.
Still, with that bright signal, I think it might be time to create different classes where SolidObject
sits in the hierarchy. And, to be clear, there are differences internally in the different SolidObject
instances. Let’s see what some of those differences are. That will help us decide what to do.
Probably the best way to consider differences is to look at the factory methods in the SolidObject
companion:
fun asteroid(pos:Point, vel: Velocity, killRad: Double = 500.0, splitCount: Int = 2): SolidObject {
return SolidObject(
position = pos,
velocity = vel,
killRadius = killRad,
mutuallyInvulnerable = true,
view = AsteroidView(),
finalizer = AsteroidFinalizer(splitCount)
)
}
fun missile(ship: SolidObject): SolidObject {
val missileKillRadius = 10.0
val missileOwnVelocity = Velocity(U.SPEED_OF_LIGHT / 3.0, 0.0).rotate(ship.heading)
val standardOffset = Point(2 * (ship.killRadius + missileKillRadius), 0.0)
val rotatedOffset = standardOffset.rotate(ship.heading)
val missilePos: Point = ship.position + rotatedOffset
val missileVel: Velocity = ship.velocity + missileOwnVelocity
return SolidObject(
position = missilePos,
velocity = missileVel,
killRadius = missileKillRadius,
lifetime = 3.0,
view = MissileView(),
finalizer = MissileFinalizer()
)
}
fun ship(pos:Point, control:Controls= Controls()): SolidObject {
return SolidObject(
position = pos,
velocity = Velocity.ZERO,
killRadius = 150.0,
view = ShipView(),
controls = control,
finalizer = ShipFinalizer()
)
}
fun shipDestroyer(ship: SolidObject): SolidObject {
return SolidObject(
position = ship.position,
velocity = Velocity.ZERO,
killRadius = 100.0,
)
}
fun splat(missile: SolidObject): SolidObject {
val lifetime = 2.0
return SolidObject(
position = missile.position,
velocity = Velocity.ZERO,
lifetime = lifetime,
view = SplatView(lifetime)
)
}
}
I notice mutuallyInvulnerable
right away. It’s true in asteroid and not true for any other object. Looks like a datatype to me. Let’s see how it’s used:
class SolidObject ...
private fun weCanCollideWith(other: ISpaceObject): Boolean {
return if ( other !is SolidObject) false
else !(this.mutuallyInvulnerable && other.mutuallyInvulnerable)
}
This code says, in essence, that a SolidObject can’t collide with a non-Solid, and that if they’re both mutuallyInvulmerable, they can’t collide. And look: it’s already doing a type check.
- Aside
- There’s surely a way to avoid at least the type check with another message dispatch, and if Chet were here we might do it. By the time I get this deep in the collision logic, I am ready to be done.
I think we have a winner. I think we can name this baby isAsteroid
just fine. Now we have:
private fun weCanCollideWith(other: ISpaceObject): Boolean {
return if ( other !is SolidObject) false
else !(this.isAsteroid && other.isAsteroid)
}
We have, in essence, discovered that we already have a type variable in SolidObject
at least sufficient to distinguish asteroids from everything else, and that’s part of what we need.
Up to this moment, other than renaming that member, we haven’t changed the code at all. Commit: rename mutuallyInvulnerable
to isAsteroid
.
Now we “just” have to count them. Have I mentioned that the word “just” often hides a lot of trouble? It surely does in this case. To count the asteroids, we “just” have to iterate over all the space objects and tally the ones that respond affirmatively to isAsteroid
. But … but … but the place where we want to know this is here, in ShipMonitor:
WaitingForSafety -> {
if (safeToEmerge) {
toBeCreated = makeEmergenceObjects()
nextHyperspaceFatal = random(0.0, 1.0) < U.HYPERSPACE_DEATH_PROBABILITY
HaveSeenShip
} else {
startCheckingForSafeEmergence()
WaitingForSafety
}
}
We can even write what we wish that line said:
nextHyperSpaceFatal = someoneCalculateTheOddsForMe()
And that method would contain the simple expression:
return randomEvenFrom(0,62) >= numberOfAsteroids() + 44
And … and who can we call to know numberOfAsteroids
? Ship monitor instances know the ship, and everything else is internal. The ship doesn’t know how many asteroids there are. The Game instance, which might exist but usually doesn’t, doesn’t know. It could be made to know: it could iterate over the space objects and inquire. But our design, and we’re sticking to it, doesn’t allow the Game to know how to do that.
One more concern: we don’t really want to know very often how many asteroids there are. We want to know only when we are about to return from hyperspace (which means that the current flag trick needs to be changed: we can’t pre-calculate the odds, because at the time this call is made, we don’t know how many asteroids there will be when the player next goes to hyperspace. Important safety tip, Egon.)
We could build a new space object that counts asteroids via its collisions … oh! Why can’t we count them right in ShipMonitor? We could probably do it in the collision code:
override fun interactWith(other: ISpaceObject): List<ISpaceObject> {
if (state == LookingForShip) {
if (other == ship)
state = HaveSeenShip
} else if (state == WaitingForSafety){
if (tooClose(other)) safeToEmerge = false
}
return emptyList() // no damage done here
}
This code will get called with every object in the system as other
, including all the existing asteroids. (But we have no guarantee that those asteroids will still be alive at the time of the next update, which is when we do this. Missiles could take out additional asteroids. We might decide to ignore this fact. Once we are waiting for safety all the missiles have probably timed out.)
Yes. Let’s record the count of asteroids when, and only when, the state is WaitingForSafety
. Then we can use it. Once we use it, we can clean up the code to make it as clear as we can manage.
We have this method:
private fun startCheckingForSafeEmergence() {
// assume we're OK. interactWith may tell us otherwise.
safeToEmerge = true
}
We call that just twice, from here:
WaitingForTime -> {
if (elapsedTime < 3.0)
WaitingForTime
else {
startCheckingForSafeEmergence()
WaitingForSafety
}
}
WaitingForSafety -> {
if (safeToEmerge) {
toBeCreated = makeEmergenceObjects()
nextHyperspaceFatal = random(0.0, 1.0) < U.HYPERSPACE_DEATH_PROBABILITY
HaveSeenShip
} else {
startCheckingForSafeEmergence()
WaitingForSafety
}
}
These two calls, in update
, tell us that the next time we consider collisions, we should do our safety check. Since our safety check now includes the unfortunate possibility of a hyperspace malfunction, we can zero our asteroid count in that function:
private fun startCheckingForSafeEmergence() {
// assume we're OK. interactWith may tell us otherwise.
safeToEmerge = true
asteroidTally = 0
}
And …
override fun interactWith(other: ISpaceObject): List<ISpaceObject> {
if (state == LookingForShip) {
if (other == ship)
state = HaveSeenShip
} else if (state == WaitingForSafety){
if (other is SolidObject && other.isAsteroid) {
asteroidTally += 1
}
if (tooClose(other)) safeToEmerge = false
}
return emptyList() // no damage done here
}
I think that gives us the count.
I was thinking so hard about how to do it that I haven’t thought about how to test it. Let’s do write a simple test.
@Test
fun `can tally asteroids`() {
val tick = 0.01
val controls = Controls()
val ship = SolidObject.ship(Point(10.0, 10.0), controls)
val mon = ShipMonitor(ship)
mon.state = ShipMonitorState.WaitingForSafety
mon.startCheckingForSafeEmergence()
val a = SolidObject.asteroid(Point(100.0, 100.0), Velocity.ZERO)
val m = SolidObject.missile(ship)
val s = ScoreKeeper()
mon.interactWith(a)
mon.interactWith(a)
mon.interactWith(m)
mon.interactWith(s)
assertThat(mon.asteroidTally).isEqualTo(2)
}
That passes just fine.
Hm. Now if we had a method that answers whether you emerge safely, given a random even number between 0 and 62, we could test it.
assertThat(mon.asteroidTally).isEqualTo(2)
// rule is random(0-62) <= asteroidTally + 44
assertThat(mon.hyperspaceFailure(46)).isEqualTo(true)
assertThat(mon.hyperspaceFailure(45)).isEqualTo(false)
We implement:
fun hyperspaceFailure(random0to62: Int): Boolean {
return random0to62 <= (asteroidTally + 44)
}
I expect green. I fail:
expected: false
but was: true
I don’t see the error. Oh. My brain is wired upside down. I miss-stated the rule. Should be
assertThat(mon.asteroidTally).isEqualTo(2)
// rule is random(0-62) >= asteroidTally + 44
assertThat(mon.hyperspaceFailure(45)).isEqualTo(false)
assertThat(mon.hyperspaceFailure(46)).isEqualTo(true)
- Hello? Are you seeing this?
- What we see here is that trivial code that even a fool like me couldn’t get wrong, can be gotten wrong, and tests can and will turn up many silly mistakes.
I think we might be ready to make the hyperspace emergence do its thing. We can nuke that flag, to begin with.
IDEA flags some things when I ask for a safe delete, so I’ll edit a bit and then go again. Even though there is a test using that flag, I want it gone. We’ll work here:
private fun makeEmergenceObjects(): List<ISpaceObject> {
return when ((ship.position == U.CENTER_OF_UNIVERSE) or !nextHyperspaceFatal) {
true -> {
listOf(shipReset())
}
false -> {
val splat = SolidObject.splat(ship)
val destroyer = SolidObject.shipDestroyer(ship)
listOf(splat, destroyer, shipReset())
}
}
}
This is the only method that really needs to know whether we emerge safely.
Recast:
private fun makeEmergenceObjects(): List<ISpaceObject> {
return when ((ship.position == U.CENTER_OF_UNIVERSE) or hyperspaceWorks()) {
true -> {
listOf(shipReset())
}
false -> {
val splat = SolidObject.splat(ship)
val destroyer = SolidObject.shipDestroyer(ship)
listOf(splat, destroyer, shipReset())
}
}
}
We need that function:
fun hyperspaceWorks(): Boolean {
val ran = 33
return hyperspaceFailure(ran)
}
I just typed in the 33 so that I can think about the random call that I need.
I see no need for the even or integer aspects of the original spec, that’ll be down to how they create a random number. The deal is, we need an integer randomly selected between 0 and 62 (inclusive if we are to read 0-62 as it is usually meant. I still need to read the ancient scrolls on this).
fun hyperspaceWorks(): Boolean {
val ran = Random.int(0,62)
return hyperspaceFailure(ran)
}
Now can I remove that flag? Yes. IDEA just does it for me.
I surely have a failing test, because I removed its setting directly.
@Test
fun `hyperspace emergence`() {
val tick = 0.01
val controls = Controls()
val ship = SolidObject.ship(Point(10.0, 10.0), controls)
val mon = ShipMonitor(ship)
controls.hyperspace = true
mon.safeToEmerge = true
mon.state = ShipMonitorState.WaitingForSafety
val created = mon.update(tick)
assertThat(created.size).isEqualTo(3)
}
I think we’ll remove that since we have the other but we’ll wait. I want to try this in the game to get a sense of the odds.
In the game, with just one asteroid left, I feel like going to hyperspace is a truly bad idea. Let’s see, the comparison is r(0-62) >= 45
Wait, why am I returning what I’m returning above? Let’s look at that again.
fun hyperspaceFailure(random0to62: Int): Boolean {
return random0to62 >= (asteroidTally + 44)
}
fun hyperspaceWorks(): Boolean {
val ran = Random.int(0,62)
return hyperspaceFailure(ran)
}
Ah. I have that backward. We want not (!) to fail:
fun hyperspaceWorks(): Boolean {
val ran = Random.int(0,62)
return !hyperspaceFailure(ran)
}
I think I want to run an odds test for this. Let’s see, offhand there are 18 values between 45 and 62, inclusively, and 63 total possibilities, so 18/62 = 0.28. Yeah, I can believe that was what I was seeing.
But let’s see, we have that broken test, what do do about that?
- Aside
- I have just spent way too long sorting between Kotlin’s random and OPENRNDR’s random. I think I want to use Kotlin’s throughout. Let me go through and change whatever needs it. We don’t have too much random usage but we have some.
I want this throughout:
import kotlin.random.Random
I change all the others and see what won’t compile.
class SplatView(lifetime: Double): FlyerView {
private val rot = Random.nextDouble(0.0, 360.0)
private var sizeTween = Tween(20.0,100.0, lifetime)
private var radiusTween = Tween(30.0, 5.0, lifetime)
private val points = listOf(
Point(-2.0,0.0), Point(-2.0,-2.0), Point(2.0,-2.0), Point(3.0,1.0), Point(2.0,-1.0), Point(0.0,2.0), Point(1.0,3.0), Point(-1.0,3.0), Point(-4.0,-1.0), Point(-3.0,1.0)
)
class Game ...
fun createContents(controls: Controls) {
val ship = newShip(controls)
add(ship)
add(ShipMonitor(ship))
add(ScoreKeeper())
add(LifetimeClock())
for (i in 0..7) {
val pos = U.randomPoint()
val vel = Velocity(1000.0, 0.0).rotate(Random.nextDouble(0.0,360.0))
val asteroid = SolidObject.asteroid(pos,vel )
add(asteroid)
}
}
class ShipMonitor ...
fun hyperspaceWorks(): Boolean {
val ran = Random.nextInt(0,63)
return !hyperspaceFailure(ran)
}
object U {
const val HYPERSPACE_DEATH_PROBABILITY = 0.1
const val SPEED_OF_LIGHT = 5000.0
const val UNIVERSE_SIZE = 10000.0
val CENTER_OF_UNIVERSE = Point(UNIVERSE_SIZE / 2, UNIVERSE_SIZE / 2)
const val SAFE_SHIP_DISTANCE = UNIVERSE_SIZE/10.0
fun randomPoint() = Point(Random.nextDouble(0.0, UNIVERSE_SIZE), Random.nextDouble(0.0, UNIVERSE_SIZE))
}
I am concerned at this moment that I’m not seeding the generator or using it quite as intended but at least I’m consistent throughout. My best attempt at scouring through the Kotlin Random (random) documentation tells me that its next
functions are not inclusive of the final values. I double check that I’ve used the values correctly.
Could I mention at this point that I really hate testing anything involving random numbers? Thank you.
I play the game down to zero asteroids (it doesn’t recycle yet) and hyper around a bit and get 16 good to 5 bad which is at least reasonably close to what I expect, about 0.7 success rate assuming all my arithmetic has been correct, which assumes facts not in evidence.
I assert with a bit of reason behind me that the hyperspace thing is implemented as intended. I do not assert that it is well tested: I’ve tweaked tests and lost some. I want to improve the code but I need more tests to give me confidence.
I guess what we could do is work out by hand what the lowest winning or highest losing number is in our equation and test that. I don’t really want to write a stochastic test, they run long and can fail when the odds are not with you.
I do have a test that shows that we count asteroids correctly. Let’s review the hyperspace win/lose code and see what we might test:
fun hyperspaceFailure(random0to62: Int): Boolean {
return random0to62 >= (asteroidTally + 44)
}
fun hyperspaceWorks(): Boolean {
val ran = Random.nextInt(0,63)
return !hyperspaceFailure(ran)
}
Let’s redo these to pass in both parameters, asteroidTally and our random roll, and then we can test that directly. We can do this rather safely:
Now, for what it’s worth, I can exercise hyperspaceFailure
directly.
@Test
fun `hyperspace failure checks`() {
val ship = SolidObject.ship(Point(10.0, 10.0))
val mon = ShipMonitor(ship)
assertThat(mon.hyperspaceFailure(62, 19)).describedAs("roll 62 19 asteroids").isEqualTo(false)
assertThat(mon.hyperspaceFailure(62, 18)).describedAs("roll 62 18 asteroids").isEqualTo(true)
assertThat(mon.hyperspaceFailure(45, 0)).describedAs("roll 45 0 asteroids").isEqualTo(true)
assertThat(mon.hyperspaceFailure(44, 0)).describedAs("roll 44 0 asteroids").isEqualTo(true)
assertThat(mon.hyperspaceFailure(43, 0)).describedAs("roll 43 0 asteroids").isEqualTo(false)
}
This runs green and I believe the numbers to be correct and to bracket the expected behavior.
Let’s review what we have and see if we’re done for now. I feel whipped, so we’d better be.
class ShipMonitor(val ship: SolidObject) : ISpaceObject {
override var elapsedTime: Double = 0.0
var state = HaveSeenShip
var safeToEmerge = false
var asteroidTally = 0
fun hyperspaceFailure(random0thru62: Int, tally: Int): Boolean {
return random0thru62 >= (tally + 44)
}
fun hyperspaceWorks(): Boolean {
val ran = Random.nextInt(0,63)
return !hyperspaceFailure(ran, asteroidTally)
}
We have only one user of hyperspaceWorks
:
private fun makeEmergenceObjects(): List<ISpaceObject> {
return when ((ship.position == U.CENTER_OF_UNIVERSE) or hyperspaceWorks()) {
true -> {
listOf(shipReset())
}
false -> {
val splat = SolidObject.splat(ship)
val destroyer = SolidObject.shipDestroyer(ship)
listOf(splat, destroyer, shipReset())
}
}
}
I’d rather like to have the hyperspace failure function read exactly like the spec we have above:
choose a random even number from 0-62, explode if random_number >= number_of_asteroids + 44.
I’d also like the method above to be at least as clear as it is, and ideally more clear.
Let’s refactor that first. There are three cases hiding as two.
private fun makeEmergenceObjects(): List<ISpaceObject> {
return when (emergenceIsOK()) {
true -> {
listOf(shipReset())
}
false -> {
val splat = SolidObject.splat(ship)
val destroyer = SolidObject.shipDestroyer(ship)
listOf(splat, destroyer, shipReset())
}
}
}
private fun emergenceIsOK() = (ship.position == U.CENTER_OF_UNIVERSE) or hyperspaceWorks()
Is that clearer now? I think so. Clear enough? Let’s try one more:
private fun makeEmergenceObjects(): List<ISpaceObject> {
return when (emergenceIsOK()) {
true -> {
listOf(shipReset())
}
false -> {
val splat = SolidObject.splat(ship)
val destroyer = SolidObject.shipDestroyer(ship)
listOf(splat, destroyer, shipReset())
}
}
}
private fun emergenceIsOK() = notInHyperspace() or hyperspaceWorks()
private fun notInHyperspace() = (ship.position == U.CENTER_OF_UNIVERSE)
I like that, and IDEA did it for me. Test anyway. And aren’t we well overdue for a commit?
I push: Using old school hyperspace explosion logic. Per Jeff Grigg per some internet page.
There were a few warnings, but I checked them out.
Now back to the hyperspace methods. I’m just not loving them.
fun hyperspaceFailure(random0thru62: Int, tally: Int): Boolean {
return random0thru62 >= (tally + 44)
}
fun hyperspaceWorks(): Boolean {
val ran = Random.nextInt(0,63)
return !hyperspaceFailure(ran, asteroidTally)
}
No, I think they’re OK. THe top one does express the rule directly, and the bottom one provides the variables. Maybe rename tally
:
fun hyperspaceFailure(random0thru62: Int, asteroidCount: Int): Boolean {
return random0thru62 >= (asteroidCount + 44)
}
Cleaned up some warnings, none worth remarking, and I’m more than ready to be done.
Summary
In the end, this turned out to be simple, but I fumbled a number of times, making me glad for the tests I had and making me want to write more. The final implementation is just these additions:
-
Rename
mutuallyInvulnerable
toisAsteroid
. This is a bit naff, because we’re clearly doing to do something involving what kind of an object we have in hand, but “better” solutions didn’t come to mind. -
Use
isAsteroid
to develop a count of asteroids, in ShipMonitor, only when we need them, that is, when we’re about to rez the ship and need to decide whether it survives the hyperspace difficulty. -
We replicated the emergence check from the words we were given. I’d still like to see if I can decode the source code.
-
We refactored a bit to make the code a bit more clear.
Most important, I think, is that this has taken terribly long given its apparent simplicity. I’ve been at this article for a bit over 5 hours, with a lot of that time spent trying to work out the intricacies of Java random, Kotlin random, and OPENRNDR random. I’m still not sure that I’ve done that right.
The actual coding didn’t take long, but I had to test to determine whether the random calls were or were not inclusive of the end points. And I think but am not sure that the situation is not consistent across all of Java, Kotlin and OPENRNDR.
I don’t know that 5 hours is the right amount to spend on about 8 lines of code, but it is a tricky area that is not easy to get right by inspection, nor to check with tests. Maybe sometimes things just take longer than we feel they should … and maybe I have been off my game today. It could happen, to any of us.
Anyway, we now have what seems to be, probably, the official old-school chance of safe emergence from hyperspace.
Lessons Learned?
Well, testing finds problems that inspection does not.
And while we have again avoided having to create separate classes of SolidObject, there are at least some signals that those classes need to be born. So far, however, I don’t quite see the need.
See you next time!