Python Asteroids on GitHub

Is this program as perfect as I can make it? Is there anything left to do? When your best idea is spinning asteroids …

This tiny Asteroids clone has provided innumerable examples of the typical issues we find in our code, and has given me many chances to show how we might resolve those issues. With the latest little object, the Painter, we’ve separated drawing the objects almost entirely from the logic of the objects. I do say “almost”.

There is a linkage between the logical size of an object, its radius, and the size of the drawing. I’m not sure that that’s inevitable. Certainly we could represent an asteroid by a dot or a huge square and the logic would still run … but the picture wouldn’t look right. We would see a missile skim far from the dot, but the got would explode anyway. Or we’d see the missile clearly hit the square and yet the square would not explode. So the picture and the logic have to be linked somehow.

There is also the issue of the size of the playing field. It is logically 1024x1024, but, due to Pygame oddities, draws itself in 2048x2048 pixels on my huge retina-scale monitor. I have not been able to sort out why it does that, and I freely confess that I haven’t tried very hard, because I just don’t care.

Finally, there are the explosion fragments, which draw themselves without using Painter, so that aspect of drawing is not as independent as we might wish. It’s hard to care about that, too, but if we were moving to a new game engine, we might need to address the issue. The sort of thing we’d do would be to isolate all drawing into a single class like Painter, or as few classes as we could manage, so that we could then plug in new graphics more readily.

Game Engines

Speaking of game engines, I just finished Doom Guy, by John Romero, one of the original creators of Doom and many other games. It is an autobiography of Romero, and a biography of id and many other companies he was associated with. I enjoyed the book and if you’ve ever enjoyed Doom or Quake or any of those, you might find it interesting as well. The book clearly shows Romero’s love for gaming, programming, and the many people who have been a part of his life.

(It was John Carmack, Romero’s long-time collaborator and occasional nemesis who actually wrote the engines. Just so you’ll know that I know that, as you probably did.)

What other things might there be that make this little Asteroids game imperfect, from which we might learn some lessons? We’ll take a look. But …

Speaking of Lessons
What are some things that this long-running exercise has taught me? Most of these are lessons that I have learned again and again. I am a perfect human being and programmer, and I can always use a little improvement1, oddly enough often the same improvements over and over.
Testing
In writing this game, I have created far more tests than in any other [video] game that I can recall. There are 148 tests running now, comprising 21 files and a tools file. Some of those tests just check things about Python that I wanted to be sure about, including a number of tests verifying my understanding of aliasing. Most of the interesting classes in the system have at least a few sanity tests,

There are tests that verify that objects are set up correctly, for example checking that all the subclasses of Flyer implement certain methods. Some of that is also checked by PyCharm, but I wanted to learn how to check things like that, and the tests have caught me at least once.

There are some fairly messy tests. The design of this program involves objects interacting “at arm’s length”, and so there are a lot of tests that set up a situation in space, run some interactions, and then inspect space to see that the right things have happened. I found those tests difficult to write, and difficult to read, and valuable in that they frequently demonstrated that something wasn’t working.

Overall, I am very satisfied with the tests and glad that I have done them. They’ve caught a lot of problems, although not all problems. Were I to do another game like this one, I would try to do more testing, and try to make the tests easier to write and to understand.

Test-Driven Development
My implementation has always been incremental, but not every object was started with TDD. The objects where I did start with TDD are generally better-tested, and their tests are easier to understand. This is not a surprise: it almost always happens. Were it not for the True Scotsmen2 among us, I would say that TDD done right always makes that happen.

I have learned, yet again for the <mumble>th time, that TDD makes my work easier, more reliable, and faster.

Small Objects
I like the designs I get with small objects. In gaming, there are some legitimate concerns over the use of many separate objects, mostly having to do with memory caching in the hardware. Separate objects tend to be far apart in memory and can cause slower access to unchached memory, and can cause the CPU’s look-ahead to fail and have to reload the pipeline, slowing things down.

These are legitimate concerns at some points in a high-performance video game, and not everywhere even there. Your game engine probably wants to be written in C. If your game logic is written in Lua, it hardly matters.

