Python Asteroids on GitHub

I’ll do a small change, then see what comes to mind. As long as we keep moving in roughly the right direction, we’re good. Thoughts on small steps.

The Collider object’s action looks like this:

class Collider:
    def check_collisions(self):
        for pair in itertools.combinations(self.fleets.colliding_fleets, 2):
            self.check_individual_collisions(pair[0], pair[1])
        return self.score

    def check_individual_collisions(self, attackers, targets):
        for target in targets:
            for attacker in attackers:
                if self.mutual_destruction(target, targets, attacker, attackers):
                    break

    def mutual_destruction(self, target, targets, attacker, attackers):
        attacker.interact_with(target, attackers, self.fleets)
        target.interact_with(attacker, targets, self.fleets)
        if self.within_range(target, attacker):
            self.score += target.score_for_hitting(attacker)
            self.score += attacker.score_for_hitting(target)
            return True
        else:
            return False

These names are not so great. Even the name “Collider” is wrong, it should probably be called Interactor or something Let’s not get that wild yet, but let’s rename the methods.


    def perform_interactions(self):
        for pair in itertools.combinations(self.fleets.colliding_fleets, 2):
            self.perform_individual_interactions(pair[0], pair[1])
        return self.score

    def perform_individual_interactions(self, attackers, targets):
        for target in targets:
            for attacker in attackers:
                if self.interact_one_pair(target, targets, attacker, attackers):
                    break

    def interact_one_pair(self, target, targets, attacker, attackers):
        attacker.interact_with(target, attackers, self.fleets)
        target.interact_with(attacker, targets, self.fleets)
        if self.within_range(target, attacker):
            self.score += target.score_for_hitting(attacker)
            self.score += attacker.score_for_hitting(target)
            return True
        else:
            return False

PyCharm is very helpful with this, I just tell it rename and it does the work. I approve.

I think those are more in the spirit of the design, the objects are interacting, collision is just one of the things that they do.

Commit: rename methods in Collider.

Shall we rename the class as well? Sure, why not? Python might even rename the file for us.

class Interactor:
    def __init__(self, fleets):
        self.fleets = fleets
        self.score = 0

Nice. Commit: rename Collider to Interactor.

Let’s rename the tests and their file. I think I have to do that manually but we’ll see.

Done. I had to rename the file. I call them things like test_interactions.py and the class is named TestInteractions and PyCharm doesn’t make the connection. Woe is me, I had to rename the file with my own fingers.

Commit: Rename class Collider to Interactor, rename tests to match.

Not a great commit name but it’ll do. Should have said “Renamed”.

Now what?

One possibility comes to mind from looking at the Interactor. Our proposed protocol is that objects will be sent a message before interaction begins and another after it is over, so that they can do things that are timing dependent or that involve assessing the result of all their interactions.

It occurs to me that there is probably no need for both tick and, say, prepare_for_interaction, just one message before and one after.

For now, though, let’s provide for both here and see about folding in tick later.

Right now, Fleets does not provide all the objects to Collider:

class Fleets:
    @property
    def colliding_fleets(self):
        return self.asteroids, self.missiles, self.saucers, self.saucer_missiles, self.ships

Fleets has one hidden fleet now, the explosions, which consists of a Fleet of Fragments. They really don’t interact with any other objects, so there is, at present, no reason to include them in the mix. But … our design idea suggests that all objects interact with all others, so there’s something to think about there. It’s just an optimization to cause them not to be included.

Let me try returning them as part of colliding_fleets just to see what happens. I expect nothing of interest to happen, but I could be wrong.

And in fact I am wrong:

  File "/Users/ron/PycharmProjects/firstGo/interactor.py", line 18, in perform_individual_interactions
    if self.interact_one_pair(target, targets, attacker, attackers):
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ron/PycharmProjects/firstGo/interactor.py", line 24, in interact_one_pair
    if self.within_range(target, attacker):
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ron/PycharmProjects/firstGo/interactor.py", line 33, in within_range
    in_range = target.radius + attacker.radius
               ^^^^^^^^^^^^^
AttributeError: 'Fragment' object has no attribute 'radius'

