Python Asteroids on GitHub

Now that I have my nifty timer object, I want to use it. Is there a rationale for this? I’ll find one.

All the timing-related actions in the game are working perfectly. Is there any reason under the sun for converting them to use the Timer object? Of course there is.

First, doing the same thing in consistent ways makes the code generally easier to understand. “Are we sending tick to a thing with timer in its name? I think I know what might be going on.”

Second, it’s good practice. No, not in the sense of “best practices” but in the sense of “piano practice”. By using the thing, we’ll become more adept at using the thing, and in doing the related changes. It’s good for us.

Third, this is my new darling, and I’m going to support it.

Fourth, it’s fun and I want to.

Those may not be in priority order.

Missile Timeout

Missiles have a built-in time limit for their lives, and it seems like a perfect opportunity for a timer. I am worried about one little thing, which is that to remove itself from play, an object removes itself from its game-owned collection, which is passed to whatever method decides to remove the object. The space objects do not hold on to this collection, owing to a sort of phobia about things knowing their collections, so we may have some tricky work to do. Then again, we may not.

Let’s see how it’s done now.

Interesting: there’s a special update call:

    def update(self, missiles, delta_time):
        self.time += delta_time
        if self.time > u.MISSILE_LIFETIME:
            missiles.remove(self)

That’s called from the main loop, I imagine:

    def check_missile_timeout(self, delta_time):
        for missile in self.missiles.copy():
            missile.update(self.missiles, delta_time)
        for missile in self.saucer_missiles.copy():
            missile.update(self.saucer_missiles, delta_time)

I’m not entirely happy about this. First of all, the main game loop knows about missile timeout, which is a bit of a crock. Second, why wouldn’t all objects have a chance to update, and wouldn’t that method do whatever updating an object needed, including moving it?

Contrariwise, given that missiles will be told to move, why doesn’t the missile deal with it timing then? Simply because when move is called, it is not passed the object’s collection.

So I think we have identified a design issue in the overall program, which is that update should be sent to everyone, and they should move if they care to. I make a yellow sticky “Jira” note about that.

Anyway, let’s do the timer with our new timer.

Immediately I run into trouble. This is Timer:

class Timer:
    def __init__(self, delay, action, *args):
        self.delay = delay
        self.action = action
        self.args = args
        self.elapsed = 0

    def tick(self, delta_time):
        self.elapsed += delta_time
        if self.elapsed >= self.delay:
            action_complete = self.action(*self.args)
            if action_complete is None:
                raise Exception("Timer action may not return None")
            if action_complete:
                self.elapsed = 0

We can pass arguments when we create the timer, but in Missile, we won’t know the collection at creation time. And we cannot pass arguments to tick, when we will know the collection.

Let’s improve the timer:

    def test_tick_args(self):
        result = ""
        
        def action(action_arg, tick_arg):
            nonlocal result
            result = action_arg + " " + tick_arg
        timer  = Timer(1, action, "action arg")
        timer.tick(1.1, "tick arg")
        assert result == "action arg tick arg"

This should get me going. Python complains that tick doesn’t expect more than one argument. I have no argument with that, but I am here to change things.

    def tick(self, delta_time, *tick_args):
        self.elapsed += delta_time
        if self.elapsed >= self.delay:
            action_complete = self.action(*self.args, *tick_args)
            if action_complete is None:
                raise Exception("Timer action may not return None")
            if action_complete:
                self.elapsed = 0

I forgot to return True in my test. Not the first or last time.

    def test_tick_args(self):
        result = ""

        def action(action_arg, tick_arg):
            nonlocal result
            result = action_arg + " " + tick_arg
            return True
        timer  = Timer(1, action, "action arg")
        timer.tick(1.1, "tick arg")
        assert result == "action arg tick arg"

Green. Commit: Timer tick now accepts arguments.

Now let’s add a Timer to Missile and use it.

class Missile:
    def __init__(self, position, velocity, missile_score_list, saucer_score_list):
    	,,,
        self.timer = Timer(u.MISSILE_LIFETIME, self.timeout)


    def update(self, missiles, delta_time):
        self.timer.tick(delta_time, missiles)

    def timeout(self, missiles):
        missiles.remove(self)
        return True

Tests are green. Game works. Commit: Missile uses Timer for timeout.

Reflection

I have learned something. The default behavior that I want is for the timers to reset, and I seem invariably to forget to return True.

