Python Asteroids+Invaders on GitHub

More waffles, about the tensions between design alternatives for the Flyers. What if we were to open the doors as widely as possible? I think this is my last waffle on this topic. I like the outcome. Today.

Issue

The interaction logic in our games is simple, it seems to me, but it has led to a lot of mostly useless code. It goes like this:

The core of the game, by design, does not know the classes of the many objects that make up a game. During game execution, there is a central cycle, and in that cycle, each object is sent a series of messages:

  1. update: adjust your position or take similar action;
  2. begin_interactions: prepare for the interaction cycle if you care to;
  3. interact_with: respond with interact_with_classname, for your own class;
  4. interact_with_classname: your chance to interact with every other object in the game;
  5. end_interactions: interactions are complete, take any final action desired;
  6. tick: take any action desired;
  7. draw: draw yourself on the screen if you care to.

Each of those is sent to each object once per cycle, except for interact_with_classname, which is sent to each object with the parameter set to every other object in the game. Every object interacts with all the other objects, in every cycle, and is aware of their class by virtue of the specific name of the interact_with_ method called. (Double Dispatch)

I believe that there is no game object that responds to all of these messages. In particular, tick is seldom used, typically by objects that need to time out.

At present, and by that I mean today, all of those methods are abstract except for begin_interactions and end_interactions. This means that every flyer object must implement around 16 methods that they may or may not be interested in.

Plan

Today, and by that I mean at 0812 on September 24th, I don’t like that. I propose a new rule:

  • There shall be no abstract methods having to do with the game cycle and interaction other than interact_with, which must be implemented like this for any Flyer other test objects: self.interact_with(other) -> other.interact_with_myclass(self), where myclass is the lowercase class name of the receiver.

There will be some sub-rules, I suppose.

  • The top-level behavior of all those methods will be pass.
  • There can be other methods, not part of the cycle, that are abstract, such as rect and mask.
  • All current pass implementations in concrete classes should be removed at the earliest convenient time.

So the new description of the cycle might go something like this:

During each cycle of the game, sixty times per second, every instance of any Flyer subclass in Fleets will be sent the sequence of messages shown above, 1-7. Your object may optionally implement any or all of those methods, following the calling sequence shown in the appropriate superclass. If you build the method, the messages will come. If you do not, they will not.

Concerns

Once this change is made, everything should work perfectly, unless I mistakenly remove a concrete method other than pass, which I plan not to do.

However, thereafter, it will be possible to break the game by inadvertently removing a key method such as draw or update from a class that needs it. It will be possible to break the game by adding a new object and forgetting that some other object should interact with it, or forgetting to update it or draw it.

I propose to live with those concerns and see what I am induced to do about them. I suppose that I might even put some or all of the abstract methods back: I’ve waffled on this several times now. What I hope and plan to do, however, is to devise other ways of ensuring that I do the right things, ways that are more direct and less inconvenient than all these useless pass methods everywhere.

And, of course, whatever happens, I’ll tell you what happened, what caused the issue as best I can tell, and what I choose to do about it.

Let’s do this. First, I’ll just remove the @abstractmethod marker on all those methods in the abstract classes Flyer, InvadersFlye, and AsteroidFlyer. The methods stay, to provide the default pass. They’re just no longer abstract.

Doing It

The first phase is just to remove @abstractmethod from everything in the cycle except for interact_with, which we are going to force objects to implement. The tests are green, of course. Commit: remove almost all abstract method decorators. Leave default methods.

Now, in principle, we can go around removing all those pass methods from concrete classes. In practice, I think we have a test that will break. So we’ll remove some and find out.

TopBumper is a marvelous example. It is 65 lines long going in and has just about no behavior at all. We’ll clean it, find out what the test is that breaks, fix that and then assess.

No tests fail and it is now 20 lines long:

class TopBumper(InvadersFlyer):
    def __init__(self):
        self.y = 40

    @property
    def mask(self):
        return None

    @property
    def rect(self):
        return None

    def interact_with(self, other, fleets):
        other.interact_with_topbumper(self, fleets)

    def intersecting(self, point):
        return point.y <= self.y

I am a bit surprised that no tests fail. I thought we had that one that cross-checked all the methods. Probably TopBumper is listed as not included in that test. Anyway commit: clear out unneeded pass methods.

I need to break that test and I honestly don’t know where it is. I’ll remove the redundant methods from a more central class, how about the InvaderPlayer? Removing all its pass methods doesn’t break a test. I have to find it. I think it used subclasses: I’ll search for that.

Ah. Of course. The test does check that for each subclass, all the relevant methods interact_with_klass are implemented, but because those methods are inherited now, they are implemented, by default.

That test is not entirely useless. When we implement a new Flyer subclass, it will check to be sure that we do implement the default method, so we’ll leave it in place. It’s not terribly valuable but that is a mistake that I have made and probably will make again.

I’m not quite tired yet, so let’s go through and remove some more of those pass methods. I’ll set up a little regex search and do a batch of them.

I do a few. There are a lot of subclasses of Flyer, around 25 or 30, but we have agreed to do this as time permits. So far, the ones I have done have removed about 25 or 30 lines from each edited class, often more. Why? Because there are a lot of auxiliary objects, and almost no object interacts with them. They do receive interaction messages, but by and large, the main flyers only concern themselves with other actual flyers and perhaps one or two of the auxiliaries.

Let’s sum up for now.

Summary

I’ve been struggling to find a middle way between abstract methods and a wide open scheme, and have kept coming back to abstract methods. Today, we’ve opened the doors all the way. The abstract methods are gone except for the ones that are actually required and actually have a chance to be helpful.

Arguably, this is a major change of my position, and equally arguably, it may be a serious violation of a good design principle, the avoidance of implementation inheritance.

That said, the implementation we are inheriting is always simply to ignore the message. The effect is to turn the way the flyers interact into a sort of publish-subscribe model. The game publishes a raft of messages, update, begin, interact, end, tick, draw, whatever all … and the Flyers can opt in on those messages, by implementing the corresponding method, and otherwise they are opted out.

In a more conventional pub-sub scheme you might say something like:

PubSub.subscribe([
    ("draw", self.draw),
    ("tick", self.tick),
    ...])

And then you’d implement draw and tick. Here you just implement the method and you are automatically subscribed.

I think that’s half of one, six a dozen of the other, and it’s close enough to pub-sub to satisfy me.

There are risks associated with a pub-sub model, typically that you’ll forget to subscribe to something that was important. And we have exactly those risks here. We’ll probably make related mistakes and when we do, we’ll decide how to protect ourselves from them. The protection afforded by our abstract methods was not sufficient, and it was expensive in huge tracts of white space and pass methods of no consequence.

I feel … relaxed, as if a great weight has been lifted from my shoulders. All those useless methods, looming over me like some bizarre object-oriented kaiju about to trample my beautiful program and bathe it in radioactive fire, are now gone. I feel safe and at peace, at least until another monster arises from the sea.

I am rather sure that I will not waffle again on this subject. I do expect that we’ll find some ways to improve our process and our tests in the light of what happens in the future.

See you next time!