Python Asteroids on GitHub

Somehow I am enjoying working on, refining, polishing this Asteroids game. I don’t want to stop. I have at least two silly features in mind. And, are there still lilies to gild? We discuss inheritance. Basically done?

C.W.
Lots of thinking and speculation in here, no code changes, but some code displayed to show why no code changes were found. Scan freely, or go about your other business. I just write ‘em, I don’t demand that you read ‘em.

But there are at least three valuable ideas in here. Probably.

My two feature ideas include:

  1. A moving star field in the background.
  2. The asteroids could rotate at random speeds.
  3. What would a 3D Asteroids game be like?
  4. Do the Gilded Rose in Python?
  5. [Your idea here. Toot1 me up!]

As for lily gilding, we’ll take a look at some of the larger classes. But first, a word from our sponsor, Implementation Inheritance. Here’s a snippet of code from ScoreKeeper, which happens to be open on my screen just now:

    def interact_with_asteroid(self, asteroid, fleets):
        pass

    def interact_with_missile(self, missile, fleets):
        pass

    def interact_with_saucer(self, saucer, fleets):
        pass

    def interact_with_ship(self, ship, fleets):
        pass

There’s a dozen lines that aren’t as interesting as they might be.

I wonder how many occurrences there are of something like that.

There are 92 occurrences of pass in 18 files. Flyer has a bunch but that may be OK, it’s defining the protocol. Asteroid has 2. Fragment has 4, GameOver 5, Saucer 2, SaucerMaker 4, Score 6, ScoreKeeper 6, Ship 1, ShipMaker 1, ShipProvider 4, Signal 5, and there are a bunch in tests.

Those are not all in interact_with_something. Some are legitimate implementations in null objects. Some are other “events” that the object ignores, such as draw or tick.

Well, since the game’s fundamental notion is that everyone is sent interact_with_whatever for every kind of object in the mix, a case can be made that, at least for the real Flyers, asteroid, missile, ship, and saucer, every class should be required to say explicitly what it does with those classes. And that’s why they are listed as abstract methods in Flyer, to enforce that rule. The interact methods for all the non-flying Flyers are non-abstract and thus optional.

There’s a trade-off here. We gain safety, some error prevention, by making those methods abstract, requiring Flyers all to implement them explicitly. And we suffer some loss of code clarity, because those blank methods are in our face when we review a class, taking up screen space and head space.

Rely on Duck Typing

In Python, we have duck typing2, so in principle, we need not have all our objects inheriting from the Flyer interface. We could root them separately, and in their superclass root, implement all those methods as pass, removing all that null code from the working classes, who just yearn for a living wage and a bit of freedom excuse me I got off track.

Dispatch Table

Another possibility would be to steal a page from our Kotlin Asteroids, where we had a small dispatch table that each object was required to provide, with that table listing all the events that it needed to handle. That notion was created by GeePaw Hill, who had objected to my use of implementation inheritance, and devised the scheme in an attempt to show me a way to write near-minimal code without implementation inheritance.

That scheme actually had the same issue as we’d have here if we made those methods non-abstract: if the programmer forgot to put an event in the table, the object wouldn’t respond to that event, and there would be no warning about it anywhere. Here, if a ship just happened not to respond to interact_with_missile, the Saucer could not shoot it down. A pleasant outcome but not what the makers intended. With the abstract methods, I am required to explicitly deal with ship-missile interactions.

Delegation

A similar possibility would be to defer the interact_with_thing handling to a separate object. Suppose that each object had to implement interactor, which must return an object that handles all the interact_with_widget methods. We could implement that trivially, as

class Gubbin(Flyer):
	@property
	def interactor(self):
		return self

Then, at our leisure, as if we had any, we could implement an interactor class and anyone who wanted one could return it. And, perhaps, it would have a constructor like this:

class Thingummy(Flyer):
	@property
	def interactor(self):
		return Interactor({
			`interact_with_ship`, self.interact_with_ship,
			`interact_with_missile, self.interact_with_missile
			})

Or something like that, whatever it took to hook it up, and then (a miracle occurs) and the high-level interaction code looks up the interaction in the dictionary and if it is there, calls it and otherwise just skips out.

Essentially, we’d be implementing method dispatching using a dictionary, which would be slower and otherwise not impressive. But it would have the advantage of a compact form for defining interact_with_doodad methods all in one place.

That would be a sort of middle ground, saving source code space, but at the cost of time and perhaps even a bit of clarity while everyone asks “what the hell is going on here”.

Meh, not interesting.

Enough speculation. None of these ideas seems much better than leaving it alone. Of all of them, the most tempting is to allow more implementation inheritance than we already have, at least for the non-flying Flyers.

That phrase does suggest that there’s a missing notion in our hierarchy. We’ll ignore that, for now, as well.

Reviewing some classes

In a desperate attempt to find something to refactor, I look at a few classes. I find things like Missile. Look at this, with its small methods and not many of them:

class Missile(Flyer):
    radius = 2

    def __init__(self, transponder_key, position, velocity):
        self._transponder = Transponder(transponder_key)
        self._timer = Timer(u.MISSILE_LIFETIME)
        self._location = MovableLocation(position, velocity)

    @property
    def position(self):
        return self._location.position

    @property
    def velocity_testing_only(self):
        return self._location.velocity

    def are_we_colliding(self, position, radius):
        kill_range = self.radius + radius
        dist = self.position.distance_to(position)
        return dist <= kill_range

    def interact_with(self, attacker, fleets):
        attacker.interact_with_missile(self, fleets)

    def interact_with_asteroid(self, asteroid, fleets):
        if asteroid.are_we_colliding(self.position, self.radius):
            self.die(fleets)

    def interact_with_missile(self, missile, fleets):
        if missile.are_we_colliding(self.position, self.radius):
            self.die(fleets)

    def interact_with_saucer(self, saucer, fleets):
        if saucer.are_we_colliding(self.position, self.radius):
            self.die(fleets)

    def interact_with_ship(self, ship, fleets):
        if ship.are_we_colliding(self.position, self.radius):
            self.die(fleets)

    def die(self, fleets):
        fleets.remove(self)

    def draw(self, screen):
        pygame.draw.circle(screen, "white", self.position, 4)

    def ping_transponder(self, transponder_key, function, *args):
        self._transponder.ping(transponder_key, function, *args)

    def tick(self, delta_time, fleets):
        self.tick_timer(delta_time, fleets)

    def tick_timer(self, delta_time, fleets):
        self._timer.tick(delta_time, self.die, fleets)

    def update(self, delta_time, _fleets):
        self._location.move(delta_time)

There isn’t much to dislike there, is there?

The largest class is Ship, at 188 lines. It’s not bad. I’ve done a bit of tidying and alphabetized the methods, which is interesting in a couple of ways:

First, alphabetizing sometimes breaks up some chunks that might belong together, such as update:

    def update(self, delta_time, fleets):
        self.control_motion(delta_time, fleets)
        self._location.move(delta_time)
        self.control_firing(fleets)

It might be nicer to have those two control methods near update. Additionally, that suggests that there may be some chunk of responsibility that could be broken out. We’ll think about that.

Second, the need to alphabetize or otherwise organize things is a hint that the class may be too large. I’ll paste it below now, and you can scan over it down to the next thrilling paragraph.

# Ship

from explosion import Explosion
from flyer import Flyer
from hyperspace_generator import HyperspaceGenerator
from missile import Missile
from movable_location import MovableLocation
from painter import Painter
from pygame import Vector2
from sounds import player
from timer import Timer
import pygame
import random
import u


class Ship(Flyer):

    thrust_sound = None

    def __init__(self, position, drop_in=2):
        self.radius = 25 * u.SCALE_FACTOR
        self._accelerating = False
        self._acceleration = u.SHIP_ACCELERATION
        self._allow_freebie = True
        self._angle = 0
        self._asteroid_tally = 0
        self._can_fire = True
        self._drop_in = drop_in
        self._hyperspace_generator = HyperspaceGenerator(self)
        self._hyperspace_timer = Timer(u.SHIP_HYPERSPACE_RECHARGE_TIME)
        self._location = MovableLocation(position, Vector2(0, 0))
        self._missile_tally = 0
        self._shipmaker = None
        self._ship_painter = Painter.ship()
        self._accelerating_painter = Painter.ship_accelerating()

    @property
    def position(self):
        return self._location.position

    @property
    def velocity_testing_only(self):
        return self._location.velocity

    @property
    def velocity(self):
        return self._location.velocity

    @velocity.setter
    def velocity(self, value):
        self._location.velocity = value

    @velocity_testing_only.setter
    def velocity_testing_only(self, velocity):
        self._location.velocity = velocity

    def accelerate_by(self, accel):
        self._location.accelerate_by(accel)

    def accelerate_to(self, accel):
        self._location.accelerate_to(accel)

    def are_we_colliding(self, position, radius):
        kill_range = self.radius + radius
        dist = self.position.distance_to(position)
        return dist <= kill_range

    def begin_interactions(self, fleets):
        self._asteroid_tally = 0
        self._missile_tally = 0

    def control_motion(self, delta_time, fleets):
        if not pygame.get_init():
            return
        keys = pygame.key.get_pressed()
        if keys[pygame.K_s]:
            if self._allow_freebie and self._shipmaker:
                self._shipmaker.add_ship(u.PLAYER_ZERO)
                self._allow_freebie = False
        else:
            self._allow_freebie = True
        if keys[pygame.K_f]:
            self.turn_left(delta_time)
        if keys[pygame.K_d]:
            self.turn_right(delta_time)
        if keys[pygame.K_j]:
            self.power_on(delta_time)
        else:
            self.power_off()
        if keys[pygame.K_SPACE]:
            self._hyperspace_generator.press_button(self._asteroid_tally, fleets)
        else:
            self._hyperspace_generator.lift_button()

    def control_firing(self, fleets):
        if not pygame.get_init():
            return
        if pygame.key.get_pressed()[pygame.K_k]:
            self.fire_if_possible(fleets)
        else:
            self._can_fire = True

    def create_missile(self):
        player.play("fire", self._location)
        return Missile("ship", self.missile_start(), self.missile_velocity())

    def draw(self, screen):
        self.select_ship_source.draw(screen, self.position, self._angle, self._drop_in)

    def explode(self, fleets):
        player.play("bang_large", self._location)
        fleets.remove(self)
        Explosion.from_ship(self.position, fleets)

    def explode_if_hit(self, attacker, fleets):
        if attacker.are_we_colliding(self.position, self.radius):
            self.explode(fleets)

    def fire_if_possible(self, fleets):
        if self._can_fire and self._missile_tally < u.MISSILE_LIMIT:
            fleets.append(self.create_missile())
            self._can_fire = False

    def increment_tally(self):
        self._missile_tally += 1

    def interact_with(self, attacker, fleets):
        attacker.interact_with_ship(self, fleets)

    def interact_with_asteroid(self, asteroid, fleets):
        self._asteroid_tally += 1
        self.explode_if_hit(asteroid, fleets)

    def interact_with_missile(self, missile, fleets):
        missile.ping_transponder("ship", self.increment_tally)
        self.explode_if_hit(missile, fleets)

    def interact_with_saucer(self, saucer, fleets):
        self.explode_if_hit(saucer, fleets)

    def interact_with_ship(self, ship, fleets):
        pass

    def interact_with_shipmaker(self, shipmaker, fleets):
        self._shipmaker = shipmaker

    def missile_start(self):
        start_distance = self.radius + Missile.radius + 1
        offset = Vector2(start_distance, 0).rotate(-self._angle)
        return self.position + offset

    def missile_velocity(self):
        return Vector2(u.MISSILE_SPEED, 0).rotate(-self._angle) + self.velocity_testing_only

    def move_to(self, vector):
        self._location.move_to(vector)

    def power_on(self, dt):
        self._accelerating = True
        player.play("accelerate", self._location, False)
        accel = dt * self._acceleration.rotate(-self._angle)
        self.accelerate_by(accel)

    def power_off(self):
        self._accelerating = False

    @property
    def select_ship_source(self):
        if self._accelerating and random.random() >= 0.66:
            return self._accelerating_painter
        else:
            return self._ship_painter

    def tick(self, delta_time, fleets):
        self._drop_in = self._drop_in - delta_time*2 if self._drop_in > 1 else 1
        self._hyperspace_generator.tick(delta_time)

    def turn_left(self, dt):
        self._angle -= u.SHIP_ROTATION_STEP * dt

    def turn_right(self, dt):
        self._angle += u.SHIP_ROTATION_STEP * dt

    def update(self, delta_time, fleets):
        self.control_motion(delta_time, fleets)
        self._location.move(delta_time)
        self.control_firing(fleets)

Huge, right? But aside from the control stuff, the methods are all quite small and seem to make sense. I don’t see much to worry about here, although it might be tempting to try to extract something around the notion of controlling the ship.

Hey!!
I found something! thrust_sound is not used. Whee, down to 186 lines!

There almost seem to be phases in that code, some kind of logical sections: controlling, interacting, firing, … but nothing jumps out at me as really needing a separate object.

Bottom line, unless we’re going to make a design change of some major kind, I honestly feel like we have polished this about as far as any irrational3 person might go.

We could do some little features, like the star field or spinning asteroids, but really? I’m afraid this thing is done.

See you next time!



  1. That doesn’t sound obscene at all, does it? Ron Jeffries at mastodon dot social 

  2. You’ve got your mallard, your canvasback, your spotted, plumed, or fulvous whistling duck … no, really, duck typing is a property of languages like Python where you don’t need to specify interfaces. If the object responds to a method by name, you can send that message to it. This is a very powerful, very sharp tool. Some love it, some hate it. 

  3. A rational person in a real situation might have stopped long ago. We have kept exploring as an example of what can be discovered in perfectly reasonable code, and how to improve it if we care to, all in small safe steps.