GitHub Decentralized Repo
GitHub Centralized Repo

There’s a lot to like about the centralized version, but this morning I realized that it’s far from ideal. Let’s explore why and what to do.

Observations

One bit of good news, I think, is that we’ve removed a number of the “special” objects from the decentralized version, replacing them with explicit methods, typically in Game, to do housekeeping like starting new waves and replacing the ship. I think that’s good, but today I’m less certain, because of what’s coming up.

Another good news item is that we’ve been able to remove the rather arcane subscriptions model. As interesting and powerful as that is, it was also so clever that it probably had to go. The code is simpler and easier to understand without it.

However, I want to argue now that there is a very visible design flaw in the centralized system as it stands, and it is the way we handle interactions, with our shiny new Interaction object:

class Interaction(
    private val missiles: List<Missile>,
    private val ships: List<Ship>,
    private val saucers: List<Saucer>,
    private val asteroids: List<Asteroid>,
    private val trans: Transaction
) {
    init {
        missilesVsMissileShipSaucerAsteroids()
        shipVsSaucerAsteroids()
        saucerVsAsteroids()
    }

    private fun saucerVsAsteroids() {
        saucers.forEach { saucer->
            asteroids.forEach { asteroid ->
                saucer.interact(asteroid, trans)
                asteroid.interact(saucer, trans)
            }
        }
    }
    ...
}

The whole class is 60 lines long, so I’ll spare us the rest. Didn’t I copy the basic design right from the original Asteroids game? Now I think something is wrong with it? What’s up with that?

This object runs at the behest of Game. It is an extracted “Command” object, a helper that just keeps these 60 lines isolated and out of the Game class. But there are things not to like. First of all, it’s full of duplication repeating x.interact(y) 28 times, if my count is correct. Second, it is basically type-dependent code. It’s not checking types: that has been done for us in Transaction and SpaceObjectCollection, at a fairly high cost in lines of code and duplication. But even so, this code knows all about the types missile, saucer, ship, and asteroid, and it knows just exactly who can interact with whom.

This code is tightly coupled to the particular semantics of the game and to the particular type choices we made in implementing it. That’s just not as good as it might be.

You aren’t convinced

Perhaps you are not convinced that this is all that bad. Well, it isn’t terrible, and I’m proud to have made it work, but we can do better. Over the next few articles, we’ll see what we can do. And, unlike my usual practice, where I just follow my nose and see where I wind up, I will predict that we’ll wind up with substantially less code than we have now, and that the code we wind up with will be more clear and simpler than what we have now.

If that doesn’t happen, well, we’ll find out and I’ll eat, well, not crow. I’ll take a failure bow, how about that?

The Rough Plan

I plan to do roughly these things:

  • Convert back to a simple loop over all pairs of objects that can collide, rather than the large involved thing that is the current Interaction class.

  • Remove the separate collections for asteroids, missiles, saucers, and ships in SpaceObjectCollection, and in Transaction.

  • Use a simple double dispatch in the colliding objects to get to the right operations. We’ll get the dynamic effect of Hill’s Subscriptions, without the mystery.

Assuming that I’m correct, those changes will reduce the code in Interaction, Transaction, and SpaceObjectCollection, with very minimal increases in the code of the colliders.

And, as always, we’ll do this without breaking things, and in reasonably small steps. I’m not sure how we’ll do that last bit, this idea is new to me as well.

A First Detail

The decentralized version of the system, and early versions of this centralized version, used a method that produced all unique pairs of space objects. I need that capability again.

I think we just removed the entire notion of space objects from the system. No, we’re in luck. It’s still there:

    fun spaceObjects():List<SpaceObject> = asteroids + missiles + saucers + ships + splats

Let’s see if we can find how we did the pairs. A quick scan of commits, plus a diff, gives me this:

class SpaceObjectCollection ...
    fun pairsToCheck(): List<Pair<SpaceObject, SpaceObject>> {
        val pairs = mutableListOf<Pair<SpaceObject, SpaceObject>>()
        spaceObjects().indices.forEach { i ->
            spaceObjects().indices.minus(0..i).forEach { j ->
                pairs.add(spaceObjects()[i] to spaceObjects()[j])
            }
        }
        return pairs
    }

class GameTest...
    @Test
    fun `count interactions`() {
        val game = Game()
        val n = 12
        for (i in 1..n) {
            game.add(
                Ship(
                    position = Vector2.ZERO
                )
            )
        }
        val pairs = game.knownObjects.pairsToCheck()
        assertThat(pairs.size).isEqualTo(n*(n-1)/2)
    }

