Python Asteroids on GitHub

I’ve been perusing the ancient scrolls, i.e. the Github repo of the original asteroids code. Some notes on what I think I understand so far, then some code. Repeat. Success!

It is 1600 hours on Monday. I begin with a quick review of the well-commented assembly code provided on GitHub by Nicholas Mikstas, whose home page can be found here.

I think I learned:

  • Frame time in the original game was 62.5 fps. Close enough to our 60.

  • The game triggers “thump” sounds, which I am supposing are the same as beat1 and beat2 that I’ve downloaded. Suspense is added by causing the thumps to come close and closer together.

  • Fastest inter-thump time seems to be 8 frames. about 1/8 of a second.

  • At first the inter-thump time is 127 frames. (I see another place where it may be 30 frames, more reading needed.) Every so often (yet to be determined, possibly 63 frames, once every second) the inter-thump time is decremented. It seems to be decremented by just one frame at a time.

Let’s try a spike. Maybe a little thumper object that holds two methods and a timer. When the wait interval runs out, it calls whichever method was not just called. Every N times through, it decrements its wait interval.

thump = None

def beat1():
    global thump
    thump = 1

def beat2():
    global thump
    thump = 2

class Thumper:
    def __init__(self, b1, b2, interval, update_time):
        self.b1 = b1
        self.b2 = b2
        self.interval = interval
        self.update_time = update_time


class TestThumper:
    def test_thumper_create(self):
        thumper = Thumper(beat2, beat2, 127, 63)
        assert thumper

That passes. Now test the operation a bit.

    def test_thumper_thumps(self):
        thumper = Thumper(beat2, beat2, 127, 63)
        assert not thump
        thumper.tick(128)
        assert thump == 1
        thumper.tick(128)
        assert thump == 2

I see that I’m not clear on when the interval gets ticked but this test won’t care.

Once I get the tests right things go better. I typed beat2 twice.


class TestThumper:
    def test_thumper_create(self):
        thumper = Thumper(beat1, beat2, 127, 63)
        assert thumper

    def test_thumper_thumps(self):
        thumper = Thumper(beat1, beat2, 127, 63)
        assert not thump
        thumper.tick(128)
        assert thump == 1
        thumper.tick(128)
        assert thump == 2

And:

class Thumper:
    def __init__(self, b1, b2, interval, update_time):
        self.b1 = b1
        self.b2 = b2
        self.interval = interval
        self.update_time = update_time
        self.time = 0

    def tick(self, delta_time):
        self.time += delta_time
        if self.time >= self.interval:
            self.time = 0
            self.b1()
            self.b1, self.b2 = self.b2, self.b1

Green. Let’s check the flip back just to be sure.

    def test_thumper_thumps(self):
        thumper = Thumper(beat1, beat2, 127, 63)
        assert not thump
        thumper.tick(128)
        assert thump == 1
        thumper.tick(128)
        assert thump == 2
        thumper.tick(128)
        assert thump == 1

Still green. Now the decrement of the interval. How often is that supposed to happen? I think it’s every 63 frames. Let’s check that.

    def test_thumper_decrement_interval(self):
        thumper = Thumper(beat1, beat2, 127, 63)
        thumper.tick(63)
        assert thumper.interval == 126
        thumper.tick(63)
        assert thumper.interval == 125

To make that go, I rush a bit and do this:

class Thumper:
    def __init__(self, b1, b2, interval, update_time):
        self.b1 = b1
        self.b2 = b2
        self.interval = interval
        self.update_time = update_time
        self.execute_time = 0
        self.decrement_time = 0

    def tick(self, delta_time):
        self.execute_time += delta_time
        if self.execute_time >= self.interval:
            self.execute_time = 0
            self.b1()
            self.b1, self.b2 = self.b2, self.b1
        self.decrement_time += delta_time
        if self.decrement_time >= self.update_time:
            self.decrement_time = 0
            self.interval -= 1
            if self.interval < 8:
                self.interval = 8

We are green. But the class is in the test file, so let’s move it to its proper home.

Some fumbling ensues …

Oh, darn, the times I used were in 60ths and my delta_time is in seconds. Need more study and a brain reset. It’s kind of working, but rather than ball-peen it into working, I’ll start over tomorrow. Enough for today.

Tuesday 0700

I started this article at 1600 hours on Monday. Worked a bit, got a thumper working, and tried it in the game, which told me that I had been thinking wrongly, so I broke for some R&R. That included a bit more study of the well-commented assembly code provided on GitHub by Nicholas Mikstas. Very good stuff.

