Python Asteroids on GitHub

This afternoon, I notice something that could be better. Let’s see if we can find a bit of improvement.

I happened to glance at PyCharm and saw this:

    def create_optimal_missile(self, fleets, saucer, ship):
        saucer_position = saucer.position
        closest_shot_position = self.closest_aiming_point(saucer_position, ship.position, u.SCREEN_SIZE)
        delta_position = closest_shot_position - saucer_position
        aim_time, adjustment_ratio = self.optimal_shot(delta_position, ship)
        target_position = closest_shot_position + ship.velocity * aim_time
        self.create_adjusted_missile(adjustment_ratio, target_position, saucer_position, fleets)

    def optimal_shot(self, delta_position, ship):
        aim_time = self.time_to_target(delta_position, ship.velocity)
        adjustment_ratio = self.velocity_adjustment(aim_time)
        return aim_time, adjustment_ratio

At first, I was just thinking that maybe closest_shot_position should be done first, and that maybe it needed a better name. But then I noticed that optimal_shot has an odd choice of parameters. One is a point and the other is a game object. Inside, from the game object, the method extracts velocity. Let’s correct the imbalance:

    def create_optimal_missile(self, fleets, saucer, ship):
        saucer_position = saucer.position
        closest_shot_position = self.closest_aiming_point(saucer_position, ship.position, u.SCREEN_SIZE)
        delta_position = closest_shot_position - saucer_position
        delta_velocity = ship.velocity  # we treat saucer as not moving
        aim_time, adjustment_ratio = self.optimal_shot(delta_position, delta_velocity)
        target_position = closest_shot_position + delta_velocity * aim_time
        self.create_adjusted_missile(adjustment_ratio, target_position, saucer_position, fleets)

    def optimal_shot(self, delta_position, delta_velocity):
        aim_time = self.time_to_target(delta_position, delta_velocity)
        adjustment_ratio = self.velocity_adjustment(aim_time)
        return aim_time, adjustment_ratio

Better. The name of the method isn’t great. But let’s look more deeply:

    def optimal_shot(self, delta_position, delta_velocity):
        aim_time = self.time_to_target(delta_position, delta_velocity)
        adjustment_ratio = self.velocity_adjustment(aim_time)
        return aim_time, adjustment_ratio

    def velocity_adjustment(self, aim_time):
        return self.compensate_for_offset(aim_time) if aim_time else 1

    def compensate_for_offset(self, aim_time):
        distance_to_target = aim_time * u.MISSILE_SPEED
        adjusted_distance = distance_to_target - 2 * self._radius
        return adjusted_distance / distance_to_target

It seems to me that once we are down here near the geometry, we shouldn’t be reaching back up to get self._radius. Let’s pass that in as another parameter:

    def create_optimal_missile(self, fleets, saucer, ship):
        saucer_position = saucer.position
        closest_shot_position = self.closest_aiming_point(saucer_position, ship.position, u.SCREEN_SIZE)
        delta_position = closest_shot_position - saucer_position
        delta_velocity = ship.velocity  # we treat saucer as not moving
        aim_time, adjustment_ratio = self.optimal_shot(delta_position, delta_velocity, 2*self._radius)
        target_position = closest_shot_position + delta_velocity * aim_time
        self.create_adjusted_missile(adjustment_ratio, target_position, saucer_position, fleets)

    def optimal_shot(self, delta_position, delta_velocity, initial_offset):
        aim_time = self.time_to_target(delta_position, delta_velocity)
        adjustment_ratio = self.velocity_adjustment(aim_time, initial_offset)
        return aim_time, adjustment_ratio

    def velocity_adjustment(self, aim_time, initial_offset):
        return self.compensate_for_offset(aim_time, initial_offset) if aim_time else 1

    def compensate_for_offset(self, aim_time, initial_offset):
        distance_to_target = aim_time * u.MISSILE_SPEED
        adjusted_distance = distance_to_target - initial_offset
        return adjusted_distance / distance_to_target

PyCharm points out that compensate can be static. Now none of optimal_shot, velocity_adjustment, nor compensate_for_offset are really referring to the Gunner or Saucer at all.

This could be a new object, I think. I feel that one has been trying to hatch for quite a while.

Let’s commit what we have, as it is working well and a bit better.

Commit: refactoring toward separate shot optimizer?

Extraction