Be that as it may, performance is not a concern in this program, and I much prefer the clarity and ease of change that I get with small independent objects. My habits, however, do not always produce them.

Ive been programming for over six decades, and code flows from my mind to my fingertips pretty rapidly sometimes, and that code usually looks rather procedural. So when I get rolling, I tend to roll out procedural code. I compensate for that by reviewing my work often, and by refactoring to a better design all the time. This has the possibly undesirable side effect of providing meat for 194 Python articles and counting, but it does produce code that I prefer, almost every time.

MMMSS
Many More Much Smaller Steps, as GeePaw Hill puts it. I’ve been trying to minimize the time between a commit, some changes, and the next commit. This works particularly well when test-driving, but works nicely even when one is just refactoring or even coding without a net, as ill-advised as that might be.

When I work in my MMMSS style—which may or may not measure up to Hill’s standards—I seem to make fewer mistakes, and when I do make mistakes they show up right away. I move from working program, through small change, to working program very quickly. The trick, of course, is “how do you know the program is working?”. This really works best when we have solid tests around whatever we’re working on. If we have to check by running the whole program, it slows us down, which makes us want to write more code in between runs, which results in a defect being inserted somewhere and the next thing you know we are wishing we knew how to run the debugger.

I pride myself on never learning debuggers because I go in small enough steps that when something breaks the defect is obvious, since I only typed 11 characters since the program worked. I do, however, now how to run the PyCharm debugger a little bit, and I feel rather ashamed to admit it.

Hill’s MMMSS articles and chats have focused me more on the small step approach and I find it to be quite good. It keeps more of my ideas in the code instead of in my head, and my mistakes are more often small and easy to see.

There is a downside. My practice here is that when I’m ready to commit a change, I push it to GitHub unconditionally. That has resulted in a few real defects reaching GitHub, which is embarrassing even if it’s only for a few minutes. At least one was there overnight! Awful!

If I were more adept with Git, and if I were willing to go without pushing for a longer period, I might be less likely to push a defect to GitHub. But it feels very wrong to me to do anything that keeps my code out of the repo. Down that path lie dangerous traps, merge conflicts, and boring integration work. I learned long ago on the C3 project that the best place for my work is in the HEAD of the official repo. The more frequently I get in there, the less painful integration I have to do.

But pushing a defect is very bad, and on your project you may be tempted to work on a branch for a long time, perhaps even an hour or a day. Surely no longer than that? My answer is “more tests”, not a longer delay. But I’m not perfect at it and it has bitten me perhaps five times in almost 200 articles. Ow! I hate being bitten.

Continuous Design Improvement
All this adds up to allow me to continuously improve the design, as I add new features and at any other time that a design improvement comes to mind. Instead of getting harder to change, the code becomes easier to change, because a good design is easier to change than a, um less good one.
Geek Joy
Frequent readers know that I do not like the word “geek”, as I noted in my article Geek Joy, but I do love the work, and I think that comes from doing it rather well and from a sense that I can improve myself, improve the code, and generally make the program a better place to live and work.

If I gave advice, I would advise that developers consider some of these ideas as a source of their own “geek joy”. Fortunately for you, I do not give advice, so you’ll have to create your own.

But His Program?

What about this program? Are we done? Is it perfect? Does it still have room for improvement? Let’s at least look and see what we can find. Here are line counts for the game’s files:

(venv) Fresh-Air:firstGo ron$  wc -l *.py
      24 aimimprover.py
      95 asteroid.py
      41 coin.py
      37 explosion.py
      62 fleets.py
      74 flyer.py
     103 fragment.py
      62 game.py
      52 game_over.py
      32 gunner.py
      51 hyperspace_generator.py
      23 interactor.py
       8 main.py
      70 missile.py
      46 movable_location.py
      85 painter.py
     140 saucer.py
      47 saucermaker.py
      35 score.py
      99 scorekeeper.py
     194 ship.py
      72 shipmaker.py
      77 shipprovider.py
      79 shot_optimizer.py
      29 signal.py
      51 sounds.py
      82 thumper.py
      20 timer.py
       7 transponder.py
      33 u.py
      44 wavemaker.py
    1874 total