The new interaction logic works just fine, but Interactor uses its own method, within_range:

    @staticmethod
    def within_range(target, attacker):
        in_range = target.radius + attacker.radius
        dist = target.position.distance_to(attacker.position)
        return dist <= in_range

We have the new method, are_we_colliding. Can we use that here and have things work? Not really … we don’t know which one is the one with a radius. It’ll work inside but not outside. Let me try another angle here.

    def interact_one_pair(self, target, targets, attacker, attackers):
        attacker.interact_with(target, attackers, self.fleets)
        target.interact_with(attacker, targets, self.fleets)
        if False and self.within_range(target, attacker):
            self.score += target.score_for_hitting(attacker)
            self.score += attacker.score_for_hitting(target)
            return True
        else:
            return False

That change should break scoring but everything else should be OK. Let’s run and find out what else may break.

Everything works just fine, just no scoring. I’ll roll that spike back and we’ll assess the learning.

Thinking …

My full plan for the decentralized version calls for a Score object, which we are now creating, and a ScoreKeeper that lurks in space, collects up the Score values, and gets drawn, thereby showing the accumulated score, all without the game knowing anything like that is happening.

As of now, Score objects, at least for Asteroid, are being created and passed in to Fleets. Could Fleets accumulate the score and display it?

How do we display score now? Game fetches it from Interactor:

class Game:
    def process_collisions(self):
        collider = Interactor(self.fleets)
        self.score += collider.perform_interactions()

We need a little renaming here, don’t we? Let’s do it.

class Game:
    def process_interactions(self):
        interactor = Interactor(self.fleets)
        self.score += interactor.perform_interactions()

Commit: rename to interactor terminology.

OK, we ask the Interactor for score. Presently, it saves it. Instead, we could save it in Fleets and it could ask Fleets for it. Like this:

class Fleets:
    def add_score(self, score):
        self.score += score

I’ve initialized that to 0 in __init__, of course. And now …

We change this:

class Interactor:
    def __init__(self, fleets):
        self.fleets = fleets
        self.score = 0

To this:

class Interactor:
    def __init__(self, fleets):
        self.fleets = fleets

    @property
    def score(self):
        return self.fleets.score

And this:

    def interact_one_pair(self, target, targets, attacker, attackers):
        attacker.interact_with(target, attackers, self.fleets)
        target.interact_with(attacker, targets, self.fleets)
        if self.within_range(target, attacker):
            self.score += target.score_for_hitting(attacker)
            self.score += attacker.score_for_hitting(target)
            return True
        else:
            return False

To this, temporarily:

    def interact_one_pair(self, target, targets, attacker, attackers):
        attacker.interact_with(target, attackers, self.fleets)
        target.interact_with(attacker, targets, self.fleets)
        if self.within_range(target, attacker):
            # self.score += target.score_for_hitting(attacker)
            # self.score += attacker.score_for_hitting(target)
            return True
        else:
            return False

This breaks some tests. Does it break the game? I expect asteroid scores to work and saucer ones not to.

Fleets was wrong, should be:

    def add_score(self, score):
        self.score += score.score

Try again. The score increases without bound. I am not sure why, unless we are reusing the interactor and I thought we were not.

Game does create a new one each time:

class Game:
    def process_interactions(self):
        interactor = Interactor(self.fleets)
        self.score += interactor.perform_interactions()

Ah. Score is kept in self.fleets, which does not get recreated, so score is accumulated properly in there. Also, we could just ask fleets and avoid the indirection. Let’s let that be, but this should be the code:

    def process_interactions(self):
        interactor = Interactor(self.fleets)
        self.score = interactor.perform_interactions()

That works just fine. As I predicted, the asteroids score when I hit them, and the saucer does not when I hit it.

If we complete this, we can simplify Interactor’s logic. But we do have broken tests. Let’s look at those. We might do well to roll back and do this again with a fresh mind.

Those two tests will work when saucer scoring is put into the system, because they are running the collider. Which we should rename. I do the rename and commit the tests.

Now, to go ahead or not to go ahead. Let’s review how Asteroid does it and see if it will translate to Saucer.

