Python Asteroids on GitHub

Some detailed thinking on how to refactor to a decentralized model. With any luck, some starting code. But I do feel the need of the thinking part.

The basic idea:

Refactor the current design so that each object interacts with all other objects and manages its own behavior such as exploding, dying, spawning children, racking up score points. Whatever the game needs to do should be done by individual objects in “space”, not by a centralized authority in the game. There will be a centralized authority, but it will know no details of the game, or as close as we can get to that.

The current design:

As the design works currently, there are aspects that need to change, including but not limited to:

  • The Game object has separate collections for each kind of space object, asteroid, missile, and so on;
  • The game choreographs interactions among those groups, such that each object type sees all other types but does not see its own type. Asteroids do not interact with asteroids because the top level prohibits it.
  • The game checks whether interacting objects are within range to destroy each other;
  • If the objects are in range, the game collects score information from each;
  • If the objects are in range, the game tells them that they are destroyed, passing them the collection they are in, and they remove themselves from it.

As I read the code just now, I’m not sure how the code manages the remove from a collection that is being iterated. It is possible that there is a bug there. If there were, it would look like an object being skipped in one frame. Since things don’t change much frame to frame, it probably gets picked up next time around. I just don’t know.

I wonder if I could write a test for that. I bet I could.

Scoring is intricate enough that I think I’ll describe it here. If a target and attacker (any two different space objects) are colliding, the game does this:

    self.score += target.score_for_hitting(attacker)
    self.score += attacker.score_for_hitting(target)

Each object implements score_for_hitting, and it goes like this for Asteroid:

class Asteroid:
    def score_for_hitting(self, attacker):
        return attacker.scores_for_hitting_asteroid()[self.size]

    @staticmethod # probably never called
    def scores_for_hitting_asteroid():
        return [0, 0, 0]

class Missile:
    def scores_for_hitting_asteroid(self):
        return self.score_list
        # list is 0,0,0 for saucer missiles 
        # and actual score values for ship missiles

class Ship:
    @staticmethod
    def scores_for_hitting_asteroid():
        return [0, 0, 0]

class Saucer:
    @staticmethod
    def scores_for_hitting_asteroid():
        return [0, 0, 0]

Other implementations of score_for_hitting:

class Missile:
    @staticmethod
    def score_for_hitting(_anyone):
        return 0

class Ship:
    @staticmethod
    def score_for_hitting(_anyone):
        return 0

class Saucer:
    def score_for_hitting(self, attacker):
        return attacker.scores_for_hitting_saucer()[self.size - 1]

The saucer works similarly to the asteroid underneath. The various attackers return various results, with values or without. Missile is the only one that returns values:

class Missile:
    def scores_for_hitting_saucer(self):
        return self._saucer_score_list

Here again, the missile is initialized with or without scores depending who fired it:

class Missile:
    @classmethod
    def from_ship(cls, position, velocity):
        return cls(position, velocity, u.MISSILE_SCORE_LIST, u.SAUCER_SCORE_LIST)

    @classmethod
    def from_saucer(cls, position, velocity):
        return cls(position, velocity, [0, 0, 0], [0, 0])

That’s rather intricate, but what it comes down to is that only Missiles ever deliver a non-zero score, and then only if they are from the Ship.

What we have here, of course, is a classic “double dispatch” where the Game asks a colliding asteroid, say, for its score, and the asteroid asks its attacker “score_for_hitting_asteroid”, and the recipients all say NOTHING, except for missiles, which compute a score and return it. Almost all the queries return zero, because only asteroids collect any score, and then only if hit by a ship missile.

But it all works out quite nicely, although it is a bit hard to think about as all double-dispatches seem to be. Just me? Oh ….

What we’ll need:

The core idea here is to remove all knowledge of game specifics from Game object. It should end up with just one collection of objects, including asteroids, missiles, saucers, and ships, but also including other objects that make the game do what the game does.

The Game will send a few staged messages, each one here sent to all before the next is sent:

  1. Tick(delta_time) - your chance to move
  2. Prepare for interactions - your chance to know that you’re about to get a raft of interaction messages
  3. Interact with: another_object - you’ll see every other object here
  4. Interactions are over - your signal that all the interactions are complete
  5. Draw - your chance to draw if you care to