Less than 2000 lines, not bad. I suppose Asteroids could really be written in 37 lines or something but still. Obviously we’d look at the big files to see what’s up with them. Ship and Saucer are obvious candidates and they do have a lot of responsibilities, which might make them candidates for the Small Objects treatment. But there are others that I wonder about.

Thumper (82 lines) just makes that da-dum da-dum sound. That seems large. Scorekeeper at 99? Painter is mostly data, so that’s OK. Fragment (103) might be worth a look, and we know it uses pygame graphics, not our new Painter scheme.

A glance at Thumper makes me think it’s OK. I’ll include it here in case you want to disagree, but it seems pretty straightforward. It could perhaps use the Timer object but it’s pretty clear as it is.

class Thumper(Flyer):

    def __init__(self, first_action=None, second_action=None):
        self._longest_time_between_beats = 30 / 60
        self._shortest_time_between_beats = 8 / 60
        self._delay_before_shortening_beat_time = 127 / 60
        self._amount_to_shorten_beat_time = 1 / 60
        self._time_between_beats = 0
        self._time_since_last_beat = 0
        self._time_since_last_decrement = 0
        self.current_action = first_action if first_action else self.play_beat_1
        self.next_action = second_action if second_action else self.play_beat_2
        self._saw_ship = False
        self._saw_asteroids = False
        self.reset()

    @staticmethod
    def play_beat_1():
        player.play("beat1")

    @staticmethod
    def play_beat_2():
        player.play("beat2")

    def begin_interactions(self, fleets):
        self._saw_ship = False
        self._saw_asteroids = False

    def draw(self, screen):
        pass

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

    def interact_with_asteroid(self, asteroid, fleets):
        self._saw_asteroids = True

    def interact_with_missile(self, missile, fleets):
        pass

    def interact_with_saucer(self, saucer, fleets):
        pass

    def interact_with_ship(self, ship, fleets):
        self._saw_ship = True

    def reset(self):
        self._time_between_beats = self._longest_time_between_beats
        self._time_since_last_decrement = 0
        self._time_since_last_beat = 0

    def tick(self, delta_time, fleets):
        if self._saw_ship and self._saw_asteroids:
            if self.it_is_time_to_beat(delta_time):
                self.play_and_reset_beat()
            if self.it_is_time_to_speed_up_beats(delta_time):
                self.speed_up_beats()
        else:
            self.reset()

    def it_is_time_to_beat(self, delta_time):
        self._time_since_last_beat += delta_time
        return self._time_since_last_beat >= self._time_between_beats

    def play_and_reset_beat(self):
        self._time_since_last_beat = 0
        self.current_action()
        self.current_action, self.next_action = self.next_action, self.current_action

    def it_is_time_to_speed_up_beats(self, delta_time):
        self._time_since_last_decrement += delta_time
        return self._time_since_last_decrement >= self._delay_before_shortening_beat_time

    def speed_up_beats(self):
        self._time_since_last_decrement = 0
        self._time_between_beats = max(
            self._time_between_beats - self._amount_to_shorten_beat_time,
            self._shortest_time_between_beats)

ScoreKeeper? I suspect it’s just fine as well. Let’s have a look:


@dataclass
class NoShips:
    def ships_remaining(self, _ignored):
        return 0

    def add_ship(self):
        pass