What I learned, or gleaned, or think I got from this deeper review of the source code includes:

  1. The original thumper thumps at a frequency starting at once every 30 frames, down to once every 8 frames;
  2. Ticking down every 127 frames;
  3. Resetting if there are no asteroids;
  4. Resetting if the ship has exploded;
  5. At 62.5 frames per second.

Part of my confusion yesterday, was that when I coded the tests for the Thumper, I was thinking in frames, and our tick operation runs in seconds.

And, in part because I didn’t have the exact figures in mind, in part because I was thinking in frames, in part because I always do it, I made the Thumper class too general, by providing its timing values as parameters.

Now, in general, we could imagine that there might be lots of thumpers all thumping away at different rhythms, but the fact is, and it is well known, we are only ever going to have one. This still calls for an object, in my opinion, to isolate the behavior, but it does not call for an object that is all parameterized. Let’s recast the tests.

As written yesterday, they look like this:

thump = None

def beat1():
    global thump
    thump = 1

def beat2():
    global thump
    thump = 2

class TestThumper:
    def test_thumper_create(self):
        thumper = Thumper(beat1, beat2, 127, 63)
        assert thumper

    def test_thumper_thumps(self):
        thumper = Thumper(beat1, beat2, 127, 63)
        assert not thump
        thumper.tick(128)
        assert thump == 1
        thumper.tick(128)
        assert thump == 2
        thumper.tick(128)
        assert thump == 1

    def test_thumper_decrement_interval(self):
        thumper = Thumper(beat1, beat2, 127, 63)
        thumper.tick(63)
        assert thumper.interval == 126
        thumper.tick(63)
        assert thumper.interval == 125

And the Thumper looks like this, as debugged yesterday afternoon:

class Thumper:
    def __init__(self, b1, b2, interval, update_time):
        self.b1 = b1
        self.b2 = b2
        self.interval = interval
        self.update_time = update_time
        self.execute_time = 0
        self.decrement_time = 0

    def tick(self, delta_time):
        self.execute_time += delta_time
        if self.execute_time >= self.interval:
            # print("calling b1")
            self.execute_time = 0
            self.b1()
            self.b1, self.b2 = self.b2, self.b1
        self.decrement_time += delta_time
        if self.decrement_time >= self.update_time:
            self.decrement_time = 0
            self.interval -= 1/60
            if self.interval < 8/60:
                self.interval = 8/60

You can see there at the end where I started to accommodate the brain-switch between frames and seconds. I decided to use 1/60th as my frame time, since it is my frame time.

Let’s recast the tests. I plan to provide the two beat functions, but no other parameters to the creation. I will, however, check them in the tests, mostly as documentation.

    def test_thumper_create(self):
        thumper = Thumper(beat1, beat2)
        assert thumper
        assert thumper._long_interval == 30/60
        assert thumper._short_interval == 8/60

And

class Thumper:
    def __init__(self, b1, b2):
        self._long_interval = 30/60
        self._short_interval = 8/60
        self.b1 = b1
        self.b2 = b2

I’ve left the tick method alone. The test above is running, the others fail. Change the next one.

    def test_thumper_thumps(self):
        """thumper ticks between 30 and 8 60ths"""
        thumper = Thumper(beat1, beat2)
        assert not thump
        thumper.tick(31/60)
        assert thump == 1
        thumper.tick(31/60)
        assert thump == 2
        thumper.tick(31/60)
        assert thump == 1

Make that run:

class Thumper:
    def __init__(self, b1, b2):
        self._long_interval = 30/60
        self._short_interval = 8/60
        self._interval = self._long_interval
        self._execute_time = 0
        self.b1 = b1
        self.b2 = b2

    def tick(self, delta_time):
        self._execute_time += delta_time
        if self._execute_time >= self._interval:
            self._execute_time = 0
            self.b1()
            self.b1, self.b2 = self.b2, self.b1

The second test runs green. Now the third:

    def test_thumper_decrement_interval(self):
        thumper = Thumper(beat1, beat2)
        thumper.tick(128/60)
        assert thumper._interval == 30/60 - 1/60
        thumper.tick(128/60)
        assert thumper._interval == 30/60 - 1/60 - 1/60
class Thumper:
    def __init__(self, b1, b2):
        self._long_interval = 30/60
        self._short_interval = 8/60
        self._interval = self._long_interval
        self._decrement_interval = 127/60
        self._decrement_time = 0
        self._execute_time = 0
        self.b1 = b1
        self.b2 = b2

    def tick(self, delta_time):
        self._execute_time += delta_time
        if self._execute_time >= self._interval:
            self._execute_time = 0
            self.b1()
            self.b1, self.b2 = self.b2, self.b1
        self._decrement_time += delta_time
        if self._decrement_time >= self._decrement_interval:
            self._decrement_time = 0
            self._interval = self._interval - 1/60
            if self._interval < 8/60:
                self._interval = 8/60