Here’s Timer again:

    def tick(self, delta_time, *tick_args):
        self.elapsed += delta_time
        if self.elapsed >= self.delay:
            action_complete = self.action(*self.args, *tick_args)
            if action_complete is None:
                raise Exception("Timer action may not return None")
            if action_complete:
                self.elapsed = 0

I think what I need here is for None to be the normal return case, meaning reset elapsed time. What if we did this:

    def tick(self, delta_time, *tick_args):
        self.elapsed += delta_time
        if self.elapsed >= self.delay:
            action_complete = self.action(*self.args, *tick_args)
            if action_complete is None or action_complete:
                self.elapsed = 0

Now None means OK, and anything else falsy will not reset, while anything else truthy will reset.

This is undeniably odd, but it flows with how we’re going to use it.

Another possibility would be to return True to mean “don’t reset” but that seems quite odd.

I’m going to try this for a while, and I need to fix one test, the one that used to throw.

    def test_returning_none_resets_timer(self):
        happened = False

        def action_without_return():
            nonlocal happened
            happened = True
        delay = 1
        timer = Timer(delay, action_without_return)
        timer.tick(1.5)
        assert happened

That revised test runs. Now I’m going to find my timers and remove all the return True, as the new default is to reset,

class Missile:
    def timeout(self, missiles):
        missiles.remove(self)

class Saucer:
    def set_zig_timer(self):
        # noinspection PyAttributeOutsideInit
        self.zig_timer = Timer(u.SAUCER_ZIG_TIME, self.zig_zag_action)

    def zig_zag_action(self):
        self.velocity = self.new_direction() * self.direction

We’re green. Game works. Commit: Timer default action on None is to reset.

Reflection

This seems to me to be an interestingly odd situation. If we were to describe how to use the Timer, we might say something like this:

Conversation:

Just set up a timer with a delay and an action. Then send tick(delta_time) to the timer. The timer will count down from delay and when it runs out, the action will happen. The timer will reset and start over.

What if we don’t want the timer to start over?

Just return False from the action and the timer will keep calling the action until it returns True.

But we don’t have to return True in the regular case?

Nope.

OK.

That sort of makes sense, especially if you don’t think too hard about it.

But if you think too hard about it, you’ll be like

Conversation Continued:

But wait! In the regular case my action is returning None, and None is falsy and so why does it reset???

Trust me.

The object is coded in an odd way to accommodate what the user (at least this user) is most likely to do. That’s a good thing. Probably.

Where else can we use Timer? We have wave creation, and we have ship spawning. Each of those will be interesting.

In the case of wave creation, we want to start timing when we notice that there are no asteroids, and I guess we’ll just tick down and then spawn. So that should be easy.

The ship is a bit more tricky, because it sometimes won’t come out. Should be OK too, because we can return false.

Wave Timer

Let’s see how the wave code works now.

    def asteroids_tick(self, delta_time):
        self.check_saucer_spawn(self.saucer, self.saucers, delta_time)
        self.check_ship_spawn(self.ship, self.ships, delta_time)
        self.check_next_wave(delta_time)
        self.check_missile_timeout(self.delta_time)
        self.control_ship(self.ship, delta_time)
        self.move_everything(delta_time)
        self.process_collisions()
        self.draw_everything()
        if self.game_over: self.draw_game_over()

    def check_next_wave(self, delta_time):
        if not self.asteroids:
            if self.wave_timer == u.ASTEROID_TIMER_STOPPED:
                self.wave_timer = u.ASTEROID_DELAY
            else:
                self.create_wave_in_due_time(self.asteroids, delta_time)

    def create_wave_in_due_time(self, asteroids, dt):
        self.wave_timer -= dt
        if self.wave_timer <= 0:
            asteroids.extend([Asteroid() for _ in range(0, self.next_wave_size())])
            self.wave_timer = u.ASTEROID_TIMER_STOPPED

This is weird because of the need to start the timer, and honestly I think it’s made more complicated than it needs to be. Anyway we’re here to use the timer.

Extract a method:

    def create_wave_in_due_time(self, asteroids, dt):
        self.wave_timer -= dt
        if self.wave_timer <= 0:
            self.create_wave(asteroids)
            self.wave_timer = u.ASTEROID_TIMER_STOPPED

    def create_wave(self, asteroids):
        asteroids.extend([Asteroid() for _ in range(0, self.next_wave_size())])

We’ll use create_wave in our Timer.

