Python Asteroids+Invaders on GitHub

I would really like to move this game along a bit. But there’s a matter of scale that needs attention: the reversal points. And that fills the morning.

According to Computer Archaeology the Space Invaders game was played on a raster display that was 256 by 224 pixels, rotated. I take that to mean that the screen as viewed was 224 wide by 256 high. The game code is not as well commented as Asteroids, and it’s not easy to follow without learning more than I care to about whatever chip’s assembly language this is.

Let’s do some arithmetic. If the screen is 224 bits wide and invaders are 16 bits wide, times 11 invaders is 176 bits taken up with invaders, leaving 224-176 or 48 pixels of free movement for the rack. The invaders move two pixels per cycle, so they get 24 steps across the screen.

Now let’s translate that into our game, which has a screen that is indexed at 1024 by 1024. (It’s really some other size because I have a high-resolution display and Pygame does some magic to decide what I really meant when I said 1024.) Our scale should be 4X the original

So … half of 224 is 112, times 4 is 448, so I’d like the Invaders screen to be bounded at mid-screen plus or minus 448, or 64 and 960. Let’s draw some lines there to see how that looks. I propose that we put the bumpers at those locations and have them draw a line, at least while we’re developing.

coin.py
def invaders(fleets):
    fleets.clear()
    fleets.append(Bumper(64, -1))
    fleets.append(Bumper(960, +1))
    fleets.append(InvaderFleet())

class Bumper(Flyer):
    def __init__(self, x, incoming_direction):
        self.x = x
        self.rect = Rect(x, 0, x+1, u.SCREEN_SIZE)
        self.incoming_direction = incoming_direction

    def draw(self, screen):
        pygame.draw.line(screen, "green", (self.x, 0), (self.x, 1023))

And we get this:

invaders with green lines marking edges

I notice while letting that run that on the right the invaders are almost centered on the line before they reverse, and on the left, they reverse while still about a square and a half away from the line. Curious indeed. Something about the collision logic, possibly having to do with the centers of the rectangles?

I want more information. I print the two rectangles here:

class Invader:
    def interact_with_bumper(self, bumper, invader_fleet):
        if bumper.rect.colliderect(self.rect):
            print(bumper.rect, self.rect)
            invader_fleet.at_edge(bumper.incoming_direction)

On the right and left, I get values like this, for bumper and invader:

<rect(960, 0, 961, 1024)> <rect(929, 496, 32, 32)>
<rect(64, 0, 65, 1024)> <rect(124, 528, 32, 32)>

These don’t even look the same. The bumpers seem to be ohh … rectangles are defined by (x, y, w, h) not (x1, y1, x2, y2). Fix Bumper:

class Bumper(Flyer):
    def __init__(self, x, incoming_direction):
        self.x = x
        adj = 0 if incoming_direction < 0 else -1
        self.rect = Rect(x + adj, 0, 1, u.SCREEN_SIZE)
        self.incoming_direction = incoming_direction

I added the adj temp, so that the both rectangles are one pixel wide toward center. That makes the collisions symmetric. Maybe.

NOTE
This hackery was speculative, and unneeded. It may have contributed to my deciding that I wanted to do this differently, as you’ll see below.

There is another issue. Invaders init like this:

class Invader:
    def __init__(self, row, column):
        self.row = row
        self.column = column
        self.rect = pygame.Rect(0, 0, 32, 32)

Invaders are twice as wide as they are high. In the original game, they are 16x8, so in our game they will be four times that, 64x32. Let’s fix that up. And I am concerned about center vs corner as well. We’ll see.

After messing around with the rectangle intersect logic, I realize that I want to go another way, actually just comparing the suitable x values of the bumper and invader. I write these tests:

    def test_bumper_intersecting_left(self):
        bumper = Bumper(64, -1)
        rect = Rect(64, 512, 64, 32)
        assert bumper.intersecting(rect)
        rect = Rect(65, 512, 64, 32)
        assert not bumper.intersecting(rect)

    def test_bumper_intersecting_right(self):
        bumper = Bumper(960, +1)
        rect = Rect(960-64, 512, 64, 32)
        assert bumper.intersecting(rect)
        rect = Rect(959-64, 512, 64, 32)
        assert not bumper.intersecting(rect)

And I have the invader ask the bumper if they are intersecting, passing in its rectangle:

class Invader:
    def interact_with_bumper(self, bumper, invader_fleet):
        if bumper.intersecting(self.rect):
            invader_fleet.at_edge(bumper.incoming_direction)

As I write this now, I realize that I am not absolutely certain what the right side x will be of my invader. We’ll have to look into that in more detail. I think I have an off-by-one in the offing. By one. Anyway in Bumper:

class Bumper(Flyer):
    def intersecting(self, rect: Rect):
        if self.incoming_direction > 0:
            return rect.bottomright[0] >= self.x
        else:
            return rect.bottomleft[0] <= self.x

Those two tests are passing. Another is failing and has been for most of the morning:

    def test_bumper_invader_collision(self):
        fleet = InvaderFleet()
        bumper_x = 16
        bumper = Bumper(bumper_x, 1)
        invader_column = 5
        invader = Invader(invader_column, 2)
        step = 64
        invader.move_relative(fleet.origin)
        invader.interact_with_bumper(bumper, fleet)
        assert not fleet.reverse
        start_x = bumper_x - invader_column*step
        start = Vector2(start_x, 512)
        invader.move_relative(start)
        invader.interact_with_bumper(bumper, fleet)
        assert fleet.reverse

