Python Asteroids on GitHub

A couple of small changes, then we’ll see what’s next.

User Feedback

I was delighted to get a message on Mastodon from Jani Poikela, reporting issues playing the game. Wow, real users! Jani found the ship exploding when going forward very fast. That will happen near the speed of light if you fire, because the missile can’t move away fast enough. Don’t do that.

Jani also saw the message “channel came back None”, which comes out if the sound system runs out of channels. The default is eight, so I think Jani must be pretty good at the game, smashing lots of things all at once.

I’ve increased the number of channels and suppressed the message based on Jani’s input:

class Sounds:
    def play(self, name, location=None, multi_channel=True):
        if name in self.catalog:
            sound = self.catalog[name]
            count = sound.get_num_channels()
            if multi_channel or count == 0:
                chan = self.catalog[name].play()
                if chan:
                    self.set_volume(chan, location)
                # else:
                    # print("channel came back None")

class Game:
    def init_pygame_and_display(self, testing):
        if testing:
        pygame.mixer.set_num_channels(16). # <===
        self.clock = pygame.time.Clock()
        self.screen = pygame.display.set_mode((u.SCREEN_SIZE, u.SCREEN_SIZE))

That should help with that issue. For sure the message won’t come out any more. Internet thinks there are odd issues with get_num_channels but I think we should be OK now.

Hyperspace Roll

For testing purposes, the press_button method accepts the random roll as a parameter:

class HyperspaceGenerator:
    def press_button(self, asteroid_tally, dice_roll=0, fleets=None):
        self._asteroid_tally = asteroid_tally
        if self._charged and not self._button_down:
            self.jump_or_explode(asteroid_tally, dice_roll, fleets)
        self._button_down = True

I had intended to make that parameter optional and roll internally, which makes more sense as it is the HG that has the glitch in it. To do that, I think we need to reorder the parameters and put the roll last.

Let’s try Change Signature, which often works.

    def press_button(self, asteroid_tally, fleets=None, dice_roll=0):
        self._asteroid_tally = asteroid_tally
        if self._charged and not self._button_down:
            self.jump_or_explode(asteroid_tally, dice_roll, fleets)
        self._button_down = True

The tests all pass. I want to look and see what it did. It correctly reordered my tests that provide all three parameters and it left the ones that just send 99 alone. Tests are green, so let’s add the new feature. I see no good way to test it, so I’ll just do it.

    def press_button(self, asteroid_tally, fleets=None, dice_roll=None):
        dice_roll = dice_roll if dice_roll else random.randrange(0, 63)
        self._asteroid_tally = asteroid_tally
        if self._charged and not self._button_down:
            self.jump_or_explode(asteroid_tally, dice_roll, fleets)
        self._button_down = True

And then:

class Ship(Flyer):
        if keys[pygame.K_SPACE]:
            # roll = random.randrange(0, 63) <=== removed
            self._hyperspace_generator.press_button(self._asteroid_tally, fleets)

Works as intended. Beyond actually passing in a fake random number generator, I don’t see a good way to test that, but by inspection it is correct, and it seems to work as expected in the game. Certainly hyperspace works often but not always.

Commit: HyperspaceGenerator rolls dice unless provided with a roll.

Now What?

I’ve removed the hyperspace-related Jira sticky notes. I’m left with these:

  1. Pass delta_time to begin / end?
  2. Debug keys
  3. Star Field
  4. move ->update (tick?)
  5. Simplify Saucer
  6. tick move saucer has extra parm
  7. Better tests around startup
  8. rename fleets & asteroids_tick
  9. Why have Fleets and Fleet?

There is no object that needs delta_time in begin_ or end_interactions. I’ll remove that note. If we have a need, we’ll do it then.

I’ve not been feeling the need for debug keys, which would do things like turn off asteroids so that I can shoot the saucer, or such. Delete.

I’m saving Star Field for a day when I’m bored.

Renaming move to update is probably a good idea. Let’s do it.

After the rename, one test fails. PyCharm got a little optimistic. I change it back to move:

    def test_ship_move(self):
        ship = Ship(Vector2(50, 60))
        ship.velocity_testing_only = Vector2(10, 16)
        assert ship.position == Vector2(55, 68)