In at least some of these, each object has an opportunity to remove itself from future consideration, and to add new objects for future consideration. This operation needs to take effect on the next time through the cycle, not immediately.

Let me give an example of why.

Suppose that a large asteroid is destroyed by a ship missile. It needs to remove itself, to add two new medium asteroids, and to add a Score object containing whatever score has accrued.

In the next cycle, the Score and ScoreKeeper interact. The ScoreKeeper has to see the Score, so that it can accumulate the score. The Score, when it sees the ScoreKeeper, removes itself for the next cycle so that it accrues only once.

Some folks think this is just too intricate to be allowed to live. I think it works a lot like reality, at least according to my cosmology. I do not envision a god-like creature that guides me to the fridge and hands me a LaCroix. I see myself and fridge and the LaCroix interacting, each doing its bit to get me a drink.

Be that as it may, I’m going to do it, because it’ll be fun and I’ll surely learn something. The main area of learning will be that my requirement is that the game never stops working, and we commit code at least once per session and ideally many times. And we might even have some new features to inject. We need a few, including a saucer explosion effect, a period of time when you can’t jump back to hyperspace (refractory period), maybe a star field.

How we might proceed:

Our current Fleets object has separate collections for each kind of flyer, asteroids, missiles, saucers, saucer missiles, ships, and explosions.

I’m not sure, looking at that, what advantage I’m getting from separating saucer missiles from ship missiles, since the scoring tables are correct. Let’s do a quick test.

Curious. I changed Fleets to return the regular missiles collection when you ask for the saucer missiles. The effect was that neither the ship nor the saucer can fire any missiles at all.

I decide not to chase that issue. I do recall why I have the two collections: it’s because you can have four simultaneous ship missiles and two simultaneous saucer missiles, so I keep them separate so as to count.

But I digress:

We will want to wind up with a single collection of all objects. Since we’re on the topic, how might we ensure that there can only be two saucer missiles at a time? Well … since the saucer will see all the missiles (among all the other things), it can just count saucer missiles and set a flag to itself saying that it cannot fire. Next time around, the flag will be set and it can’t fire. Maybe it fires during “interactions are over”. Anyway, easily done.

That’s the basic pattern for the global questions objects may want to ask: count things and decide based on how many you see. Count asteroids. See none. Time for a new wave. Etc.

We currently pass an object’s fleet in during interaction, and the full Fleets object. The former is used for adding or removing selves from the mix and the latter is used for asking global questions.

When this is done, we’ll just have one big Fleet in Fleets, and we’ll collapse out some of its capability.

Let’s look again at the destruction code for Asteroid:

class Asteroid:
    def destroyed_by(self, attacker, asteroids, fleets):
        self.split_or_die(asteroids)

    def split_or_die(self, asteroids):
        if self not in asteroids:
            return # already dead
        asteroids.remove(self)
        self.explode()
        if self.size > 0:
            a1 = Asteroid(self.size - 1, self.position)
            asteroids.append(a1)
            a2 = Asteroid(self.size - 1, self.position)
            asteroids.append(a2)

As things stand, we’re sending remove and append to a Fleet, namely the AsteroidFleet. When we collapse things, that will be the game’s single fleet instance and we’ll just add to it whatever we add. We’ll lose the type of the thing but our things manage type communication otherwise, via double dispatch.

180 lines, still no real plan, no real code?

That’s OK. We’re sure to take some wrong steps, but we need to at least generate a general notion of the direction we’re going to take.

I was thinking maybe we could start with Score and ScoreKeeper. That would mean that we’d need to build new collections for them and iterate those separately with the new methods for preparing, interacting, and finishing up. That’s large enough that I doubt we can get it done in what remains of this session: I’m already 90 minutes in right now.

Another angle would be to modify the interaction logic to give the existing object the double dispatch that will be needed. We currently send destroyed_by to each of them. We could send the more generic interact_with with a rename. Then each object could send interact_with_CLASS to the other, and we could put the action there. We could incrementally (double) check range in there.

I think that will be interesting. I don’t promise to like it.

Let’s try a spike. We’ll do the hard cases, missiles and asteroids.

I don’t promise to throw this away. If it works as I imagine, I might keep it.

The first thing is to rename desroyed_by to interact_with. We should be able to keep that.