This test does not spark joy. Thank it for its service and remove it. Now I want to be sure that I understand the rectangle rules. The result surprises me:

    def test_rectangle_right(self):
        rect = Rect(0, 0, 64, 32)
        assert rect.bottomright == (64, 32)

I really expected the bottom right to be (63, 31). Let’s change this test for clarity:

    def test_rectangle_right(self):
        rect = Rect(100, 200, 64, 32)
        assert rect.bottomright == (164, 232)

Clarity? That’s harder to read. Yes, but now, given that you think about it, it’s clear which values are affecting which. We can do better. Let’s do.

    def test_rectangle_bottom_right_is_inclusive(self):
        left = 100
        top = 200
        width = 64
        height = 32
        rect = Rect(left, top, width, height)
        assert rect.bottomright == (left + width, top + height)

Better, but still surprising. I’m also told by the Pygame documents that the collision checking does not include the right edge or bottom edge. I guess that means not the 164 vertical line or 232 horizontal. It also means that I don’t trust rectangle collision even more than I used to not trust it a few lines back when I wrote my own “intersecting” code.

I have one more concern. In the original game, invaders stepped two pixels per step. At our 4X scale, that should be 8 pixels per step. The original cycle time was 60 per second, and our game is clocked at 60 also. But we’re scaling the step and the step sizes right now are strange. Let me change that to be more clear.

class InvaderFleet(Flyer):
    def __init__(self):
        self.invaders = [Invader(x%11, x//11) for x in range(55)]
        self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, 512)
        self.step = Vector2(8, 0)
        self.down_step = Vector2(0, 32)
        self.reverse = False
        self.next_invader = len(self.invaders)
        self.direction = 1
        # self.update(0, None)
        for invader in self.invaders:
            invader.move_relative(self.origin)

    def reverse_or_continue(self, delta_time):
        # we use +, not += because += modifies in place.
        if self.reverse:
            self.reverse = False
            self.direction = -self.direction
            self.origin = self.origin + self.direction * self.step + self.down_step
        else:
            self.origin = self.origin + self.direction * self.step
        self.next_invader = 0

The reverse_or_continue used to scale self.step by delta-time. Now we have a fixed step and rely on the clock. That should avoid any odd fractional steps or values in the rectangles. (I am sure that a fractional rectangle will draw positioned on an integer, but it might have an x value that was not an integer, which could interfere with our bumper checks. This way, that won’t happen.)

The invaders now go right to the edge lines and then reverse:

aliens go back and forth

We are green. I’d best check the diffs and see what I may not want to commit, but I want to get this thing committed: it’s better than it was. All the changes are righteous.

Commit: Modify bumper and invader to better manage collisions. Rescale motion for same purpose. Game better matches original game layout and scale.

I had hoped to get a bit more done this morning. I might come back later, as it is only 0822, but I’ve been at it for two hours and that’s a good amount of work and calls for a little rest and reflection.

One more little thing

In reviewing this before publishing, I realized that this code also does not spark joy:

    def intersecting(self, rect: Rect):
        if self.incoming_direction > 0:
            return rect.bottomright[0] >= self.x
        else:
            return rect.bottomleft[0] <= self.x

I don’t like checking direction all the time, and that leads me to consider first extracting two methods:

    def intersecting(self, rect: Rect):
        if self.incoming_direction > 0:
            return self.beyond_on_right(rect)
        else:
            return self.beyond_on_left(rect)

    def beyond_on_left(self, rect):
        return rect.bottomleft[0] <= self.x

    def beyond_on_right(self, rect):
        return rect.bottomright[0] >= self.x

Now let’s initialize a little something in __init__, and we get:

class Bumper(Flyer):
    def __init__(self, x, incoming_direction):
        self.x = x
        self.check = self.beyond_on_right if incoming_direction > 0 else self.beyond_on_left
        self.incoming_direction = incoming_direction

    def intersecting(self, rect: Rect):
        return self.check(rect)

    def beyond_on_left(self, rect):
        return rect.bottomleft[0] <= self.x

    def beyond_on_right(self, rect):
        return rect.bottomright[0] >= self.x

Now we don’t test the direction except at the beginning. If we were really brave, we might store that check value directly into interacting but that would constitute too much of a monkey patch for my taste. Even this might be a bit more than you would prefer, but I’ll allow it, at least for now.

Commit: modify how bumper checks intersecting to use pluggable method.

Let’s sum up.

Summary

My study last night focused me on the scale of the original game compared to ours. I’m not entirely happy with what we have, but we are better. The invader size and step are properly scaled. What would be better? It might be better to include the original scale values, and then multiply explicitly by our additional scale factor of 4. That would show where everything comes from.

I want to check the down step: it might be too large. And I’m not sure whether the original game steps straight down and then to the side, or down and to the side in one go. I might be able to suss that out.

I still believe that there are objects wishing to be born in InvaderFleet. Maybe we should look at that next time. But it would be nice to have a feature as well, even if it was just an alien bitmap.

We’ll find out next time. See you then!