So that’s done. I like it. We’re green. Commit: rename move to update. Another yellow sticky note bites the dust.

I don’t see anything wrong with the parameters in Saucer tick, update, or _move. Delete the sticky. If we notice something later, we’ll fix it or make a new sticky.

We are down to this:

  1. Star Field
  2. Simplify Saucer
  3. Better tests around startup
  4. rename fleets & asteroids_tick
  5. Why have Fleets and Fleet?
  6. Pass delta_time to begin / end?
  7. Debug keys
  8. move ->update (tick?)
  9. tick move saucer has extra parm

Let’s have a glance at Saucer, see why I thought it needs to be simplified.

Well, for starters, it’s about 200 lines long.

I do a little tidying, sorting methods into better locations, setting member variables private, and such. Committed a couple of times.

There’s just a lot in there. It has a lot of responsibilities. One that seems fairly large and perhaps independent is targeting. Here’s the firing logic:

class Saucer(Flyer):
    def fire_if_possible(self, delta_time, fleets):
        self._missile_timer.tick(delta_time, self.fire_if_missile_available, fleets)

    def fire_if_missile_available(self, fleets) -> bool:
        if self._missile_tally >= u.SAUCER_MISSILE_LIMIT:
            return False
        missile = self.create_missile()
        return True

    def create_missile(self):
        """callback method, called from"""
        should_target = random.random()
        random_angle = random.random()
        return self.suitable_missile(should_target, random_angle)

    def suitable_missile(self, should_target, random_angle):
        if self.cannot_target_ship(should_target):
            return self.missile_at_angle(random_angle * 360.0, self._velocity)
            targeting_angle = self.angle_to(self._ship)
            velocity_adjustment = Vector2(0, 0)
            return self.missile_at_angle(targeting_angle, velocity_adjustment)

    def cannot_target_ship(self, should_target):
        return not self._ship or should_target > u.SAUCER_TARGETING_FRACTION

    def missile_at_angle(self, desired_angle, velocity_adjustment):
        missile_velocity = Vector2(u.MISSILE_SPEED, 0).rotate(desired_angle) + velocity_adjustment
        offset = Vector2(2 * self._radius, 0).rotate(desired_angle)
        return Missile.from_saucer(self.position + offset, missile_velocity)

    def angle_to(self, ship):
        aiming_point = nearest_point(self.position, ship.position, u.SCREEN_SIZE)
        angle_point = aiming_point - self.position
        return degrees(atan2(angle_point.y, angle_point.x))

def nearest_point(shooter, target, wrap_size):
    nearest_x = nearest(shooter.x, target.x, wrap_size)
    nearest_y = nearest(shooter.y, target.y, wrap_size)
    return Vector2(nearest_x, nearest_y)

def nearest(shooter, target, wrap_size):
    direct_distance = abs(target - shooter)
    target_wrap_left = target - wrap_size
    wrap_left_distance = abs(target_wrap_left - shooter)
    target_wrap_right = target + wrap_size
    wrap_right_distance = abs(target_wrap_right - shooter)
    if wrap_left_distance < direct_distance:
        return target_wrap_left
    elif wrap_right_distance < direct_distance:
        return target_wrap_right
        return target

That’s 55 lines right there that could be in a TargetingComputer class or something like that. The two top-level functions, nearest_point and nearest, are used to decide whether to fire across the edge of the screen for a wrap-around shot. Sneaky but deadly.

Some of that could be cleaned up but the benefit will come from moving it out. Make a sticky: TargetingComputer for Saucer.

I think I’ll move all that code together, toward the bottom of Saucer, under a comment line.

There are still about 150 lines above that, so TargetingComputer, if we do it, isn’t going to solve this problem but it will help reduce the thing by 25%.


We’ve done a few useful changes, sorted through our Jira tickets, and improved Saucer a tiny bit. A decent afternoon’s work. Everything went easily, because the tests support these changes well, and PyCharm is a rather clever tool.

See you next time!