Python Asteroids on GitHub

We think about our design, do a feature, recognize a potential concern. Not what I planned, but that always happens.

Features
We only have one size of saucer. The original game had two large and small. The smaller one is harder to hit, scores more points, and targets every shot at the ship.

The player should receive a free ship after every N points are scored. I think N is perhaps 10,000, but whatever N is, we need to honor it.

There is a low-priority suggestion that we should have a star field, a background of stars that moves along slowly.

Design
There are six “events” available during the game’s cycle, every 60th of a second: update, begin interactions, interact, end interactions, tick, and draw. Of these, we are not using the end one at all, and it seems that we could do just fine with three, if we could come up with decent names for them. We’ll discuss.

In a bit of over-exuberance, some developer who will go unnamed1 built an Explosion object that gets thrown into the mix, only to remove itself on the first cycle, adding in a number of Fragment objects. If we were just to add the Fragments immediately, we could remove a class from the Flyers subclasses, and probably simplify the fragment-making code somewhat. We have made similar changes to the Coin object with some resultant simplification.

Why should we improve the design?

Good question. The big reason in these articles is because it’s useful to practice changing code, since that is basically our profession. However, in a real world project, there are other reasons as well.

To the extent that what we have here is a framework that can be used for additional games, to follow on after Asteroids, we want that framework to be complete, so that it can build any game we might want to build … but we also want it to be as simple as possible, so that building games is as easy and rapid as possible. With six “events” where we may need only four, game makers must make decisions as to which events to use, and the answers are not clear. This will slow them down, and they’ll make mistakes.

The game we have right here, Asteroids, will, in any real situation, need changing, new versions, and so on. We would not want it to waste time or space if we can avoid it. (Not that we’ve actually gone for ultra-tight code here, but surely we want to avoid pure waste.) More importantly, the fewer Flyers we have, the easier it is to see how to implement any new features that we may need, because there’s less complication in our way.

Gimme Chocolate Features!2

Let’s do a feature first. I’m sure that our business hat3 will appreciate that.

Let’s do free ships every N thousand points. The story goes like this:

Every N thousand points, the player earns a new ship. As soon as points roll over the next N thousand, the game makes some kind of cha-ching sound and a new ship appears in the rack of ships up by the score.

Our design thinking goes like this:

We’ll need a constant in u for the free ship score value. We have a ScoreKeeper object that knows the score at all times. It can watch to see if the score has rolled over. It’s ShipMaker that knows how many ships there are, and ScoreKeeper already grabs access to the ScoreKeeper for purposes of drawing available ships:

    def draw(self, screen):
        score_surface, score_rect = self.render_score()
        screen.blit(score_surface, score_rect)
        self.draw_available_ships(screen)

    def draw_available_ships(self, screen):
        for i in range(0, self._ship_maker.ships_remaining):
            self.draw_available_ship(self.available_ship, i, screen)

Since we have access to ShipMaker, we can send it a message add_ship or something and have it tick the number of ships upward.

Hmm. It’s a somewhat odd decision having the ScoreKeeper drawing info that it gets from elsewhere, but doing it as we do it here keeps the drawing values all in one object. If ShipMaker were to draw the available ships, its drawing code would be coupled to ScoreKeeper’s. So this is probably about as good as it gets.

How will we test this?

We can set up a ScoreKeeper and ShipMaker in a Fleets. Run some fake Score instances through the ScoreKeeper, ask the ShipMaker for the ships remaining, and observe that it increases when it should.

That should suffice.

OK, let’s do it.

The Test

We have a module test_score.py, we’ll put our new test or tests in there.

Here’s my first cut:

    def test_free_ship_every_N_points(self):
        fleets = Fleets()
        keeper = ScoreKeeper()
        fleets.append(keeper)
        maker = ShipMaker()
        maker.ships_remaining = 0
        fleets.append(maker)
        free = u.FREE_SHIP_SCORE
        assert keeper.score == 0
        keeper.interact_with_score(Score(100), fleets)
        assert maker.ships_remaining == 0
        assert keeper.score == 100
        keeper.interact_with_score(Score(free), fleets)
        assert keeper.score == 100 + free
        assert maker.ships_remaining == 1

It’s a bit of a long story test. But I think that’s how it needs to be. We’ll try to think how it could be simpler but still safe, after we’re done. I’ve set u.FREE_SHIP_SCORE to 1000, and I think I might leave it that way to give myself a chance. The test doesn’t know the value.

Test fails:

Expected :1
Actual   :0

Perfect. Now how shall we do this? [^survey]: We should probably also survey it, scrutinize it, and inspect it, but time presses.