I plan to just make the changes I need. I expect tests to break. Starting here:

    # noinspection PyAttributeOutsideInit
    def init_asteroids_game_values(self):
        self.asteroids_in_this_wave: int
        self.saucer_timer = 0
        self.ship_timer = 0
        self.ships_remaining = 0
        self.wave_timer = u.ASTEROID_TIMER_STOPPED

That becomes:

    # noinspection PyAttributeOutsideInit
    def init_asteroids_game_values(self):
        self.asteroids_in_this_wave: int
        self.saucer_timer = 0
        self.ship_timer = 0
        self.ships_remaining = 0
        self.wave_timer = Timer(u.ASTEROID_DELAY, self.create_wave)

And also:

    def insert_quarter(self, number_of_ships):
        self.asteroids = []
        self.missiles = []
        self.ships = []
        self.asteroids_in_this_wave = 2
        self.game_over = False
        self.saucer_timer = u.SAUCER_EMERGENCE_TIME
        self.score = 0
        self.ships_remaining = number_of_ships
        self.set_ship_timer(u.SHIP_EMERGENCE_TIME)
        self.wave_timer = Timer(u.ASTEROID_DELAY, self.create_wave)
        self.delta_time = 0

And:

    def check_next_wave(self, delta_time):
        if not self.asteroids:
            self.wave_timer.tick(delta_time, self.asteroids)

I think this should work as it stands. It does. One thing that I don’t like is the duplication of the creation of the Timer. And I have to say that I forgot to put the one in insert_quarter until I got a failure.

    # noinspection PyAttributeOutsideInit
    def init_asteroids_game_values(self):
        self.asteroids_in_this_wave: int
        self.saucer_timer = 0
        self.ship_timer = 0
        self.ships_remaining = 0
        self.init_wave_timer()

    def insert_quarter(self, number_of_ships):
        self.asteroids = []
        self.missiles = []
        self.ships = []
        self.asteroids_in_this_wave = 2
        self.game_over = False
        self.saucer_timer = u.SAUCER_EMERGENCE_TIME
        self.score = 0
        self.ships_remaining = number_of_ships
        self.set_ship_timer(u.SHIP_EMERGENCE_TIME)
        self.init_wave_timer()
        self.delta_time = 0

    def init_wave_timer(self):
        self.wave_timer = Timer(u.ASTEROID_DELAY, self.create_wave)

That’s better. I still have to remember to do it, but I don’t have to remember how to do it.

Commit: Game wave timing done with Timer.

Saucer Emergence Timer

Ah, I see right there in front of me, the saucer emergence time. That needs to be a timer.

Let’s review how we use that.

    def check_saucer_spawn(self, saucer, saucers, delta_time):
        if saucers: return
        self.saucer_timer -= delta_time
        if self.saucer_timer <= 0:
            saucer.ready()
            saucers.append(saucer)
            self.saucer_timer = u.SAUCER_EMERGENCE_TIME

Well that’s pretty obvious isn’t it? Again I’ll just follow my nose.

    # noinspection PyAttributeOutsideInit
    def init_asteroids_game_values(self):
        self.asteroids_in_this_wave: int
        self.saucer_timer = 0
        self.ship_timer = 0
        self.ships_remaining = 0
        self.init_wave_timer()

Extract that timer line and create the timer.

    # noinspection PyAttributeOutsideInit
    def init_asteroids_game_values(self):
        self.asteroids_in_this_wave: int
        self.init_saucer_timer()
        self.ship_timer = 0
        self.ships_remaining = 0
        self.init_wave_timer()

    def init_saucer_timer(self):
        self.saucer_timer = Timer(u.SAUCER_EMERGENCE_TIME, self.bring_in_saucer)

I need bring_in_saucer, and it comes from here:

    def check_saucer_spawn(self, saucer, saucers, delta_time):
        if saucers: return
        self.saucer_timer -= delta_time
        if self.saucer_timer <= 0:
            saucer.ready()
            saucers.append(saucer)
            self.saucer_timer = u.SAUCER_EMERGENCE_TIME

