If all we wanted was a display with a bouncing ball, we could have done it by just checking coordinates and negating one of the components of velocity, something like this:

``````if x > max_x: dx = -dx
if x < min_x: dx = -dx
if y > max_y: dy = -dy
if y < min_y: dy = -dy
``````

But we had a different mission, to show how, in a pool simulator, to use one Rail class to serve as any of the six rails on a pool table. Let’s look at the simple logic behind how our scheme works. This is like math, but as you’ll see, it’s really pretty simple math.

### Coordinate System

If we imagine a pool table drawn on graph paper, we need to decide the orientation of the table, and where the graph origin goes. I chose to put the long sides of the table up and down, the short sides across:

As for the origin, in one sense it doesn’t matter, because we can always add or subtract a couple of constants if we need to, but looking at the table with a bit of thought you can see that there’s one place that’s different from all the others.

One possibility would be to put the graph origin at the lower left corner of the table. Another would be to put it at the top left corner, with y going down, because PyGame draws things upside down in its wisdom. But no. The place that is both different from all the others and visibly special is the center of the table. With the origin at the center, the distance to the head rail equals the distance to the foot, the distance to the left rail equal the distance to the right. We choose the center of the table, with +x on the right and +y at the top. I’m calling the +y rail “head” and the +x rail “right”

Partly we choose that because I’m used to drawing graphs with y going up. If your preferences differ, that’s fine, but we’re going with mine here. Stand on your head or lie on your side if you prefer another orientation.

### Balls1

We’ll suppose that our ball position will be measured from the center of the ball. Why? Because it’s more symmetric, and a ball’s position shouldn’t depend on how big the ball is. And because that’s how we do things. “Assume a spherical cow of unit mass.”

### Ball hitting the head rail.

Now let’s think about what has to be true for a ball to be colliding with the head rail. It’s not just sitting there: it has to be moving toward the head rail. It has to be moving upward. Its y velocity, its up-down motion must be positive. Its x velocity, the motion across, can be anything.

And if it’s colliding then its distance from the rail has to be equal to its radius. It looks like this:

I’ve written some values on the picture. The ball is at (x,y), and the y coordinate of the head rail is head_y. The radius of the ball is r, and it is moving at velocity v = (vx,vy).

So, for the ball to be colliding, we require:

1. Distance of (x,y) from `head_y` must be `r` or less, so `head_y - y` must be `r` or less;
2. Must be moving toward the rail, so `vy` must be upward, or positive, or greater than zero.2

And that’s the code we originally wrote for the head rail when it was all we had:

``````    def colliding(self, ball):
return self.close_enough(ball) and self.ball_is_approaching(ball)

def close_enough(self, ball):

def distance_to_rail(self, ball):
y_distance = self.position.y - ball.position.y
return y_distance

def ball_is_approaching(self, ball):
velocity = ball.velocity
return velocity.y > 0
``````

If we were to inline all that, it would look like this:

``````    def colliding(self, ball):
return self.position.y - ball.position.y <= ball.radius
and ball.velocity.y > 0
``````

Now if we were to do the same calculation for the other rails, we’d have to change things around, like this:

``````    def head_colliding(self, ball):
return self.position.y - ball.position.y <= ball.radius
and ball.velocity.y > 0

def foot_colliding(self, ball):
return ball.position.y - self.position.y <= ball.radius
and ball.velocity.y < 0

def right_colliding(self, ball):
return self.position.x - ball.position.x <= ball.radius
and ball.velocity.x > 0

def left_colliding(self, ball):
return ball.position.x - self.position.x <= ball.radius
and ball.velocity.x < 0
``````

I think that’s right. I haven’t tested it. It shows the general idea and might even be correct.

If we were to do that, we’d either wind up with some kind of switch statement in Rail class, or four different Rail classes, each specialized as to head foot left right. And we’d rather not do that.

So we look at the picture. Let’s first consider a ball colliding at the foot of the table, since having done head, it’s easiest.

There it is. I’ve drawn a line, a vector, from the origin to the ball. Suppose the ball is at -3 in the x direction and -15 in the y direction (with the foot rail at -16, and the sides at +/- 8).

Suppose that we were to take that vector from the center and rotate it around by 180 degrees:

Where would the ball be now? It’d be at (+3, +15). Which way would it be heading? It would be heading toward the head rail.

So if we were to adjust the ball by rotating it around the center and then ask the head rail if it was colliding, it would say “yes” when it was, and “no” when it wasn’t.