Let’s peruse ScoreKeeper and also look it over[^survey:

class ScoreKeeper(Flyer):
    def interact_with_score(self, score, fleets):
        self.score += score.score

A thought comes to mind, which is that rather than have ScoreKeeper reference the master constant directly, we could initialize it by passing the constant in. I don’t think that’s interesting enough to worry about it now.

But how are we going to notice repeated passing of the 1000 points, 2000 points, and so on.

What if we set a fence? We could init the fence to u.FREE_SHIP_SCORE, and when score exceeds the fence, add the ship, and add u.FREE_SHIP_SCORE to the fence. Yes, I like that.

class ScoreKeeper(Flyer):
    def __init__(self):
        self.score = 0
        self._fence = u.FREE_SHIP_SCORE
        self._ship_maker = NoShips()
        if pygame.get_init():
            self.score_font = pygame.font.SysFont("arial", 48)

    def interact_with_score(self, score, fleets):
        self.score += score.score
        if self.score >= self._fence:
            self._ship_maker.add_ship()
            self._fence += u.FREE_SHIP_SCORE

class ShipMaker(Flyer):
    def add_ship(self):
        self.ships_remaining += 1

The test does not run and I know why. In the test, the ScoreKeeper has never seen the ShipMaker and therefore does not know where it is. It has a dummy object that it uses when it has none and the dummy isn’t helpful. I add that interaction to the test:

    def test_free_ship_every_N_points(self):
        fleets = Fleets()
        keeper = ScoreKeeper()
        fleets.append(keeper)
        maker = ShipMaker()
        maker.ships_remaining = 0
        fleets.append(maker)
        free = u.FREE_SHIP_SCORE
        keeper.interact_with_shipmaker(maker, fleets)
        # ^ added
        assert keeper.score == 0
        keeper.interact_with_score(Score(100), fleets)
        assert maker.ships_remaining == 0
        assert keeper.score == 100
        keeper.interact_with_score(Score(free), fleets)
        assert keeper.score == 100 + free
        assert maker.ships_remaining == 1

The test is green. I need more tests and we need to talk.

Let’s have a test for passing the fence a second time, and for hitting the fence exactly. You may have noticed that I wrote >= in the code that checks the fence. The test did not require that: I did it because it was right, but made a mental note to do the test.

Lets write a new test, though we could extend the one we have. I’m kind of hoping writing one or two more will give me an idea for how to make this easier.

    def test_free_ship_moves_fence(self):
        fleets = Fleets()
        keeper = ScoreKeeper()
        fleets.append(keeper)
        maker = ShipMaker()
        maker.ships_remaining = 0
        fleets.append(maker)
        free = u.FREE_SHIP_SCORE
        keeper.interact_with_shipmaker(maker, fleets)
        assert keeper.score == 0
        keeper.interact_with_score(Score(100), fleets)
        assert maker.ships_remaining == 0
        keeper.interact_with_score(Score(free - 100), fleets)
        assert maker.ships_remaining == 1
        keeper.interact_with_score(Score(50), fleets)
        assert maker.ships_remaining == 1
        keeper.interact_with_score(Score(free - 50), fleets)
        assert maker.ships_remaining == 2

I put all those scores on the exact fence value, but I think we still want a test that says how it works.

    def test_free_ship_on_exact_score(self):
        fleets = Fleets()
        keeper = ScoreKeeper()
        fleets.append(keeper)
        maker = ShipMaker()
        maker.ships_remaining = 0
        fleets.append(maker)
        free = u.FREE_SHIP_SCORE
        keeper.interact_with_shipmaker(maker, fleets)
        assert keeper.score == 0
        assert maker.ships_remaining == 0
        keeper.interact_with_score(Score(free), fleets)
        assert maker.ships_remaining == 1

We are green. I want to test this in the game. I manage to earn a free ship after my last ship explodes, and one of my missiles scores a hit on an asteroid. I truly suck at this game.

But it’s good, except for the sound. It turns out that we have a sound extraShip.wav in our sounds! Yay, me! We can play it.

class ShipMaker(Flyer):
    def add_ship(self):
        self.ships_remaining += 1
        player.play("extra_ship")

It seemed to me that ShipMaker was the more logical place to play the sound. It is a very short beep. I suspect that in the real game it was played multiple times. We’ll let that ride for now.

The feature is tested and working. Commit: Free ship every 1000 points.

Let’s reflect, relax, and maybe pause until later.

Reflection

This went as smoothly as one could hope. I did make a few typos that I didn’t report here, including passing the score integer instead of score object to the keeper a couple of times. Quickly found, of course, because nothing would run.

The tests are long and repetitive. We can shorten the setup with Python’s “walrus” operator:

    def test_free_ship_on_exact_score(self):
        fleets = Fleets()
        fleets.append(keeper := ScoreKeeper())
        fleets.append(maker := ShipMaker())
        maker.ships_remaining = 0
        free = u.FREE_SHIP_SCORE
        keeper.interact_with_shipmaker(maker, fleets)
        assert keeper.score == 0
        assert maker.ships_remaining == 0
        keeper.interact_with_score(Score(free), fleets)
        assert maker.ships_remaining == 1

We could stop checking the score, since we test it elsewhere. I sort of like it, though, as it reminds us where we’re starting.

I don’t see a test form that seems better than this. Maybe a reader will offer an idea, but it seems to me that I need access to the keeper, the fleets, and the maker, and there’s kind of no way out. Well, wait, there is this:

    def test_free_ship_on_exact_score(self):
        fleets, maker, keeper = self.set_up_free_ship_test()
        free = u.FREE_SHIP_SCORE
        keeper.interact_with_shipmaker(maker, fleets)
        assert keeper.score == 0
        assert maker.ships_remaining == 0
        keeper.interact_with_score(Score(free), fleets)
        assert maker.ships_remaining == 1

    @staticmethod
    def set_up_free_ship_test():
        fleets = Fleets()
        fleets.append(keeper := ScoreKeeper())
        fleets.append(maker := ShipMaker())
        maker.ships_remaining = 0
        return fleets, maker, keeper

I can use that function in the other two tests as well. They’re all a bit shorter and once we get the idea of the setup we don’t have to read about it any more. Possibly better. Commit: refactor tests.

I think we’ll take a break here. We have enough words for you to read, and a nice new feature that might keep me in the game longer. It’s a near thing, though.

It has gone smoothly and there’s no question in my mind but that the tests helped, in at least two ways. Certainly they tell me that the ships are being added and the fence moves. But in addition, writing the tests made it more clear what functionality went where. I’m not sure why that happened, but I think that it just helped me parse out which object should do which, by helping separate them in my mind.

And I think I like that setup trick where a setup function creates all the objects the test needs in one go.

    fleets, maker, keeper = self.set_up_free_ship_test()

I should use that idea elsewhere: the tests for the game are often hard to read as they can be almost all setup.

One more thought.

In my exchanges with Rickard Lindberg, I have become more sensitive to the level from which I test. We could certainly arrange today’s tests to use fleets.perform_iteractions instead of calling interact_with_* as I did. That test would be a bit more vague about what’s going on, and it would be more safe in the sense that …

Ohhh … that gave me a twinge. I can think of a way that ScoreKeeper could conceivably fail.

ScoreKeeper starts out with a fake ShipMaker in its _shipmaker member:

class ScoreKeeper(Flyer):
    def __init__(self):
        self.score = 0
        self._fence = u.FREE_SHIP_SCORE
        self._ship_maker = NoShips()
        if pygame.get_init():
            self.score_font = pygame.font.SysFont("arial", 48)

That NoShips object returns zero ships remaining, and ignores add_ship, which should never be called on it but one never knows, do one? Well, in fact one does know: when we insert a slug to start in GAME OVER state, there is no ShipMaker, and there is a ScoreKeeper, and it displays no ships because it is using the NoShips.

But there is a possible timing problem, one that will never occur but it could. Suppose that there was a large Score object in the initial mix. Perhaps some programmer created a special starting key that provided a Score that was intended to provide a free ship, perhaps as a sort of handicapping feature for two-user play: I’ll spot you 1000 points, that’ll give you a free ship too.

If the keeper interacts with the maker first, it will then know the true maker and the and new ship would be added. But if it were to see the Score first, the score would accrue, but the message to add the ship would go to the NoShips, and be ignored.

Fascinating. A timing error. Would Rickard’s style find that error? I think not: it’s almost probabilistic, and won’t really occur in nature. But his form of testing did lead me to think about the case.

Suppose we cared about this problem. (And, in a general sense, we should, because we certainly don’t want any timing bugs: they are hard to duplicate and hard to figure out.) If we cared about this case, we could accumulate the incoming score in a temporary member and actually do the accrual in end_interactions or tick. So we could fix it if we had to.

Is it a more general issue with this design? I’d have to say that it is: because of the nature of the interactions, we cannot rely, in a single cycle, on knowing any particular fact before the end_interactions. Generally we act that way, by doing our checks in tick, after all the dealin’s done4. But it is at least potentially an issue.

Fascinating. Interesting. I must reflect on this. Unintended anomalous behavior could result from careless implementation of these objects, due to variability in the order of interactions. This is a black X mark against the decentralized model. Not a very serious one, but a mark nonetheless.

I love surprises. Sort of.

See you next time!



  1. CoughRonCough. 

  2. BABYMETAL, private communication. 

  3. I do actually have a hat that might count as a business hat, but I was kind of pressured into buying it some years back and I don’t like it and never wear it. So take this metaphorically or something. 

  4. Vague reference to a Kenny Rogers song, not his worst song either.