class ScoreKeeper(Flyer):

    available_ship = Ship(Vector2(0, 0), 1)
    available_ship._angle = 90

    @classmethod
    def should_interact_with(cls):
        from score import Score
        return [Score]

    def __init__(self, player_number=0):
        self.score = 0
        self._fence = u.FREE_SHIP_SCORE
        self._player_number = player_number
        self._scoring = player_number == 0
        self._ship_maker: ShipMaker|NoShips = NoShips()
        if pygame.get_init():
            self.score_font = pygame.font.SysFont("arial", 48)

    @staticmethod
    def are_we_colliding(_position, _radius):
        return False

    def interact_with_asteroid(self, asteroid, fleets):
        pass

    def interact_with_missile(self, missile, fleets):
        pass

    def interact_with_saucer(self, saucer, fleets):
        pass

    def interact_with_ship(self, ship, fleets):
        pass

    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):
        count = self._ship_maker.ships_remaining(self._player_number)
        for i in range(0, count):
            self.draw_available_ship(self.available_ship, i, screen)

    def draw_available_ship(self, ship, i, screen):
        x_position = [55, u.SCREEN_SIZE - 55][self._player_number]
        position = i * Vector2(35, 0)
        if self._player_number == 1:
            position = - position
        ship.move_to(Vector2(x_position, 100) + position)
        ship.draw(screen)

    def render_score(self):
        x_position = [10, 875][self._player_number]
        score_text = f"0000{self.score}"[-5:]
        color = "green" if self._scoring else "gray50"
        score_surface = self.score_font.render(score_text, True, color)
        score_rect = score_surface.get_rect(topleft=(x_position, 10))
        return score_surface, score_rect

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

    def interact_with_shipmaker(self, shipmaker, fleets):
        self._ship_maker = shipmaker

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

    def interact_with_signal(self, signal, fleets):
        self._scoring = signal.signal == self._player_number

    def tick(self, delta_time, fleets):
        pass

We see the NoShips dataclass. Its name should probably be FakeShipMaker. Let’s rename it. Commit rename NoShips to FakeShipMaker.

We see that this guy is drawing the score directly, putting it on a surface and then blitting it. It then lets its private copy of the ship draw itself to make the string of available ships that show up under the scores.

The _scoring member, what is that? It is a flag that the ScoreKeepers use to decide whether to accumulate the score, as we see in interact_with_score. There can be two ScoreKeepers in a two-payer game, and the ShipProvider files a Signal saying which ScoreKeeper should listen to subsequent scores.

As we see in interact_with_signal, one of them turns on his flag, the other turns it off.

I think that could be more clear. What if we just saved the signal in a member _currently_scoring_player, and checked in interact_with_score for that number? Might be better. I’m not up for doing that now, I’m after bigger game.

The drawing and rendering are a bit ad hoc:

    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):
        count = self._ship_maker.ships_remaining(self._player_number)
        for i in range(0, count):
            self.draw_available_ship(self.available_ship, i, screen)

    def draw_available_ship(self, ship, i, screen):
        x_position = [55, u.SCREEN_SIZE - 55][self._player_number]
        position = i * Vector2(35, 0)
        if self._player_number == 1:
            position = - position
        ship.move_to(Vector2(x_position, 100) + position)
        ship.draw(screen)

    def render_score(self):
        x_position = [10, 875][self._player_number]
        score_text = f"0000{self.score}"[-5:]
        color = "green" if self._scoring else "gray50"
        score_surface = self.score_font.render(score_text, True, color)
        score_rect = score_surface.get_rect(topleft=(x_position, 10))
        return score_surface, score_rect

Lots of magic numbers in there. To do this correctly, we’d probably need font metrics and other weird things.

I would like to say right here in front of everyone that my practice for fitting things on the screen is very ad hoc. I might draw a picture to scale. I might try things and fiddle the numbers. I don’t ever do a boxes arranging themselves in a frame left right justify up down whirl around kind of thing. I bash them until I like them.

This would not be ideal in a world where I had to run on different screen hardware. I plead verisimilitude: the original game knew exactly what its display looked like, so it’s OK if I do as well. Weak excuse, yes, but it’s the only one I have.

The variable score_rect, down there at the bottom, is actually used as the dest (destination) parameter in blit. We might do better to call it that.

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

    def render_score(self):
        x_position = [10, 875][self._player_number]
        score_text = f"0000{self.score}"[-5:]
        color = "green" if self._scoring else "gray50"
        score_surface = self.score_font.render(score_text, True, color)
        score_destination = score_surface.get_rect(topleft=(x_position, 10))
        return score_surface, score_destination

This is better, but still reflects my poor understanding a bit. Commit: rename score_rect to score_destination.

All that really needs to be sent there is the top-left destination coordinate. Let me try something:

    def render_score(self):
        x_position = [10, 875][self._player_number]
        score_text = f"0000{self.score}"[-5:]
        color = "green" if self._scoring else "gray50"
        score_surface = self.score_font.render(score_text, True, color)
        score_destination = (x_position, 10)
        return score_surface, score_destination