We’ll extract:

    def check_saucer_spawn(self, saucer, saucers, delta_time):
        if saucers: return
        self.saucer_timer -= delta_time
        if self.saucer_timer <= 0:
            self.bring_in_saucer(saucer, saucers)
            self.saucer_timer = u.SAUCER_EMERGENCE_TIME

    def bring_in_saucer(self, saucer, saucers):
        saucer.ready()
        saucers.append(saucer

And fix up the first bit there:

    def check_saucer_spawn(self, saucer, saucers, delta_time):
        if saucers: return
        self.saucer_timer.tick(delta_time, saucer, saucers)

    def bring_in_saucer(self, saucer, saucers):
        saucer.ready()
        saucers.append(saucer)

That’s breaking a test. Oh, right, forgot this:

    def insert_quarter(self, number_of_ships):
        self.asteroids = []
        self.missiles = []
        self.ships = []
        self.asteroids_in_this_wave = 2
        self.game_over = False
        self.init_saucer_timer()
        self.score = 0
        self.ships_remaining = number_of_ships
        self.set_ship_timer(u.SHIP_EMERGENCE_TIME)
        self.init_wave_timer()
        self.delta_time = 0

I do still have a test failing but it is this one:

    def test_spawn_saucer(self):
        game = Game(testing=True)
        saucer = game.saucer
        game.game_init()
        game.check_saucer_spawn(saucer, game.saucers, 0.1)
        assert not game.saucers
        game.check_saucer_spawn(saucer, game.saucers, u.SAUCER_EMERGENCE_TIME)
        assert saucer in game.saucers
        assert game.saucer_timer == u.SAUCER_EMERGENCE_TIME

That’ll need revision but I want to run the game first. The saucer does spawn. So it’s just that the test need revision to bring it up to date. We just have to not check the time:

    def test_spawn_saucer(self):
        game = Game(testing=True)
        saucer = game.saucer
        game.game_init()
        game.check_saucer_spawn(saucer, game.saucers, 0.1)
        assert not game.saucers
        game.check_saucer_spawn(saucer, game.saucers, u.SAUCER_EMERGENCE_TIME)
        assert saucer in game.saucers

We could test further by removing the saucer and running the timer again, ensuring that the timer doesn’t turn itself off. Let’s do.

    def test_spawn_saucer(self):
        game = Game(testing=True)
        saucer = game.saucer
        game.game_init()
        game.check_saucer_spawn(saucer, game.saucers, 0.1)
        assert not game.saucers
        game.check_saucer_spawn(saucer, game.saucers, u.SAUCER_EMERGENCE_TIME)
        assert saucer in game.saucers
        game.saucers.remove(saucer)
        assert not game.saucers
        game.check_saucer_spawn(saucer, game.saucers, u.SAUCER_EMERGENCE_TIME)
        assert saucer in game.saucers

Green. Commit: saucer spawning uses Timer.

Article’s more than long enough, and I’ve been working nearly two full hours (whew!), so let’s sum up.

Summary

We found that we had a need to pass parameters to the timer’s action either from the creation of the timer, or from the tick operation. That’s not quite accurate: we have never used the facility to pass arguments in at the time of creation, only passing them at the time of ticking.

I guess leaving the capability in is harmless. The code is pretty clear, and we might need it someday. No point deleting the capability now, I think:

class Timer:
    def __init__(self, delay, action, *args):
        self.delay = delay
        self.action = action
        self.args = args
        self.elapsed = 0

    def tick(self, delta_time, *tick_args):
        self.elapsed += delta_time
        if self.elapsed >= self.delay:
            action_complete = self.action(*self.args, *tick_args)
            if action_complete is None or action_complete:
                self.elapsed = 0

I should mention that Python conventions usually call for allowing both *args and *kwargs, the latter supporting keyword arguments. We’re not using those, but if we were building a Timer library, we’d probably want to cater to the possibility.

We also learned that it is nearly certain that the programmer—at least the one we have—will forget to return True from an action, so we changed the code to treat a return of None as a normal return, resetting the timer.

I like the way the timer went in in the wave and saucer timers, where something “goes wrong” and we want to fix it after a delay:

if something_is_wrong:
	self.timer.tick(...)

Very convenient.

Overall, the Timer seems to me to be simplifying the code. I’m glad we have it.

Small Issue

I am noticing another issue, which is that some things need to be initialized both when the game is created, and when a quarter is inserted. It’s easy to forget to deal with both locations. That’s the thing with duplication, isn’t it?

But we don’t want to insert a quarter at the beginning. We want the game to start in the GAME OVER state. And it turns out that for both the asteroid waves and the saucer … we actually want those timers running during GAME OVER, because that makes the attract screen.

Must think about that. But not now. We’re done for now, and happy about our nice shiny Timer.

Added at Last Second:
Maybe Timer needs a reset function. Then we could init in init and just reset them when we insert a quarter.

See you next time!