Python Asteroids on GitHub

I think today I’ll put in the small saucer, because the game is already too hard for me.

It’s a good thing that my tests are as good as they are — although far from perfect — because when I need to test things by playing the game, the fact that I am a capital-L Loser at Asteroids makes it hard to test rare or high-point features. I’m a programmer, Jim, not a pinball wizard.

As I recall the small saucer spec, it starts to appear after the player has scored some arbitrary number of points, in the place of the large saucer. It is half the size of the larger one, and it scores 1000 points rather than 200. When it fires missiles, they are always targeted at the ship, while the large saucer only targets a quarter of the time.

Some of this logic is already in place. I’m sure that the scoring is, as it is encoded into u:

SAUCER_SCORE_LIST = [1000, 200]

I believe that if I were to set the Saucer size in __init__, we would always get the small one, and it would score 1000. I’ll try it. I’ll just change the default size in Saucer:

class Saucer(Flyer):
    def __init__(self, _position=None, size=1):
    	...

A test fails, but I’m just spiking here and want to see what happens in the game. Yes, that works. And since free ships come every thousand points, thanks to my poor game play, I get a free ship in the unlikely event that I shoot the saucer.

I don’t think that firing is handled: in fact I could tell that it wasn’t. Roll back, and let’s start doing this right. But first, what test is failing? It might be useful to know.

    def test_saucer_ship_missile_scores(self):
        pos = Vector2(100, 100)
        saucer = Saucer()
        self.interact_with_missile(pos, saucer, 200)

    @staticmethod
    def interact_with_missile(pos, saucer, expected_score):
        saucer.move_to(pos)
        missile = Missile.from_ship(pos, Vector2(0, 0))
        fleets = Fleets()
        fleets.append(saucer)
        fleets.append(missile)
        fi = FI(fleets)
        fleets.append(ScoreKeeper())
        interactor = Interactor(fleets)
        interactor.perform_interactions()
        interactor.perform_interactions()
        assert not fi.missiles
        assert not fi.saucers
        assert fi.score == expected_score
        return interactor

Not much of a test. We’ll beef it up, I think. Roll back.

The Saucer’s __init__ looks odd, did you notice?

    def __init__(self, _position=None, size=2):
        Saucer.direction = -Saucer.direction
        x = 0 if Saucer.direction > 0 else u.SCREEN_SIZE
        position = Vector2(x, random.randrange(0, u.SCREEN_SIZE))
        velocity = Saucer.direction * u.SAUCER_VELOCITY
        self._directions = (velocity.rotate(45), velocity, velocity, velocity.rotate(-45))
        self._gunner = Gunner()
        self._location = MovableLocation(position, velocity)
        self.missile_tally = 0
        self._radius = 10*size  # getting ready for small saucer
        self._ship = None
        self._size = size
        self._zig_timer = Timer(u.SAUCER_ZIG_TIME)
        self.create_surface_class_members()

It expects an optional argument _position, which it ignores. Vestiges of the past, I guess. Let’s change the signature on that first thing.

Tests run. Commit: remove unused position parameter.

Now let’s see what we’ll need to do to SaucerMaker to get it to make small saucers.

class SaucerMaker(Flyer):
    @staticmethod
    def create_saucer(fleets):
        fleets.append(Saucer())

Right. Comes down to that. We’d like to check the score here and if it’s less than some constant, say u.SAUCER_SCORE_FOR_SMALL, to keep it alphabetized with the other saucer ones, create a size 2 saucer, otherwise a size 1 one.

So we need to know the ScoreKeeper in SaucerMaker.