This works perfectly. Better. Commit: return score destination as desired top left.

These are minor improvements. They’re the kind of opportunistic changes we make as we pass through code on our way to do something. They are aimed at reflecting our new and typically improved understanding of what’s going on. Something catches our eye and we make it better.

But what of larger game? What larger improvement might ScoreKeeper need?

Well, the class has a few responsibilities:

  1. Knows which player it scores for;
  2. Know where on screen to draw (score area);
  3. Fields Signal and either listens or ignores future Score instances;
  4. Accumulates Score;
  5. Capture ShipMaker instance;
  6. Ask ShipMaker for available ship count;
  7. Render ships to score area;
  8. Render numeric score to score area.

We might want to separate out the rendering and drawing into a separate object, perhaps ScorePainter. It could be primed, like Painter, with knowledge of the top left point at which to draw, and then its draw function would be called with a score and perhaps a ship count. Then ScoreKeeper wouldn’t need its ship object, because the ScorePainter would deal with that. ScorePainter would be screen-dependent in the same way that Painter is.

We would have to tell the ScorePainter whether we were active or not, I suppose, so it would know to draw in green or gray, or do whatever other fancy stuff was required on screen.

We could surely imagine that the part that paints numbers and the part that paints ships might be different objects, subordinate to ScorePainter. But they’re only a method or two even now, so ScorePainter might be cohesive enough without them.

The way we find out how many ships to draw is interesting: we notice ShipMaker as it goes by and we send it a message. We could instead create a new kind of Signal, that says “player 0/1 now has n ships”. We would field interact_with_ship_counter and save the value. That would be in the spirit of our decentralized design, and more in the spirit of “tell don’t ask”.

That might be interesting. Better? In a larger system, perhaps.

I think there is another case, somewhere, where an object snatches another one as it goes by and later sends it a message. I’d have to look for it. It might benefit from a similar treatment.

Pub / Sub

I’ve commented before that the interact_with_foo scheme can be seen as a sort of publish-subscribe scheme. You implement interact_with_signal if you want to hear what signals have to say. We use the scheme it a few different ways.

We detect collisions. An asteroid interacts with us: we check to see if it’s hitting us and die if so.

We count things. An asteroid goes by and we count it, using the count later to decide our odds of surviving hyperspace.

We save things and ask questions later. A ShipMaker goes by, we capture it and ask it later how many ships are available for our player.

I like the fact that I can do those three things (and more), but I’m not sure it’s in the proper spirit to hold on to an instance and talk to it later. That third usage could be handled instead by the captured object issuing other information-bearing objects, such as ship count objects as we mused about above.

This is a grand question, in two senses. First of all, it’s fun. But it’s also a Big Design Question, regarding what the proper protocol is for our decentralized scheme. There is certainly a risk if one object holds on to another one. The one held on to might be replaced by another. For example, if an object held onto a ship, when that ship was destroyed, the holding object would not know that, and if it sent any messages to that ship, it would be talking to a deader. Not good.

Interesting topic. We’ll give that some consideration. For now, we’ll sum up.

Summary

We’ve looked at some things we’ve learned (again), and we’ve recommended a book that you might or might not like. We’ve reviewed some code and made some small improvements. We’ve seen that some of our larger objects are pretty reasonable, and we’ve seen that probably no object is perfect: each of them could be improved, but perhaps not a lot.

We’ve seen that we still have drawing and game play mixed in at least one object, the ScoreKeeper, and thought about how we might separate out the drawing.

And we’ve identified an interesting question about which uses of interact_with_X are appropriate and which are not so OK.

And, at least in my mind, it makes me wonder whether a real publish-subscribe scheme would be better, or at least interesting, right here in Python Asteroids. Could we incrementally convert this program to a pub-sub style? If we did, would we learn something interesting.

Or … could it be time for something new? And if so, what would it be? I do not know, but I look forward to finding out.

See you next time!



  1. Shunryu Suzuki — “Each of you is perfect the way you are … and you can use a little improvement.” 

  2. And Scotswomen and Scotsones and Scotsfolx. I mean no exclusion, just a reference to the “No True Scotsman” objection.