class Asteroid:
    def interact_with_missile(self, missile, fleets):
        if missile.are_we_colliding(self.position, self.radius):
            fleets.add_score(Score(self.score_for_hitting(missile)))
            self.split_or_die(fleets)

And in Saucer:

class Saucer:
    def interact_with_missile(self, missile, fleets):
        if missile.are_we_colliding(self.position, self.radius):
            self.explode(fleets)

    def score_for_hitting(self, attacker):
        return attacker.scores_for_hitting_saucer()[self.size - 1]

So we should be able to do just as Asteroid did:

    def interact_with_missile(self, missile, fleets):
        if missile.are_we_colliding(self.position, self.radius):
            fleets.add_score(Score(self.score_for_hitting(missile)))
            self.explode(fleets)

The tests are green. I think the game will work, I just have to manage to kill the darn saucer.

I am getting a double score for the saucer. No, I am mistaken, I was seeing 200 and thought the score should be 100, but it’s 200 for the big saucer. This code is working correctly!

I need a rest from my brain burning on a mistake.

Resting …

A bit rested …

OK, this is working. We’re still using the True/False to break the loop. Let’s remove the old scoring code:

class Interactor:
    def interact_one_pair(self, target, targets, attacker, attackers):
        attacker.interact_with(target, attackers, self.fleets)
        target.interact_with(attacker, targets, self.fleets)
        if self.within_range(target, attacker):
            return True
        else:
            return False

And I think we can improve that a bit:

    def interact_one_pair(self, target, targets, attacker, attackers):
        attacker.interact_with(target, attackers, self.fleets)
        target.interact_with(attacker, targets, self.fleets)
        return self.within_range(target, attacker)

Green. Commit: scoring done by flyers sending Score objects, accumulating score in Fleets class for now.

Now we still can’t start sending the Fragments through until we get rid of the need for the T/F. We’ll explore that next time, I’m fried enough for now. Let’s sum up.

Summary

These changes continue to go in quite smoothly. Intercepting the score in Fleets is a step in the right direction but not all the way. We can unwind that in due time.

Fundamentally, scoring is now done proactively by colliding objects rather than passively by the game asking for the score. So that’s good.

I think part of why this is going well is that the objects are rather well factored, with each one mostly handling its own concerns and delegating concerns to others, such as the way one object asks another whether it is colliding, given the first object’s position and radius.

It’s interesting, though …

I am not so much planning and designing this solution as I am finding small steps to take “in the direction” of the solution. Score wants to be an object in the mix. First I make a call that does nothing. Later I make the call save the value, and wire a connection from Game to use it. Then I can remove the score-collecting lines in Game. And so on.

I have programmed and studied programming for over six decades. Over much of that time, the supposedly-right thing was to completely analyze your problem, design a perfect solution, and then implement it. This never seemed to work, but you always felt like if you could just do it a little bit better, it should work.

Over the almost three decades I’ve been learning the more “Agile” approach, I’ve learned another way. I do analyze up front, though just enough to understand a good chunk of what I need. I do design, just enough to get a sense of something moving in the right direction. And then I program, just enough to get another test to run, just enough to make a step toward what I need.

You’d worry that you might never get “there”, and that you might wander off and do something completely wrong without a detailed plan. But that doesn’t happen. Even a detailed plan to go to the grocery store doesn’t really quite work. You have to wait at the light. Some damn fool wants to turn left and can’t seem to work the road. A school bus makes you stop while it picks up children. The parking lot is being rebuilt and you have to park on the side instead of in front.

In everything we do, we may have an overall goal but we actually work in small steps toward that goal. It should not surprise us that software development works the same way.

What helps it work …

Since the decentralized idea relies on the objects being unaware of other objects until they are told to interact, the independence means that they are already in a mood to send messages to each other on an individual basis.

There’s one aspect of this that is hard to assess. I have written Asteroids at least four or five times prior to this version. How much of what’s good in here is due to having done it so many times, versus how much is due to the general way that I develop things? I don’t know. I know it’s going well, and that the code is making itself easy to change. How it got that way, well, it’s surely all due to me and a little help from my friends, including my past self.

That’s how it is for all of us. Our work today is based on all the work, all the learning, that we’ve done in the past.

Anyway, it’s going nicely and I am pleased. See you next time!