Python Asteroids on GitHub

The Saucer is a saucy little minx. Let’s add some more sauce.

Well, anyway … here’s the selection of saucer story slices, somewhat sorted by spriority:

• Saucer is too small, should be 2x. [NEW]
• Saucer bottom appears truncated. Use larger surface. [NEW]
• Saucer zig-zags every 1.5 seconds, left, straight, straight, right, randomly;
• Saucer scores 200 in initial size, more when small. (Look it up.)
• Saucer fires a missile very so often (1 second? 1/2?)
• Saucer only has two missiles on screen at a time;
• Saucer fires randomly 3/4 of the time, targeted 1/4 of the time;
• Saucer will fire across border if it’s a better shot;
• Saucer missiles do (do not) kill asteroids;
• Saucer does (does not?) destroy asteroids when it hits them;
• Saucer missiles do not add to score;
• Saucer is smaller if score > 10000 or some value;
• Saucer start left to right, alternates right to left;
• Saucer appears every n (=7) seconds;

I’ve only put the 2x one at the top because it’s easy. I like to give the team something easy to start on. And I noticed that the bottom line of the saucer appears thinner than the others. I think we used an expanded surface on the asteroids, and we should do that for the saucer as well.

Let’s start on those two. Let’s have a short planning session.

Tentative Plan

The saucer has two sizes, so let’s create it at size 2 and in due time we’ll have a size 1. We’ll find all the places where we need to multiply things by the size. Nota Bene: I think there is a shared constant between SurfaceMaker and Saucer.draw. If I recall it is something like `Vector2(20, 12`, the size of the surface.

Let’s review the code:

``````class Saucer:
def __init__(self, position=None):
self.velocity = u.SAUCER_VELOCITY
self.direction = 1
self.position = position if position is not None else u.CENTER
self.score_list = [0, 0, 0]
base_size = Vector2(10, 6)
saucer_scale = 4
self.offset = base_size*saucer_scale/2
saucer_size = base_size*saucer_scale
self.saucer_surface = SurfaceMaker.saucer_surface(saucer_size)

class SurfaceMaker:
@staticmethod
def saucer_surface(saucer_size):
raw_points_span = Vector2(10, 6)
raw_points_offset = raw_points_span / 2
scale_factor = saucer_size.x / raw_points_span.x
saucer_surface = SurfaceMaker.create_scaled_surface(
saucer_size, raw_points_offset, scale_factor, raw_saucer_points)
return saucer_surface
``````

OK, it’s the `(10, 6)`, not `(20, 12)`. And I’d like to reserve the word “size” for the 1, 2, small, large notion.

Let’s see if we can find where we create an expanded surface: I think we do it in the asteroid creation:

``````    def asteroid_surface(actual_size):
raw_rock_points = SurfaceMaker.get_next_shape()
raw_points_span = 8
raw_points_offset = Vector2(4, 4)
scale = actual_size / raw_points_span
room_for_fat_line = 2
surface_size = actual_size + room_for_fat_line
surface = SurfaceMaker.create_scaled_surface((surface_size, surface_size),
raw_points_offset, scale, raw_rock_points)
return surface
``````

OK, it’s just inserted there in the code. That’s not very troubling to me, because this object’s job is to know the right way to create the surfaces. We’ll do something similar for the saucer.

First let’s clean up the `Saucer.__init__` to make the word “size” a bit more available. I’ll add it as a second parameter to the init.

``````class Saucer:
def __init__(self, position=None, size=2):
self.position = position if position is not None else u.CENTER
self.size = size
self.velocity = u.SAUCER_VELOCITY
self.direction = 1
self.score_list = [0, 0, 0]
raw_dimensions = Vector2(10, 6)
saucer_scale = 4*self.size
self.offset = raw_dimensions*saucer_scale/2
saucer_size = raw_dimensions*saucer_scale
self.saucer_surface = SurfaceMaker.saucer_surface(saucer_size)
``````

I think this should double the size of the saucer right now. However, when I see it, it’s obviously too large: it’s bigger than the ship. But some research into history back tells me that the original was also larger than the ship. We’ll accept this. I think that perhaps our overall scale should be smaller.

I think the size story is done. Commit: initial saucer double previous size. code allows for smaller saucer size.

OK, now for a slightly larger surface size, we begin here:

``````    @staticmethod
def saucer_surface(saucer_size):
raw_points_span = Vector2(10, 6)
raw_points_offset = raw_points_span / 2
scale_factor = saucer_size.x / raw_points_span.x
saucer_surface = SurfaceMaker.create_scaled_surface(
saucer_size, raw_points_offset, scale_factor, raw_saucer_points)
return saucer_surface
``````