I’ll recast that test:

    @Test
    fun `count interactions`() {
        val objects = SpaceObjectCollection()
        val n = 12
        for (i in 1..n) {
            objects.add(
                Ship(
                    position = Vector2.ZERO
                )
            )
        }
        val pairs = objects.pairsToCheck()
        assertThat(pairs.size).isEqualTo(n*(n-1)/2)
    }

And test. Green. Let’s improve the pairs function a bit, it’s incredibly inefficient.

    fun pairsToCheck(): List<Pair<SpaceObject, SpaceObject>> {
        val spaceObjects = spaceObjects()
        val pairs = mutableListOf<Pair<SpaceObject, SpaceObject>>()
        spaceObjects.indices.forEach { i ->
            spaceObjects.indices.minus(0..i).forEach { j ->
                pairs.add(spaceObjects[i] to spaceObjects[j])
            }
        }
        return pairs
    }

I just cached spaceObjects because the call to spaceObjects() was recalculating the list every time.

Test. Green. Commit: Restore pairsToCheck and its test.

Now What?

Now I’d really like to take a small step here. Let me lay out the overall scheme, vague though it still is.

I plan to implement a new method in each collider, which will be a generic interact. Recall that each collider now implements all the specific collisions it can receive, given our overly complicated Interaction. For example, in Asteroid:

    fun interact(missile: Missile, trans: Transaction) {
        checkCollision(missile, trans)
    }

    fun interact(ship: Ship, trans: Transaction) {
        checkCollision(ship, trans)
    }

    fun interact(saucer: Saucer, trans: Transaction) {
        checkCollision(saucer, trans)
    }

It doesn’t implement the interaction with asteroid, because we know that will never be called, because this object knows how Interaction works. These object all implement SpaceObject and Collider interfaces. We’ll probably get down to using Collider here.

In my new plan, each object will implement all four of the combinations, this vs asteroid, this vs missile, this vs saucer, and this vs ship, and will implement one more method, this vs Collider (or maybe SpaceObject).

The Interaction will not know the types, it will only know that it is dealing with Collider (or maybe SpaceObject), so each receiver will initially execute this vs other, where other is a Collider. Everyone, by convention, will always respond to that by calling other.interact(this). Since we’ll be in a specific class, this will be a specific call, and it will be sorted correctly in the receiver class.

This is the same kind of thing we had in Subscriptions, without the necessity to build up a subscriptions table and so on.

I think that for clarity, we should not call both the generic method and the specific methods by the same name. In the decentralized version, the method was named callOther. I think here we’ll call it interactWithOther.

Let me just type in what Asteroid will look like when we’re done:

    fun interactWithOther(other: Collider, trans: Transaction) {
        other.interact(this, trans)
    }

    fun interact(asteroid: Asteroid, trans: Transaction) {}

    fun interact(missile: Missile, trans: Transaction) {
        checkCollision(missile, trans)
    }

    fun interact(ship: Ship, trans: Transaction) {
        checkCollision(ship, trans)
    }

    fun interact(saucer: Saucer, trans: Transaction) {
        checkCollision(saucer, trans)
    }

For this to work, we’ll have to add interactWithOther and all the interact possibilities to either the SpaceObject or Collider interface, whichever one we wind up using in the generic interactWithOther. But with it in place, we can be unaware of the specific types when we call interactWithOther, and the colliding objects will sort it out on their own.

I’m starting to see how I can do this incrementally. I think I can put these facilities into each Collider separately, test-driving the installation. Then, when they’re all working, we can plug in the pairs logic, removing all the complication from Interaction.

You might ask, if this is going to work, why didn’t we do that in the original decentralized version? Why did we put in that amazing Subscriptions object? I will plead guilty with an explanation.

I wanted not to have to implement all the possible interactions. In the decentralized version there weren’t just four possible interactions, there were a growing number, about eight or ten of them. That seemed burdensome to me and Hill took it upon himself to devise a scheme that would permit objects to implement only the interactions they actually cared about, defaulting the rest to do nothing, without the use of implementation inheritance.

I liked the subscriptions scheme and went with it.

But as we’ve refactored to centralized, we’re down to only four possible interactions, so the burden on the individual objects is smaller. In addition, I’ve become more comfortable with coding in the strict typing world, and am both a bit more adept and a bit more able to welcome the changes in style.

In other words, both the program and the programmer have changed to be open to a different solution. It’s great when that happens.

I have an appointment this morning, so this is as far as we’ll go, for now.

I’ll start another article, or append to this one, when I’m back from my appointment.

See you then!