Python 142
Must think about, maybe spike about, an idea from GeePaw Hill. How pure do I want to be, on what dimensions? In the end, “No!” and I’ll tell you why.
We’re going to have a long warmup here. Stick with me, we get down to cases, sort of.
I believe that in his heart, GeePaw Hill does not like the design of the current Asteroids implementation. He is of course polite in that impolite way that good friends have, he seems to understand it perfectly, and he seems to almost admire it in some regards, but there are things about it that, I think, he cringes at.
A core issue, of course, is implementation inheritance, which I use extensively (although only to implement a default pass
for everything). Hill is aware, as I am, that implementation inheritance can lead to horrible design and implementation issues unless it is used very well, and probably very sparingly. In part because he speaks and writes primarily toward the younglings in our community, he avoids it wherever possible, which is pretty much everywhere.
Hill also sees, as I do, that the decentralized design may be easy to create but that it is not so easy to understand. The core reason, I think, is that the many objects in the mix are hardly connected at all.
Normally in an OO design, this object has a pointer to that one, that one holds on to these ones, and they send messages to each other, with the “top” ones generally collecting information from the lower ones, and telling the lower ones to do things. Your code tends to have some object in hand, and to send it messages to make things happen.
This design is emphatically not like that at all.
What is the design then?
The “Flyer” subclasses never (well, hardly ever) hold on to another Flyer. There is no hierarchy of connection among them. There is just an undifferentiated collection of all the Flyer instances there are, and the guarantee that all pairs interact.
There’s actually a sequence of messages that a Flyer can receive in every cycle of the game:
- Update
- If the object moves, it should execute its move when it receives
update
. If it is subject to controls, like the ship, it reads them duringupdate
. - Begin Interactions
- Objects are sent this courtesy message before pairwise interactions start. An object can, if it wishes, set initial values of members reflecting what it may learn during interactions.
- Interact_with_*
- For every object in the mix other than itself, the Flyer will be sent
interact_with_*
, where*
is the class name of the other.interact_with_missile
, and so on. The receiver takes whatever action is necessary, generally dealing only with itself. It may check to see if it is too near the other and if it is, it may explode and remove itself as having been destroyed. It can ignore the message. It can keep track of having received the message, using members initialized inbegin_interactions
. -
An object might, for example, simply make note that an object of some class has interacted with it and therefore exists. The ShipMaker, for example, assumes on each cycle that there is no Ship, and it if receives
interact_with_ship
, makes note of the fact that there is one after all. - End Interactions
- This courtesy message is sent after all the pairs have been interacted. A receiver can take any summary action that it cares to at this point, based on what it has “learned” during the interactions. At this writing, no object implements this method: it is unused at this time.
- Tick
- This message is sent after
end_interactions
, and is used primarily to tick timers. Because it happens after interactions, implementors oftick
can base their behavior on state that has been gleaned during interactions. For example, ShipMaker checks to see whether a ship has been seen, and if the game is over and if not, ticks the timer that will, in due time, create a new ship:
def tick(self, delta_time, fleets):
if self._need_ship and not self._game_over:
self._timer.tick(delta_time, self.create_ship, fleets)
- Draw
- If the receiver is an on-screen object, it draws itself in response to this message.
During the cycle above, in an interact_with_*
message call, an object might send a message to another. For example, commonly, an object may send a message to the other, such as this, in class Asteroid:
class Asteroid(Flyer):
def interact_with_missile(self, missile, fleets):
self.split_or_die_on_collision(fleets, missile)
def split_or_die_on_collision(self, fleets, missile):
if missile.are_we_colliding(self.position, self.radius):
fleets.append(Score(self.score_for_hitting(missile)))
self.split_or_die(fleets)
class Missile(Flyer):
def are_we_colliding(self, position, radius):
kill_range = self.radius + radius
dist = self.position.distance_to(position)
return dist <= kill_range
Here, when interacting with a missile, we ask the missile whether we are colliding with it, given our position and radius. If the answer is True, we start the process of scoring and splitting the asteroid.
Notice that the interact_with_missile
and similar code does “know” the type of the object with which it is interacting. When interacting with another asteroid, the asteroid does not care:
class Asteroid(Flyer):
def interact_with_asteroid(self, asteroid, fleets):
pass
Asteroids just pass over or through each other when they appear to collide.
There are many asteroids, and many missiles in the mix at any given time. No object holds on to all the missiles and all the asteroids and has them interact, much less compare their values and tell them to split or die. Instead, the general pairwise interaction ensures that every missile will get a shot at every asteroid and vice versa.
Isn’t this “inefficient”? Surely it is. At the beginning of the game, there are 4 asteroids and the ship could shoot 4 missiles. In principle we’d check 4 missiles against each of 4 asteroids (16) and against each ship (4) and against each other (6). We’d check the ship against all the asteroids (4). I think that’s 30 checks. In the current scheme, there are 9 objects in total, so 9!/(7!*2!) = 36. And it only gets worse, the more asteroids there are. I think it the worst likely case it might be four times more costly. A couple of hundred interactions instead of maybe 50.
What I get for this pairwise interaction is that the objects are not owned by a god object that controls them and implements the game. They collaborate among themselves, mostly only concerning themselves with their own behavior, and the result is the game of Asteroids — with this collection of objects. A different collection could readily be the game of Spacewar! or SpaceInvaders. Perhaps a combination. Each object just deals with its own situation.
And I like that. I don’t claim it’s the best at anything. I just like it.
Weren’t you talking about GeePaw’s concerns?
I was, yes. I share the concerns, but they hit me with different intensity, and my goals are of course different. But this design is absolutely not easy to understand. You can describe exactly how it works, as I did above. You can understand exactly what the rules are, and I certainly do, and Hill certainly does.
But because the “game of asteroids” is not represented anywhere in the code, it’s really hard to discern why what we get is Asteroids, and even harder to discern when things are just a bit wrong. It’s not quite write-only code, it’s not Runs Once, Run Away. It’s pretty well structured, mostly readable, and yet … what exactly does it do, and why does it do it? It’s just not obvious.
The upshot of this is that GeePaw thinks about this program and sees things that stick out for him, and offers ideas about it. And he offered one a day or so ago.
Class variables counting missiles
The Ship can only have four missiles in flight at a time. The Saucer can only have two.
A week ago, the Zoom gang, including GeePaw, were looking at Asteroids, and noticed that the code for counting missiles was asking the missile whether it was from the ship or from the saucer. We identified that as basically a type check, suggesting that there should be two classes of Missile, one for ships and one for saucers, and in a fit of enthusiasm, I started to put that in.
I got a lot of trouble and five or six articles out of that, because it was a hassle to add that new class, and I shipped at least three defects during the process. (Shipped meaning they hit the GitHub repo.) I think one of them was there overnight, the others fixed in minutes. But they were shipped. That is bad.
A couple of days ago, I backed that change out. It was not worth the cost in terms of its impact all over the Flyer objects.
In the new scheme, a missile has two member variables, saucer_tally
and ship_tally
, set to 1 or 0 depending on whether the missile was fired by the saucer or the ship. These are used in Ship and Saucer to count the kind of missiles they care about:
class Ship(Flyer):
def interact_with_missile(self, missile, fleets):
self._missile_tally += missile.ship_tally
self.explode_if_hit(fleets, missile)
At the end of interactions, the Ship knows how many ship missiles were out there. (It might also be dead, but that’s not important right now.)
GeePaw suggested that the Missile class could have class-level variables, for ship missile count and saucer missile count, and could tick them up when a missile is created, and tick them down when it is destroyed.
Then, when Ship wanted to know whether it could fire, instead of having to repeatedly tally missiles as it does, it could just do something like this:
def fire_if_possible(self, fleets):
if self._can_fire and
Missile.ship_missile_count < u.MISSILE_LIMIT:
fleets.append(self.create_missile())
self._can_fire = False
The current code for that, by the way, is not very different and in my opinion just as clear:
def fire_if_possible(self, fleets):
if self._can_fire and self._missile_tally < u.MISSILE_LIMIT:
fleets.append(self.create_missile())
self._can_fire = False
I want to explore here why I’m not going to implement Hill’s idea, despite that the idea has some appeal. It would certainly be more efficient than what the code does not, since firing is rare and we count the missiles every 60th of a second now. It would be at least as clear, and possibly more clear. So why not?
First of all, this code ties the Ship class more tightly to the Missile class. It’s true that ships sort of know about missiles, they implement interact_with_missile
, but if we passed in another ship or a stone or a banana, the code would still work as long as those objects understood are_we_colliding
.
This is a weak objection, however, because Ship already knows the Missile class, because it can create missiles. But this is a much stronger connection. We have required the Missile class to implement a feature on behalf of Ship, which ship can implement itself using the facilities we already have. This new capability would be more efficient, but it’s not necessary.
Another objection is that to implement the feature, we have to very carefully create and destroy Missiles, so that we are guaranteed that if a missile creation ticks the counter up, the demise of that missile is guaranteed to tick the same counter down. We have created temporal coupling in the Missile class, tying the future to the past in a way that it wasn’t tied before.
Certainly we know how to do this and could almost certainly get it right. But I can’t begin to count the number of times in my life that I’ve opened a file and forgotten to close it and I have come to avoid this kind of open-close behavior when I can.
Yet another objection is that if we do this, we should surely do it for all the objects that are — or might be — counted. The Ship should have a class variable containing the Ship or None, as should the Saucer. Do we need it for Score? Perhaps not. In any case, we should, if we do this, try to eliminate the other way of doing it, using begin-interact-end
to count things.
Now that could be a good thing. We could eliminate end
right now, as no one uses it. If we could eliminate begin
, the whole scheme would be simpler. It’s tempting, and yesterday I thought I’d try it today.
Today, no.
The begin-interact-end
cycle is amazingly general. Every Single Object will pass under the eyes of every other object, and they can be counted, noted, collided with, sent messages saying “missed me”, whatever we want to do. Whatever we might ever want to do. And it’s already there.
To put in this change, we’d add complexity, class variables and a new linkage from one class to another. We’d want to replicate it in other classes that do similar things. We’d want to change the current begin-interact-end
cycle, because if we don’t, there’s no point to this idea. And, in the future, when we need some new kind of relationship between some pair of objects, we would have to implement the feature in those objects.
No. Not gonna do it. But I am going to keep it in mind, because the idea has some appeal. In a different situation, it might be the right thing. For me, with this situation, it doesn’t fit.
Not gonna do it. No code this morning. Very disappoint. Or not. I don’t want to write code that I know I don’t approve of.
And yet …
The current scheme is harder to understand than other schemes and it would be a good thing to make it more understandable. The more direct connection Hill suggested would be more obvious, I think. Although it’s hard to be more obvious than
self._missile_tally += missile.ship_tally
And one message is as good as another. It’s such a near thing. We’re not trying to make these object stupidly unaware of each other: after all, a ship fires missiles and is destroyed it if hits them, a ShipMaker makes Ships, and so on.
Reducing the number of methods in the full cycle would be good. Update, begin, interact, end, tick, draw … seems like we could really make do with fewer, though they would be more generic and probably less meaningful.
And yet. We have this design because I like it. Somewhere I had this intuition that “everything” could be pushed down into the individual flyers, and it worked out in a very interesting way, albeit an interesting way that is difficult to grok in fullness.
“Because I like it”, and “because it’s interesting” are not the best design criteria to use in our real work. Here, where we are pushing code around for the learning that comes from pushing code around, we have that luxury.
In real life, we could ship this program. We could have shipped it many articles ago. It has been working for a long time.
And there are features needed. Small saucer. Free ships for a high enough score (A score that apparently I’ll never reach: I am truly awful at playing this game. I’ll have to implement a cheat code to test free ships! (I just played a full four-ship game with a score of 190!))
I think next time we’ll turn back to our few remaining features, probably the small saucer. I’ve mushed the design over where it shouldn’t be, and back, and I’ve resisted the temptation provided by GeePaw.
That’s enough for this morning. Next time, back to code. Unless I pick a new topic entirely.