And we add a little room for a fat line. (The bottom line looks thin because PyGame is drawing a line that partly extends past the edge.)

``````    @staticmethod
def saucer_surface(saucer_size):
raw_points_span = Vector2(10, 6)
raw_points_offset = raw_points_span / 2
scale_factor = saucer_size.x / raw_points_span.x
room_for_fat_line = Vector2(0, 2)
saucer_surface = SurfaceMaker.create_scaled_surface(
saucer_size + room_for_fat_line, raw_points_offset, scale_factor, raw_saucer_points)
return saucer_surface
``````

That does the trick:

Let’s commit: saucer bottom line now shows fully.

Reflection

While I do think we’re going to want to tune the sizes of things, the calculation of scale that we have seems to me to be pretty reasonable. Certainly these two changes were easy enough. And overall, I’m finding this version is pretty easy to work on. Even moving all the top-level stuff into an object was simple enough, just tedious. Had I just moved everything at once, I might have even gotten away with it, albeit with a lot more debugging.

Let’s do the saucer zig-zag. What do we want?

• About every second and a half, the saucer should change direction. Its direction can be straight across, up at 45 degrees, or down at 45 degrees. It should choose to go straight 1/2 the time, up 1/4, down 1/4. The time may need tuning and/or randomizing.

The Saucer is told to move every tick, and we do not have a general `update` method for it. Let’s assume that we’ll put the zig-zag decision in move, and initialize a time counter in `ready` and of course in the zig-zag code to make it go again.

We’ll do some tests.

``````    def test_zig_zag_directions(self):
saucer = Saucer()
straight = u.SAUCER_VELOCITY
up = straight.rotate(45)
down = straight.rotate(-45)
assert saucer.new_direction(0) == up
assert saucer.new_direction(1) == straight
assert saucer.new_direction(2) == straight
assert saucer.new_direction(3) == down
``````

Here, I’m assuming that we’ll have a random integer of 0, 1, 2, or 3 and that the `new_direction` will work as shown. Test thinks we don’t have that method. It ain’t wrong.

``````class Saucer:
def __init__(self, position=None, size=2):
self.position = position if position is not None else u.CENTER
self.size = size
self.velocity = u.SAUCER_VELOCITY
self.directions = [self.velocity.rotate(45), self.velocity, self.velocity, self.velocity.rotate(-45)]
...

def new_direction(self, index):
return self.directions[index % 4]
``````

I think we should be green. Yes. Let’s review `ready`:

``````    def ready(self):
self.velocity = self.direction*u.SAUCER_VELOCITY
x = 0 if self.direction > 0 else u.SCREEN_SIZE
self.position = Vector2(x, random.randrange(0, u.SCREEN_SIZE))
self.direction = -self.direction
``````

Ah, this is tricky. We’ve toggled direction after using it. I think we’d like to toggle it before, because we’re going to need it when we zig. Or zag. Change this and the init.

``````    def ready(self):
self.direction = -self.direction
self.velocity = self.direction*u.SAUCER_VELOCITY
x = 0 if self.direction > 0 else u.SCREEN_SIZE
self.position = Vector2(x, random.randrange(0, u.SCREEN_SIZE))

def __init__(self, position=None, size=2):
self.position = position if position is not None else u.CENTER
self.size = size
self.velocity = u.SAUCER_VELOCITY
self.directions = [self.velocity.rotate(45), self.velocity, self.velocity, self.velocity.rotate(-45)]
self.direction = -1
...
``````

When I do that, the saucer starts on the right. When I change the init to +1, tests break and the saucer starts on the left. I guess I want this value to be right badly enough to fix the tests.

It’s not that bad. Not worth showing the code. Mostly I just called `ready` one more time or one fewer, to toggle the sign in the direction needed.

OK where were we? I don’t see a good way to test the timer stuff, because I want it to call random, and I don’t want to do a dependency injection for anything this simple. We’ll just do it.

We’ll have a zig-zag timer in Saucer and init it to 1.5.

``````    def ready(self):
self.direction = -self.direction
self.velocity = self.direction*u.SAUCER_VELOCITY
x = 0 if self.direction > 0 else u.SCREEN_SIZE
self.position = Vector2(x, random.randrange(0, u.SCREEN_SIZE))
self.zig_timer = 1.5
``````

And in move:

``````    def move(self, delta_time, saucers):
self.check_zigzag(delta_time)
self.position += delta_time*self.velocity
x = self.position.x
if x < 0 or x > u.SCREEN_SIZE:
if self in saucers:
saucers.remove(self)