Now how can we extract a class from here? In particular, I’d like to have some tests, but let’s see if we can just do what’s needed.

Let’s create a tiny class and see if we can do a strangler conversion. As you’ll see, by that I mean that we’ll create a small separate class, pass it the caller at first, and call back to get the work done. Then we’ll move the actual code over, bit by bit, keeping the thing running all the time.

The “strangler” idea comes from Michael Feathers, I believe, in his amazing book Working Effectively with Legacy Code.

    def create_optimal_missile(self, fleets, saucer, ship):
        saucer_position = saucer.position
        closest_shot_position = self.closest_aiming_point(saucer_position, ship.position, u.SCREEN_SIZE)
        delta_position = closest_shot_position - saucer_position
        delta_velocity = ship.velocity  # we treat saucer as not moving
        initial_offset = 2*self._radius
        optimizer = ShotOptimizer(self, delta_position, delta_velocity, initial_offset)
        aim_time, adjustment_ratio = optimizer.optimize()
        target_position = closest_shot_position + delta_velocity * aim_time
        self.create_adjusted_missile(adjustment_ratio, target_position, saucer_position, fleets)

And the new class so far:

class ShotOptimizer:
    def __init__(self, gunner, delta_position, delta_velocity, initial_offset):
        self._gunner = gunner
        self._delta_position = delta_position
        self._delta_velocity = delta_velocity
        self._initial_offset = initial_offset

    def optimize(self):
        return self._gunner.optimal_shot(self._delta_position, self._delta_velocity, self._initial_offset)

As you see, we’ve passed in the gunner as a temporary measure, and we’re just calling back to gunner to do the work for us. Tests are green.

Now we can move just one method and repeat the trick.

    def optimize(self):
        return self.optimal_shot(self._delta_position, self._delta_velocity, self._initial_offset)

    def optimal_shot(self, delta_position, delta_velocity, initial_offset):
        aim_time = self._gunner.time_to_target(delta_position, delta_velocity)
        adjustment_ratio = self._gunner.velocity_adjustment(aim_time, initial_offset)
        return aim_time, adjustment_ratio

We could be committing each of these, at least to my local repo. Let’s not, while that would be a better habit for me, I don’t want to change habit in the middle of a strangler.

We can remove the optimal_shot method from Gunner, and we do. Let’s move time_to_target. It has lots of tests, so we’ll copy it over and not remove the old version from Gunner yet.

    def optimal_shot(self, delta_position, delta_velocity, initial_offset):
        aim_time = self.time_to_target(delta_position, delta_velocity)
        adjustment_ratio = self._gunner.velocity_adjustment(aim_time, initial_offset)
        return aim_time, adjustment_ratio

    @staticmethod
    def time_to_target(delta_position, relative_velocity):
        # from https://www.gamedeveloper.com/programming/shooting-a-moving-target#close-modal
        # return time for hit or -1
        # quadratic
        a = relative_velocity.dot(relative_velocity) - u.MISSILE_SPEED*u.MISSILE_SPEED
        b = 2 * relative_velocity.dot(delta_position)
        c = delta_position.dot(delta_position)
        disc = b*b - 4*a*c
        if disc < 0:
            return 0
        else:
            divisor = (math.sqrt(disc) - b)
            if divisor:
                return 2*c / divisor
            else:
                return 0

Green again. We have to import math and u, of course. Scared me for a moment when some tests failed.

What’s next? velocity_adjustment. Let’s go nuts and move two at once.

class ShotOptimizer:
    def __init__(self, gunner, delta_position, delta_velocity, initial_offset):
        self._gunner = gunner
        self._delta_position = delta_position
        self._delta_velocity = delta_velocity
        self._initial_offset = initial_offset

    def optimize(self):
        return self.optimal_shot(self._delta_position, self._delta_velocity, self._initial_offset)

    def optimal_shot(self, delta_position, delta_velocity, initial_offset):
        aim_time = self.time_to_target(delta_position, delta_velocity)
        adjustment_ratio = self.velocity_adjustment(aim_time, initial_offset)
        return aim_time, adjustment_ratio

    def velocity_adjustment(self, aim_time, initial_offset):
        return self.compensate_for_offset(aim_time, initial_offset) if aim_time else 1

    @staticmethod
    def compensate_for_offset(aim_time, initial_offset):
        distance_to_target = aim_time * u.MISSILE_SPEED
        adjusted_distance = distance_to_target - initial_offset
        return adjusted_distance / distance_to_target

    @staticmethod
    def time_to_target(delta_position, relative_velocity):
        # from https://www.gamedeveloper.com/programming/shooting-a-moving-target#close-modal
        # return time for hit or -1
        # quadratic
        a = relative_velocity.dot(relative_velocity) - u.MISSILE_SPEED*u.MISSILE_SPEED
        b = 2 * relative_velocity.dot(delta_position)
        c = delta_position.dot(delta_position)
        disc = b*b - 4*a*c
        if disc < 0:
            return 0
        else:
            divisor = (math.sqrt(disc) - b)
            if divisor:
                return 2*c / divisor
            else:
                return 0