I put in the minimum limit code, got ahead of myself. Let’s put in a couple of test lines for that:

    def test_thumper_countdown(self):
        thumper = Thumper(beat1, beat2)
        thumper._interval = 9/60
        thumper.tick(128/60)
        assert thumper._interval == 9/60 - 1/60
        thumper.tick(128/60)
        assert thumper._interval == 8/60
        thumper.tick(128/60)
        assert thumper._interval == 8/60

That’s green. We can commit the tests and Thumper: Thumper class rebuilt to know its limits of operation.

I roll back game, where I had pasted Thumper in, and think about how it really needs to work. I immediately realize that it needs a reset operation.

Better write a test for that.

    def test_thumper_reset(self):
        thumper = Thumper(beat1, beat2)
        thumper._interval = 8/60
        thumper._execute_time = 2/60
        thumper._decrement_time = 2/60
        thumper.reset()
        assert thumper._interval == 30/60
        assert thumper._execute_time == 0
        assert thumper._decrement_time == 0

And implement:

class Thumper:
    def __init__(self, b1, b2):
        self._long_interval = 30/60
        self._short_interval = 8/60
        self._decrement_interval = 127/60
        self.b1 = b1
        self.b2 = b2
        self.reset()

    # noinspection PyAttributeOutsideInit
    def reset(self):
        self._interval = self._long_interval
        self._decrement_time = 0
        self._execute_time = 0

Green. Commit: add reset to Thumper.

Now, as I was saying, the thumper needs to reset if there are no asteroids or if the ship is gone. And it ticks all the time. It seems that the right place for this logic is in Fleets, which can know the answers to those questions.

I am a bit concerned about the fact that to use thumper, we need to be sure that sound is initialized. I think we’ll leave that up to the beat functions we provide.

Let’s give Fleets a thumper and start from there. I don’t think I want or need to TDD this. We’ll find out how wrong I am.

class Fleets:
    def __init__(self, asteroids=None, missiles=None, saucers=None, saucer_missiles=None, ships=None):
        asteroids = asteroids if asteroids is not None else []
        missiles = missiles if missiles is not None else []
        saucers = saucers if saucers is not None else []
        saucer_missiles = saucer_missiles if saucer_missiles is not None else []
        ships = ships if ships is not None else []
        self.fleets = (
            AsteroidFleet(asteroids),
            MissileFleet(missiles, u.MISSILE_LIMIT),
            SaucerFleet(saucers),
            MissileFleet(saucer_missiles, u.SAUCER_MISSILE_LIMIT),
            ShipFleet(ships),
            ExplosionFleet())
        self.thumper = Thumper(self.beat1, self.beat2)

    def beat1(self):
        player.play("beat1")

    def beat2(self):
        player.play("beat2")

    def tick(self, delta_time):
        self.thumper.tick(delta_time)
        for fleet in self.fleets:
            fleet.tick(delta_time, self)

That, of course, plays the beat all the time. And it does get faster. But we need to tick only when there are asteroids and a ship, and to reset the Thumper otherwise:

    def tick(self, delta_time):
        if self.asteroids and self.ships:
            self.thumper.tick(delta_time)
        else:
            self.thumper.reset()
        for fleet in self.fleets:
            fleet.tick(delta_time, self)

This works perfectly, with one tiny issue. Depending which tone was last played before a reset, the Thumper restarts with beat1 or beat2, rather than always starting with beat1. We’d need a fair bit more code to do better than the swap thing we do now. A couple more members, or a different way of swapping. I like what we have. I think we’ll let that be for now and see if anyone cares.

Commit: game now plays increasingly thrilling beat when asteroids and ship are on screen.

Summary

Yesterday afternoon, I started with what I called a spike, but I actually test-drove an initial Thumper class. It worked as tested, but the focus of my attention in writing the tests was on frame count, not time, so it didn’t fit well with this program’s design. I did get it working well enough to see that I needed to understand the timing better, and a new focus.

The amazing thing is that rather than bull forward and force the dad-blamed thing to work, I actually stopped for a break and to review the documentation. Good for me!

This morning, I recast the tests, rewrote the object, and plugged it into the game, where it works perfectly. (Well, if you assume that it’s OK to start on either the up beat or the down beat. And I, at least, do assume that.)

I am pleased, and call this morning a success, and it’s only 0807. Surely the beginning of an excellent day.

See you next time!