Python 138
I think I’ve gone too far … I discuss, speculate, wonder, and do not as yet know what to do. Ideas welcome, as always.
There are 17 subclasses of Flyer, including two testing subclasses:
- Asteroid
- BeginChecker
- Coin
- EndChecker
- Explosion
- Fragment
- GameOver
- Missile
- Saucer
- SaucerMaker
- SaucerMissile
- Score
- ScoreKeeper
- Ship
- ShipMaker
- Thumper
- WaveMaker
Each of those subclasses must implement interact_with
, always the same way, calling, for a class named WaveMaker, interact_with_wavemaker
. This offers each subclass the opportunity to, well, interact with the WaveMaker whenever interactions are assessed.
This is the key design element that makes this version of the game work. Asteroids interact with missiles by splitting or exploding, while missiles interact with asteroids by perishing. The visible game objects, asteroid, missiles of both kinds, saucer and ship, all interact by destroying each other, except that asteroids do not destroy other asteroids.
Meanwhile, an object like ScoreKeeper really only expects to interact with the Score object, and the Score object only expects to interact with ScoreKeeper. The ScoreKeeper accumulates the score, and the Score object immediately destroys itself so that it is only counted once.
Some objects don’t really interact at all. The Coin object, for example, merely clears the entire field and then adds in a mix of objects that will create either a game, if the coin is a Quarter, or a Game Over screen, if the coin is a slug. Those objects are SaucerMaker, ScoreKeeper, Thumper, WaveMaker, and possibly ShipMaker.
The xMaker objects are all similar. They interact with the object they’re concerned with, Asteroids or Ships or Saucers, and if they don’t find any, they wait a respectful delay and then create whatever they’re in charge of, Ship from ShipMaker, Asteroids from WaveMaker, and so on. (Maybe we should rename that class AsteroidMaker. It makes what we call “waves” of Asteroids, but the A name might be better.)
In operation, this is all quite nifty. There is no central intelligence that makes the objects behave like the game of Asteroids™. The interaction of the individual objects results in a cooperative dance of those individuals, the desired game.
I really like this design. I think it has a certain elegance, and that it is really marvelous how the individuals all collaborate to make an interesting game.
But there is a serious issue with this design, that I’ve been struggling with for a few days now: managing the interactions has become far too complicated for me. Adding a new object, the SaucerMissile, has resulted in my committing at least three, perhaps four or more serious defects to the repo. Most of them were only in for a few minutes, and could have been avoided by some manual testing before committing, but at least one was in the system overnight.
This is not acceptable to me. I don’t expect to be perfect — on one is — but that many defects over one added object? That’s just too much.
When we run into trouble like this, there are a lot of responses we can make. We can write more tests. We can arrange for manual testing before shipping. We can form a “QA” department. We can pair program, because two sets of eyes find more problems before they ship. We can “try harder”.
And we can look at the code and its design, and determine why it’s not helping us do the right thing. That’s what we’re doing today.
What Caused the Defects?
To the best of my recollection …
We were adding the new SaucerMissile, a subclass of Missile, because it was clear that the missiles have two sub-types, ship missile and saucer missile, and there was code in some interact_with_missile
implementations that was checking the sub-type with an if.
That’s not good, although it worked and had I not gone to fix it we wouldn’t be here right now, but it seemed clear that yes, there are two kinds of missiles and so there “should” be two classes, because that’s how our design works.
Creating that class resulted in a number of difficulties. First, my tests that inspect the subclasses of Flyer to see that they implement the necessary methods did not consider sub-sub-classes of Flyer, so the SaucerMissile appeared to be passing tests where in fact it wasn’t even considered.
This problem could have been avoided by making SaucerMissile a direct descendant of Flyer. That would have created quite a bit of duplication, because SaucerMissile is a lot like Missile, so the inheritance “made sense”. The Zoom crew and I talked about that a bit and somehow that was my decision. I think I’d have done it that way on my own, but I know I was encouraged in that by the others.
No, if they jumped off a cliff, I wouldn’t necessarily jump off the cliff. They’re smart people, though, so I would at least look seriously at the idea.
There are a number of possible interact_with_xyz
methods that are “abstract” in Flyer, which means that all subclasses must have an explicit implementation of them. I believe, but would not swear, that I created the default interact_with_saucermissile
without making it abstract, because I knew that would require a lot of editing before things worked, so I believe I decided to defer that change until later.
With the default method in place, everything appeared to work very quickly. But the default response to interact_with_saucermissile
was to ignore it. Which meant that saucer missiles no longer killed anything.
Even in game play, you don’t notice that at first. The Saucer flies about firing missiles and you try to avoid them.
To make the missiles effective, we needed to change some classes: Asteroid, Missile, Ship, Saucer … all those must respond to interact_with_suaucermissile
.
Now the truth is that any object that interacts with a missile almost certainly needs to interact similarly with saucer missiles. Out of the 16 implementations of interact_with_saucermissile
there are 5 that do more than pass, and 11 that just pass.
There are four obvious classes that need to interact with a saucer missile: asteroid, missile (which handles saucer missile), saucer, and ship. What is the fifth? It’s ShipMaker, which won’t create a ship until there are no missiles flying about.
And that’s one that I accidentally implemented to pass. I had a process that I was following.
Once I had made interact_with_saucermissile
abstract, PyCharm would flag all the classes that needed new methods and would implement them for me. It implements them as pass
. So my flow was to go to the class, see that it needed one or more abstract methods, let PyCharm generate them, and then “think” about whether the object needed something more than pass. It was well down the list when I came to ShipMaker and my thinking by then was “all these special guys don’t need to interact with saucer missiles”, so I accepted the pass.
And shipped a defect: ship can emerge with saucer missiles still flying.
Bigger Picture
The defects were all similar to that one. There are too many different interact_with_
methods. They are implemented kind of randomly: objects that are interested implement them, others do not. When you’re creating an object, it’s easy to decide which things to listen to. But when you’re adding an object, there are 14 other classes that you have to get right, most of whom you get right by doing nothing.
That’s error-prone. I’ve proven it the painful way, by making errors.
I’ve had some suggestions about things to do:
Rickard Lindberg suggested:
@RonJeffries What about writing domain/”game rule” tests at a higher level? That is, you put a bunch of flyers in the collection, call update (or whatever it was called), have the collection do all the interact_with_, and assert something on the collection.
Then you could more easily trust your tests and you are free to implement it however you want (implementation inheritance, events, etc).
I think such tests might even read quite well as descriptions of the game rules.
I agree that I’d like to have that, but given the timing and distance constraints, with objects moving over time and timers running, I feel that writing that test is difficult and that it would be very difficult to maintain. It might be just the thing, but it doesn’t grab me.
Bil Wake observed that the 15x15 matrix of interactions would be simpler if it were symmetric. In the current case, it is partly symmetric and partly not. That is, it can be partitioned into a symmetric part and a non-symmetric part.
Together we observed that it would be nice to have a single explicit expression of all the interactions that we could look at (and that would compile into the code that does the job). And we also observed that 15x15 is a pretty nasty matrix. I do think it would reduce a bit with the right idea but I’m not sure what the right idea is.
Enough Problems! Bring Me Solutions!
- Aside
- If your boss ever says that to you, think seriously about whether you need a new boss, because when you’re faced with a problem you can’t solve, your boss is supposed to be the person to go to to get it solved. But maybe you’re just a whiner.
Me, I’m my own boss, so it’s fair for me to ask me for solutions. It’s what I do. Let’s consider some.
Reduce the number of Flyer subclasses.
Part of our problem is that we have an O(N^2) problem here, and keeping N low is helpful. Suppose we could get rid of three objects. 15x15 is 225 interactions to design, but 12x12 is only 144. It wouldn’t solve the problem but it could help.
- Coin
-
The Coin only exists for one tick and then disappears. Maybe we could inject its objects some other way, eliminating
interact_with_coin
from our perception. On the other hand, no one implements that method. - Explosion
-
The Explosion only exists for one tick and then creates a bunch of Fragments. We could create them without Explosion ever going into the pool. Again, no one interacts with Explosion, but it might simplify things.
-
Interesting fact:
interact_with_explosion
is abstract, so there are 16 implementations of it. Every single one of them ispass
. That’s a crock. Shouldn’t be that way. No one benefits from these methods being required everywhere. - Score
-
The Score object is just noticed by ScoreKeeper. I think it’s really need that an exploding asteroid emits a Score and ScoreKeeper lurks in space scarfing them up and accumulating the score. But is it worth the complexity? The impact here is limited because
interact_with_scorekeeper
defaults topass
and only Score implements it. -
But Score could just remove itself unconditionally on
tick
orend_interactions
. It doesn’t need to see the ScoreKeeper. I just found that a nice way to do it. - GameOver
- This could be a state, perhaps in ScoreKeeper, removing an object and methods from our concern. But no one watches for GameOver anyway.
- Thumper
- This object makes the da dum da dum sound if there are asteroids and a ship on the screen. It resets to a less scary rate when either all the asteroids are gone or the ship is gone. No one interacts with it. Maybe it could be merged with ShipMaker or WaveMaker.
Relax the interact_with
rule
The current rule is that every Flyer subclass Foo must implement interact_with
and must call interact_with_foo
in that method.
What if we defined a new kind of object that by definition did not reveal itself to others by calling interact_with_foo
? We might think of these as observers: they observe what’s going on but are not observed by other objects. (This is not the famous Observer pattern. I’m using the term “observer” in its generic sense.)
This change would allow ScoreKeeper, ShipMaker, SaucerMaker, and WaveMaker to exist in the space without impacting the design of the other objects.
I think this would be done with the aid of a bit more of a real hierarchy in Flyer, divided into Observers and Actors, or perhaps Audience and Actors. Hmm. Let’s think about this a bit more.
On the Actors side, the interact_with
rule would apply. They must implement that method by calling interact_with_
, giving their class name, and all those methods would be abstract to Actors, so that they must all explicitly say how they manage interactions with all other actors.
On the Audience side, they do not have to implement interact_with
and perhaps aren’t allowed to. If they do want to be interacted with … maybe they should be Actors. We’ll assume that there is no such concern, but I wouldn’t swear that it won’t be needed. I suppose the Audience could have a way of whispering to each other …
None of this really solves my problem.
At least part of my problem this time was that a new class of Flyer can into being, SaucerMissile, and there were objects that needed to interact with that new class, and I missed thinking of them or didn’t think deeply enough. In fact, it was an Audience type object that was one of my defects, because it needed to be extended to listen for SaucerMissile, and I too quickly accepted a pass
in that object.
Centralizing Interactions
I call this current design “decentralized” in that there is no common center of operation, only a number of independent cooperating objects. That is not to say that there is no center at all. The Fleets object (now probably needed a better name) holds on to all instances of all FLyers subclasses, and causes them to interact with each other.
Fleets is limited in how much intelligence it can apply to its job, and that is intentional. The individual Flyers know their class, but Fleets does not know the class of the objects it holds. It interacts them all, and we use the double dispatch of interact_with
->interact_with_asteroid
to reconstitute class distinctions at the last moment.
But it’s up to the individual classes to implement interact_with_xyz
for any XYZ
they care about, and not to implement interact_with_abc
for those ABC
instances that they don’t care about (or to implement it as pass
). We could perhaps make the list of classes we’re interested in more explicit somehow.
Suppose that in addition to implementing, say interact_with_foo
and interact_with_bar
, but not interact_with_baz
, the object implemented another method interactions
. Perhaps that would be a dictionary, something like this:
def interactions(self):
return {
Foo: self.interact_with_foo,
Bar: self.interact_with_bar
}
Then the Fleets’ interaction logic would fetch an object’s interactions
and look up the class in the dictionary and call the method if found.
Some kind of new object
Or maybe it’s an object we could call directly somehow, some kind of Dispatcher or something. Maybe there is some horrendous way we could override __dict__
and create an abomination of a fake object that dispatches just like a real object.
Or maybe it’s some data that we could parse once and cache for the future.
Compile some comments to code
Maybe we should build some kind of little language or a table layout that makes things more clear, and then compile it to code before we really get going. That would be amusing.
Too Blue-Sky?
Well, maybe. But maybe we can find some pythonic way of expressing just exactly what messages a Flyer responds to, in a compact enough form to make it easier to grasp and define than the current long list of def this_and_that
.
Explicit, clear definition of what we do and do not respond to would be helpful, presented in better a way than the current list of a bunch of defs
that are either there or not.
This Isn’t Just About Decentralized
This is not really due to the decentralized design per se. Any implementation of Asteroids has to deal with all the topics that our 17 classes deal with, and the interaction of those dealings is always complicated and error-prone. Most of the things we’ve encountered here we would encounter in any design.
However, our particular representation of these matters leaves a lot of related code spread around among the various Flyers. This is the sort of problem that Aspect-Oriented programming was devised to help with, and we are not going to go there. Probably not.
This design has highlighted the issue of the complex interactions of all the things that have to happen in even a simple program like Asteroids™, and our particular implementation isn’t helping as much as it might. We would always have to deal with these issues. Our problem here is to find a good way to deal with them given where we are.
Tentative Conclusions
It might be good to create the Audience / Actors distinction, because there are some objects that are really invisible to all the others, simply observing the others without revealing themselves.
It might be good to eliminate some objects, like Explosion, which could be replaced with an object that directly injects Fragments into the mix.
But these are really just stopgaps. They don’t address the basic issue, which is that the strength of this design is also a weakness.
Objects in the mix can observe anything they want to, and can take actions affecting the mix, or even other objects, generally without changes to those other objects. This often makes adding new objects quite easy.
Except that when we add another object to the mix, we have to consider how each other object interacts with our new one, and whether or not it’s really OK for the others not to know about the new one. This sometimes makes adding new objects tricky and error-prone.
I look forward to finding out what I try next. Seriously. Got ideas? ronjeffries on mastodon dot social. Or, if you must, on the bird site.
See you next time!