If we rotate the ball’s coordinates and motion by 180 degrees, the head rail logic works for the foot. Ta da, that’s what we want!3

### Side Rails

The side rails are a bit harder to think about, and I’ll share a secret with you: I don’t think about the values much at all. Instead I just think in terms of rotating the position and velocity of the thing. Python has a `rotate` function. And if a given language didn’t have it, I happen to know the formula for it, though I don’t think about it much either. But if I had to implement rotation, the full formula is

``````(x,y):rotate(a) = (x*cos(a) - y*sin(a), y*cos(a) + x*sin(a))
``````

And if I forgot that, as I sometimes do, I’d look it up.

Now it turns out that the sine and cosine of 90 and 180 and 270 are all zero, or plus or minus 1, so when you rotate by those even angles, the coordinates just negate and sometimes swap. As we’ll see when we do a side rail.

One more thing, though: the side rail isn’t as far from center as the head and foot. Pool tables aren’t square. So if we are going to use our head logic to assess the sides, we’ll compare using the side rail’s distance, not that of the head. When we review the Rail code, that’s why when we create our four rails, the side ones get a different distance `dist` than the end ones.

Anyway, here’s a ball approaching the side rail, and a 90 degree rotation to line it up with head. And I’ll draw a pale green line across, showing where the side rail’s distance is.

We see the ball at 7,4. If we rotate 90 degrees it winds up at (-4,7). Remember what I said about negating and swapping? And if we draw the side rail’s distance across the screen, we see that the ball is lined up just as we’d hope to be detected as colliding by our head rail logic.

It turns out that the velocity rotates just the same way, so that if the original velocity was positive in x—colliding—the new rotated velocity will be positive in y, just what the head logic is looking for.

### Other Side Rail

The other side rail works the same way with the swapping and negating a bit different. But the bottom line is this:

To check collision with left, foot, or right, we can instead rotate their values by a suitable angle and use the head logic (with the distance set correctly) to do the work. And that’s why our final version is just like the head-only version, except with rotations of 0, 90, 180, or -90:

``````    def __init__(self, x, y, angle):
self.position = Vector2(x, y)
self.angle = angle

def bounce(self, ball):
if self.colliding(ball):
self.reflect_off_rail(ball)

def colliding(self, ball):
return self.close_enough(ball) and self.ball_is_approaching(ball)

def close_enough(self, ball):

def distance_to_rail(self, ball):
pos = ball.position.rotate(self.angle)
y_distance = self.position.y - pos.y
return y_distance
``````

All we had to do to make this work was create the rails with the right values of distance and angle:

``````class Rail:
@classmethod
def foot(cls, dist):
return cls(0, dist, 180)

@classmethod
return cls(0, dist, 0)

@classmethod
def right(cls, dist):
return cls(0, dist, 90)

@classmethod
def left(cls, dist):
return cls(0, dist, -90)
``````

## Summary

If this is new to you, you may find it a bit twisty in your head. And to be 4, it is totally not new to me, but it is still twisty in my head when I try to think about what happens to (x,y) when I rotate it by -90, and it’s much worse if I have to be using angles that aren’t just multiples of 90.

Alan Kay once said “Point of view is worth 80 IQ points”. And here, the privileged point of view is just to know that rotation works as it does and to go with it. And test it a bit, since if you’re like me, you’ll still get some signs wrong.

In math, there are lots of things like this. Maybe you remember when they had just taught you Cartesian coordinates, the good old x,y ones, and then they tried to teach you polar coordinates, the r-theta ones, and what the heck is theta anyway. You may have had trouble understanding what was going on and the conversion, that sin-cos stuff, was pure magic.

But the thing is, which if they showed us back then I missed it, once you grok the polar coordinates, you have another way of looking at problems that makes some problems easier.

But we won’t go down that path. That way lies things like T*X*T-1 and we just don’t want to go there. We have more than enough math already.

If you have questions, toot me up or tweet me up if I’m still there. Or, do as I mostly do with these things, and take it on faith-with-an-explanation that rotating vectors makes some things easier.

See you next time!

1. … said the Queen. “If I had ‘em, I’d be king.”

2. Any one of these will suffice. Pick your favorite, get your money down before the croupier calls no more bets.

3. Callooh callay, yay us, we’re getting what we want.

4. I’m always trying to be honest. I should have said “to be frank”. I don’t always say everything that I think. I try always to say what I really think. And I don’t want to be Frank. I’m happy being Ron. How did we get down here? Click to go back up.