def check_zigzag(self, delta_time):
self.zig_timer -= delta_time
if self.zig_timer <= 0:
self.zig_timer = 1.5
rand = random.randint(0, 3)
self.velocity = self.new_direction(rand)*self.direction
``````

I don’t like the effect, it’s not frequent enough. Tweak the number to 1. Move it to u anyway.

``````u.py
SAUCER_ZIG_TIME = 1

self.direction = -self.direction
self.velocity = self.direction*u.SAUCER_VELOCITY
x = 0 if self.direction > 0 else u.SCREEN_SIZE
self.position = Vector2(x, random.randrange(0, u.SCREEN_SIZE))
self.zig_timer = u.SAUCER_ZIG_TIME
``````

OK, good enough. Commit: saucer zigs once per second (exactly).

Let’s sum up.

Summary

We got some real progress on the saucer, as directed:

• Possibly randomize saucer zig time. [NEW]
• Saucer scores 200 in initial size, more when small. (Look it up.)
• Saucer fires a missile very so often (1 second? 1/2?)
• Saucer only has two missiles on screen at a time;
• Saucer fires randomly 3/4 of the time, targeted 1/4 of the time;
• Saucer will fire across border if it’s a better shot;
• Saucer missiles do (do not) kill asteroids;
• Saucer does (does not?) destroy asteroids when it hits them;
• Saucer missiles do not add to score;
• Saucer is smaller if score > 10000 or some value;
• Saucer start left to right, alternates right to left;
• Saucer appears every n (=7) seconds;
• Saucer is too small, should be 2x. [NEW]
• Saucer bottom appears truncated. Use larger surface. [NEW]
• Saucer zig-zags every 1.5xx 1.0 seconds, left, straight, straight, right, randomly;

We didn’t write tests for the timing, but we did write a test for the direction chosen. The code is stronger than the tests, because it uses a mod (%) that we didn’t test for. Let’s do that.

Oh my. I have an intermittent test. The move test moves so far that the zig timer hits. Recast that.

``````    def test_move(self):
saucer = Saucer()
starting = saucer.position
saucer.move(0.1, [])
assert saucer.position.x == u.SAUCER_VELOCITY.x*0.1
saucer.move(0.1, [])
assert saucer.position.x == 2*u.SAUCER_VELOCITY.x*0.1
``````

That was really odd. Now for the test driving out the mod. A few values outside 0-3 should do it:

``````    def test_zig_zag_directions(self):
saucer = Saucer()
straight = u.SAUCER_VELOCITY
up = straight.rotate(45)
down = straight.rotate(-45)
assert saucer.new_direction(0) == up
assert saucer.new_direction(1) == straight
assert saucer.new_direction(2) == straight
assert saucer.new_direction(3) == down
assert saucer.new_direction(4) == up
assert saucer.new_direction(5) == straight
assert saucer.new_direction(-1) == down
``````

Commit: improve saucer tests.

Now, as I was about to say, this Python Asteroids has been going smoothly. I think there are a few things playing into that:

1. Python is a duck-typing language, and I am more facile with those;
2. Things tend to be simpler in a duck-typing language;
3. Simpler works well in a small program like this;
4. But duck-typing might be problematical in a larger program;
5. But it might not, if you’re good with tests;
6. I’ve been about 0.65 good with tests here;
7. But for a graphics-oriented game that’s not too bad;
8. Don’t get a big head, Ron, it’s not that good;
9. The game’s actions are simple and small;
10. Modifying simple small actions is easy;
11. But it can lead to trouble if things get too complicated;
12. In Asteroids, things rarely get too complicated, because it’s a tiny program.

In my very long experience programming, the bulk of the difficulties and opportunities of large programs show up in small ones. Some issues, of course, are unique to large programs, and even small issues are often magnified by the sheer volume of the code. But mostly, even in a pretty bad design, we can work in a local area, without too much regard for larger issues, so the things we see even in tiny programs like this one are things we’ll see day to day in our larger real world work.

There’s a bigger lesson that I’d take with me if I were ever to go back to working on a really large program: make all your problems small.

We’d do that with careful attention to duplication and to coupling and cohesion. We’d keep things together that belong together, and keep things apart that belong apart. We’d try to avoid the complexity that we often add as programmers. We’d try to isolate the complexity that comes from the business decisions outside our control.

We’d work to keep most things simple and to isolate what can’t be simple.

We’d still get in trouble, but I think that with care, we’d get in less trouble, get in trouble later, and be better equipped to get back out of trouble.

I could be dreaming, but over six decades of software development makes me think that I might be onto something. I wish I had known then what I know now. And if I had known I was going to live this long, I’d have taken better care of myself.

See you next time!