Now ShotOptimizer isn’t using the gunner. Remove it from the init and its sole use.

class Gunner:
	...
        optimizer = ShotOptimizer(delta_position, delta_velocity, initial_offset)
    ...

class ShotOptimizer:
    def __init__(self, delta_position, delta_velocity, initial_offset):
        self._delta_position = delta_position
        self._delta_velocity = delta_velocity
        self._initial_offset = initial_offset

So that’s mostly done. However, now we can move away from that slightly funky two-element return. It’s considered Pythonic but here I think we can do better.

We’ll just code what we want to say (design by intention) and then make it work.

    def create_optimal_missile(self, fleets, saucer, ship):
        saucer_position = saucer.position
        closest_shot_position = self.closest_aiming_point(saucer_position, ship.position, u.SCREEN_SIZE)
        delta_position = closest_shot_position - saucer_position
        delta_velocity = ship.velocity  # we treat saucer as not moving
        initial_offset = 2*self._radius

        optimizer = ShotOptimizer(delta_position, delta_velocity, initial_offset)
        aim_time = optimizer.aim_time
        adjustment_ratio = optimizer.adjustment_ratio

        target_position = closest_shot_position + delta_velocity * aim_time
        self.create_adjusted_missile(adjustment_ratio, target_position, saucer_position, fleets)

Now to make that work, let’s do this:

class ShotOptimizer:
    def __init__(self, delta_position, delta_velocity, initial_offset):
        self.aim_time, self.adjustment_ratio = self.optimal_shot(delta_position, delta_velocity, initial_offset)

    def optimal_shot(self, delta_position, delta_velocity, initial_offset):
        aim_time = self.time_to_target(delta_position, delta_velocity)
        adjustment_ratio = self.velocity_adjustment(aim_time, initial_offset)
        return aim_time, adjustment_ratio

Right in __init__, we call our optimal_shot method and save the members for our user to fetch them. The old optimize method just goes away, we do all the work in __init__.Nice.

Commit: Gunner now uses ShotOptimizer to, well, optimize a shot.

Works a treat, it does. Now let’s clean up the leftovers in Gunner. We need to redirect some tests.

The tests work fine if I just plug in ShotOptimizer. Perfect. Nothing to see there.

Commit: remove unused optimizer methods from Gunner.

So this is definitely good. Gunner is simpler and ShotOptimizer is pretty nice as well.

Now let’s look at this, in Gunner:

    def create_optimal_missile(self, fleets, saucer, ship):
        saucer_position = saucer.position
        closest_shot_position = self.closest_aiming_point(saucer_position, ship.position, u.SCREEN_SIZE)
        delta_position = closest_shot_position - saucer_position
        delta_velocity = ship.velocity  # we treat saucer as not moving
        initial_offset = 2*self._radius
        optimizer = ShotOptimizer(delta_position, delta_velocity, initial_offset)
        aim_time = optimizer.aim_time
        adjustment_ratio = optimizer.adjustment_ratio
        target_position = closest_shot_position + delta_velocity * aim_time
        self.create_adjusted_missile(adjustment_ratio, target_position, saucer_position, fleets)

I think we can improve this with a bit of renaming, and maybe even some inlining. Let’s try it.

    def create_optimal_missile(self, fleets, saucer, ship):
        saucer_position = saucer.position
        best_target_position = self.closest_aiming_point(saucer_position, ship.position, u.SCREEN_SIZE)
        delta_position = best_target_position - saucer_position
        delta_velocity = ship.velocity  # we treat saucer as not moving
        missile_head_start = 2*self._radius
        optimizer = ShotOptimizer(delta_position, delta_velocity, missile_head_start)
        future_target_position = best_target_position + delta_velocity * optimizer.aim_time
        self.create_adjusted_missile(optimizer.adjustment_ratio, future_target_position, saucer_position, fleets)