That was harder than I expected. PyCharm didn’t find all the places needing the rename, I suppose because it didn’t know they were the same. I think that’s because not all the classes inherit from the Flyer “abstract class”, just enough to test that refactoring issue that I had a while back.

Anyway, they are changed now. Green and game works. Commit: change destroyed_by to interact_with.

Now we’ll want asteroid to send interact_with_asteroid to its attacker, and missile to send interact_with_missile to its attacker. That will mean that all the objects have to implement those two methods. Let’s see if we can write a test for that.

I decide that since Python has reflection, I’ll use it. My first test is this:

    def test_double_dispatch_readiness(self):
        classes = [Asteroid, Missile, Saucer, Ship, Fragment]
        for klass in classes:
            attrs = dir(klass)
            assert "interact_with" in attrs, f"missing method in class {klass.__name__}"

That fails, telling me that class Fragment does not have that method. No surprise there, but before we’re done, all the classes will have to support all the combinations, so let’s fix that and move on.

class Fragment:
    def interact_with(self,  _attacker, _fragments, _fleets):
        pass

Commit: testing for existence of interact_with. added to Fragment.

Now we’ll start layering in the new methods, I guess:

    def test_double_dispatch_readiness(self):
        classes = [Asteroid, Missile, Saucer, Ship, Fragment]
        for klass in classes:
            attrs = dir(klass)
            assert "interact_with" in attrs, f"missing method in class {klass.__name__}"
            assert "interact_with_asteroid" in attrs, f"missing method in class {klass.__name__}"

This is going to get messy. I think what I want is a test that lists class name and missing method name as an error. Let’s get fancy.

    def test_double_dispatch_readiness(self):
        classes = [Asteroid, Missile, Saucer, Ship, Fragment]
        errors = []
        for klass in classes:
            attrs = dir(klass)
            methods = ["interact_with", "interact_with_asteroid"]
            for method in methods:
                if method not in attrs:
                    errors.append((klass.__name__, method))
        assert not errors

That works nicely, with this output:

>       assert not errors
E       AssertionError: assert not 
	[('Asteroid', 'interact_with_asteroid'), 
	('Missile', 'interact_with_asteroid'), 
	('Saucer', 'interact_with_asteroid'), 
	('Ship', 'interact_with_asteroid'), 
	('Fragment', 'interact_with_asteroid')]

Just what we want. Let’s implement them all to pass for now.

I’m honestly not sure what the parameters will be, but I can guess that we won’t want to include our own fleet and we will want to include the fleets object and ourselves.

class Asteroid:
    def interact_with_asteroid(self, asteroid, fleets):
        pass
class Missile:
    def interact_with_asteroid(self, asteroid, fleets):
        pass
class Saucer:
    def interact_with_asteroid(self, asteroid, fleets):
        pass
class Ship:
    def interact_with_asteroid(self, asteroid, fleets):
        pass

That leaves me just with Fragment, which I add:

class Fragment:
    def interact_with_asteroid(self, asteroid, fleets):
        pass

Test is green. Commit: Asteroid, Fragment, Missile, Saucer, and Ship all implement interact_with_asteroid as ‘pass’.

This is quite tedious. I can only imagine how bored you must be. Still, this is a useful test for being sure that we’ve got the methods we need in place.

What’s Next:

Next we’ll do interact_with_missile, and then try to rig the asteroid and missile interaction to go through the two sequences, something like this:

Asteroid

Receive interact_with. Just send interact_with_asteroid to attacker.

Receive interact_with_missile; fetch asteroids fleet from fleets, remove self, add splits. Deal with score, or not, depending on what we decide.

Missile

Receive interact_with. Just send interact_with_missile to attacker.

Receive interact_with_asteroid; fetch missiles fleet from fleets, remove self. Deal with score, or not, depending on what we decide. The missile has the score table, could issue the score.

Before this can fully work, we’ll need the Score and ScoreKeeper objects, and we might want to defer scoring for a while, allowing it to happen as it does now, up in Game.

It all remains to be seen. For now, we have a bit of starting scaffolding in place, and we have green tests and a clean commit.

We’ve just begun, but we have a bit of motion in the desired direction. I’m sure we’ll wander a lot, but with luck we won’t have to backtrack much.

See you next time!