I think we should write a test. We have a whole file full of saucer tests, so we’ll put it there:

    def test_saucer_sizing(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(keeper := ScoreKeeper())
        fleets.append(maker := SaucerMaker())
        fleets.perform_interactions()
        keeper.score = 0
        fleets.tick(u.SAUCER_EMERGENCE_TIME)
        saucers = fi.saucers
        assert saucers
        saucer = saucers[0]
        assert saucer._size == 2
        fleets.remove(saucer)
        keeper.score = u.SAUCER_SCORE_FOR_SMALL
        fleets.tick(u.SAUCER_EMERGENCE_TIME)
        saucers = fi.saucers
        assert saucers
        saucer = saucers[0]
        assert saucer._size == 1

We set up the objects, set score to zero, tick, expecting a saucer of size 2. We get it. We then remove that saucer, set score to the small saucer value, tick again, again getting a saucer … but its size is not yet 1. Perfect failure:

Expected :1
Actual   :2

Now to fix SaucerMaker to get the scorekeeper and use it.

class SaucerMaker(Flyer):
    def __init__(self):
        self._timer = Timer(u.SAUCER_EMERGENCE_TIME)
        self._saucer_gone = True
        self._scorekeeper = None

    def interact_with_scorekeeper(self, keeper, fleets):
        self._scorekeeper = keeper

    def create_saucer(self, fleets):
        if self._scorekeeper and self._scorekeeper.score >= u.SAUCER_SCORE_FOR_SMALL:
            score = 1
        else:
            score = 2
        fleets.append(Saucer(score))

Test is green. I am satisfied that this works. I set the score constant to 500 so that I can test it in the game before setting it higher. I’m glad I did that, because I don’t get a small ship in the game after 500 points.

A couple of prints tell me that I am creating the saucer with score 1 or 2 appropriately. That should be called size of course:

Another print tells me that I’m getting to Saucer.__init__ with size 1.

When I stop calling in with the 1 and set the default to 1, the saucer is small. I have an issue in the display!

Oh I sure do.

    def create_surface_class_members(self):
        if not Saucer.saucer_surface:
            raw_dimensions = Vector2(10, 6)
            saucer_scale = 4 * self._size
            Saucer.offset = raw_dimensions * saucer_scale / 2
            saucer_size = raw_dimensions * saucer_scale
            Saucer.saucer_surface = SurfaceMaker.saucer_surface(saucer_size)

I only create the image first time through. Clever girl. The easy fix will be to remove the if. That will cost us a surface creation on each creation of a Saucer. Let’s make it work.

    def create_surface_class_members(self):
        raw_dimensions = Vector2(10, 6)
        saucer_scale = 4 * self._size
        Saucer.offset = raw_dimensions * saucer_scale / 2
        saucer_size = raw_dimensions * saucer_scale
        Saucer.saucer_surface = SurfaceMaker.saucer_surface(saucer_size)

That now works as expected. Change the score to something reasonable. Suppose we cleared the first wave, we’d score 4*20 + 8*50 + 16*100 or 2080 if I’m not mistaken. We can’t do that because the Saucer will surely kill some and we don’t get those points.

We have free ships every 1000 points. That should be 10,000 in the real game. And in the real game, the small saucer also starts at 10,000, when you get your first free ship. Let’s change those constants:

u.py

FREE_SHIP_SCORE = 10000
SAUCER_SCORE_FOR_SMALL = 10000

Now commit: Small saucer starts after 10,000 points. Free ships every 10,000 points.

Our feature is tested, working, and done. I am ever so tempted to put in a cheat code to change those numbers. But with all you people looking over my shoulder (all three of you) I just can’t. I may have to actually practice playing the game.

OK, no cheat code for now. Shall we reflect and sum up?

Reflection

The most significant issue this morning was the fact that the saucer didn’t turn small during game play, even though when I initialized it to small in a spike, it was small. I did notice while testing that even though the saucer appeared large, my shots seemed to go right through it sometimes, but I wasn’t concentrating on that so while I did see it, I didn’t really think about it.

A couple of quick prints showed me that I was caching the Saucer surface in a class variable, and it was the matter of a moment to stop caching. The surface is still being kept in that class variable and we might want to do something about that.

But the bug the defect was due to premature optimization. I had no reason to do that caching other than that it seemed wasteful to create the surface every time. And much much later, that optimization came back to surprise me and stab me in the back, et tu optimization?

That aside, everything went smoothly. It was nice that some of the prep work had been done already, with the sizing radius and so on. We could argue that that was premature as well. But we like the result in that case.

I doubt that there are tests for the radius and such, though there might be. I’m not going to look. My suspicion is that I coded to the future specification, as well as to whatever the story was when I originally did the saucer. That happens when we’re not strictly test-driven.

Freely granted:

I am not a “strictly” kind of person, so I do not ask it of others. Ideally, if we’re going to do TDD, we would never write a line of code that was not required by some test. It’s that word “ideally” there at the front. I don’t function ideally. Sometimes I barely function at all. Asking me to function ideally, you might as well ask me to fly, or to pass up ice cream after supper. You know, impossible stuff.

I try to work with a combination of thinking, TDD-style testing, careful thoughtful coding, coding as simply and clearly as I can manage, adding tests when I discover issues, reflecting on what I’ve done and then thinking about it. I use as many good practices as I can manage, so that, all together, they’ll tend to keep my program working as intended.

And I do fairly well. Not perfectly. If you want to read about perfectly, read someone else’s work. My writing is about what really happens, not a carefully edited final conclusion after however mistakes the author made behind the scenes.

I don’t know, maybe they never make mistakes. Somehow, though, I doubt that. I think they just don’t write about them. I’m not proud of my mistakes, but I am a bit proud that I write about them, because I’m sure there are one or two people out there who also make mistakes, and I want them to know that while mistakes aren’t exactly “OK”, they do happen, and that we can work in such a way as to minimize them.

The best way to minimize mistakes is not available to me on most days. That is to work with another person, pairing, or a group of people, mobbing or ensembling, if that’s a word, which it isn’t. But small steps, tests, and thinking, do help.

Anyway, a couple of tests helped this feature come to be, and the fact that those tests clearly worked helped me to realize I had a display problem, not a logic problem, more quickly than I might have.

Summary

Small saucer is done and I am quite confident in it. I freely grant that more tests would provide more confidence, and if you care to propose some, or write them, we’ll put them in. If you try the program, add them yourself and send me a pull request. And if you find an actual defect, you win. No prize, but you win.

What’s left? We have to make the small saucer target on every shot, which I would have done but forgot until just now. It’ll be a bit tricky, though, so it’s best left for a separate session.

We have a low-priority request for a star field.

And we have a growing priority on doing a new game, using this “framework”. I have a few notions, perhaps Space Invaders, boids, Braightenberg Vehicles. Perhaps I’ll go back to a dungeon thing, probably not on this framework. I think I’ll stick with Python a while, I rather like it.

I can’t wait to find out how to do full-time targeted missiles. See you next time!