I think I prefer that, though I do believe it could be better. We’ll let it sit and think about it again.

Commit: tidying.

If we look at these two methods together we see an anomaly: we are computing 2*self._radius twice:

    def create_optimal_missile(self, fleets, saucer, ship):
        saucer_position = saucer.position
        best_target_position = self.closest_aiming_point(saucer_position, ship.position, u.SCREEN_SIZE)
        delta_position = best_target_position - saucer_position
        delta_velocity = ship.velocity  # we treat saucer as not moving
        missile_head_start = 2*self._radius
        optimizer = ShotOptimizer(delta_position, delta_velocity, missile_head_start)
        future_target_position = best_target_position + delta_velocity * optimizer.aim_time
        self.create_adjusted_missile(optimizer.adjustment_ratio, future_target_position, saucer_position, fleets)

    def create_adjusted_missile(self, velocity_adjustment, target_position, saucer_position, fleets):
        vector_to_target = target_position - saucer_position
        direction_to_target = vector_to_target.normalize()
        missile_velocity = u.MISSILE_SPEED * direction_to_target
        adjusted_velocity = missile_velocity * velocity_adjustment
        offset = 2 * self._radius * direction_to_target
        missile = Missile.from_saucer(saucer_position + offset, adjusted_velocity)
        fleets.append(missile)

Better to pass the offset in, since we have defined it in the top method.

A first attempt at that breaks things. Not sure what I did wrong, roll back. Try again more slowly.

    def create_optimal_missile(self, fleets, saucer, ship):
        saucer_position = saucer.position
        best_target_position = self.closest_aiming_point(saucer_position, ship.position, u.SCREEN_SIZE)
        delta_position = best_target_position - saucer_position
        delta_velocity = ship.velocity  # we treat saucer as not moving
        missile_head_start = 2*self._radius
        optimizer = ShotOptimizer(delta_position, delta_velocity, missile_head_start)
        future_target_position = best_target_position + delta_velocity * optimizer.aim_time
        self.create_adjusted_missile(
        	missile_head_start, 
        	optimizer.adjustment_ratio, 
        	future_target_position, 
        	saucer_position,
        	fleets)

    @staticmethod
    def create_adjusted_missile(missile_head_start, velocity_adjustment, target_position, saucer_position,
                                fleets):
        vector_to_target = target_position - saucer_position
        direction_to_target = vector_to_target.normalize()
        missile_velocity = u.MISSILE_SPEED * direction_to_target
        adjusted_velocity = missile_velocity * velocity_adjustment
        offset = missile_head_start * direction_to_target
        missile = Missile.from_saucer(saucer_position + offset, adjusted_velocity)
        fleets.append(missile)

    def create_unoptimized_missile(self, from_position, to_position, fleets):
        self.create_adjusted_missile(
        	2*self._radius, 
        	1, 
        	to_position, 
        	from_position, 
        	fleets)

We’re green as Kermit. Commit: tidying, remove reference to radius.

I think those two methods are still too large at 8 and 7 lines long (wow, huge!), but I don’t quite see what to do with them just now. We’ll let them steep a bit and see what we come up with another time.

Summary

A second object has been hinting at being born for a long time, inside Gunner. Today it came out of the egg as ShotOptimizer, which removes all the basic arithmetic from Gunner, leaving Gunner to manage the objects and ShotOptimizer the math. It seems like a rather decent split of responsibility.

Could we have done this split sooner, and if so, would it have helped? Don’t know, don’t care. I doubt that one path would have been much shorter than the other, and even so, this is the path we found and we like where we are.

Small steps that (seem to) move us toward better. And sure enough, our code gets better.

Let me emphasize once again that these changes may not be ones we would trouble ourselves to make if all we were doing here was making Asteroids work. It has been working all along. We are exploring ways of making any existing code better, using small steps, built-in refactoring tools, decent tests, and such brain power as we can muster.

And, as so often happens, when we slowly improve the code, the code slowly improves. Small, safe steps, no risky big moves. And we like that!

See you next time!