My old friend Ken Pugh has been showing off a very impressive simulator for the game of pool that he’s been working on, at our weekly Tuesday night “Friday Geek’s Night Out” Zoom ensemble. It’s really quite impressive … and we’ve been leaning on him um objecting strongly um suggesting um thinking that the design is a bit too procedural for our tastes.

In particular, Ken has multiple rail classes, each embodying the special coordinates of a given rail and its logic for detecting collisions. It seemed to me that perhaps it could all be done with a single Rail class. This article covers my attempt to demonstrate that possibility.

I’ve been resisting doing anything with the problem for weeks now, because what he has is far more than I would want to do, and I don’t believe for a moment that I could do something that would stand up in comparison. What a childish fear to have, you’re right. Anyway, I do have a small idea that I want to work out here.

This will require a new PyCharm project. Stand by while I work out how to do one.

Warning

A first attempt fails and I start over. I’ll chop most of it out of the article.

OK, I have a new project with pytest in it. I’ve got two tests, have the tests pinned, and am nearly ready to start working. Here’s what I’ve got so far:

# some tests
from pygame import Vector2


def test_print_hi():
    assert 2 == 2


def test_ball_moves():
    ball = Ball()
    ball.velocity = Vector2(60, 60)
    ball.move(1/60)
    assert ball.position == (1, 1)


class Ball:
    def __init__(self):
        self.position = Vector2(0,0)
        self.velocity = Vector2(0,0)

    def move(self, delta_time):
        self.position += self.velocity*delta_time

These initial tests run. Now let’s talk about my theory for how this will all work. (And then we’ll skip to my second attempt.)

The Idea

The issue that I’m working on is that I’d like to have a single “Rail” class that can serve as any or all of the rails in game of billiards or pool. The function of a rail is for balls to bounce off of it. My mission is to have a single class, Rail, that can handle rails on all four sides of the table.

The essential idea will be that the ball is moving incrementally, according to its velocity. As it moves, we’ll check its collisions with each rail that we have. The basic ideas here are those that Ken has worked out, with my particular twist.

The ball is colliding with a rail if its position is within the length of the rail, and its distance from the rail is less than or equal to the radius of the ball. That is, it is right up against the rail or a little ways into it. We’ll ensure that the steps are small enough to make this OK.

Now for the ball to bounce, it will have to be moving toward the rail. Otherwise it must already be bouncing off.

We’ll have four rails, head, foot, left, and right. Foot will be at the bottom of our imaginary picture, head at the top, and you’ll be able to guess the rest. Head and foot are the short rails. We’ll have x and y axes and x will be horizontal, parallel to head and foot, and y will be vertical, parallel to left and right.

In “real” billiards, the table would be 5 feet by 10 feet, 60 by 120 inches. The balls are approximately 2 3/8 inches in diameter, or 1 3/16 radius. On our table, for purposes of examples, let’s suppose that x goes from -30 to 30, y from -60 to 60, table origin in the center of the table. We’ll just call the ball’s radius r, because it’s too hard to think about and type 1 3/16.

Now let’s consider a ball that may bouncing off the foot rail.

  1. -30 <= ball.position.x <= 30 (this is always true)
  2. ball.position.y <= -60+r (touching the rail)
  3. ball.velocity.y < 0 (heading into the rail)

We can generalize this a bit more by supposing that the rail has some known names for its length and coordinates, but let’s leave that for the code.

Now an essential notion of my design is that the other rails are like the foot rail, except rotated (and perhaps adjusted in length). If we rotate the foot rail clockwise 90 degrees and double its length, it’ll serve as the left rail. If we rotate it counter-clockwise 90, it can be the right rail. And rotated 180 degrees it can be the head rail.

And when we rotate the rail, you’d think we’d have to change its rules so instead of checking ball position.x it’d check y, and instead of velocity y, check x and instead of positive, check negative. But no. If we rotate the input in a way that corresponds to our rail’s rotation, the same code should work for any rail position and size.

Chop

At this point, I’ll skip to the second time through. The first time was close, but I was juggling too many x’s and y’s, and I think I had signs messed up … although there is another significant possibility: I may have just had the graphics code messed up. After all, all my tests were running.

Even so, the second time is better.

I think we should express things in positive terms. So we’ll assume that all the rails will be at nominally positive positions, and that, therefore, the ball should always be at a coordinate less than that position relative to it. From the viewpoint of a rail, the ball is always “below” the rail. We could have picked another convention. That’s the one I’ve picked.

To help keep things straight, let’s make some class methods to return specific rails while we’re at it.

Having possibly taken too big a bite last time, I decide to break up the logic into a few separate bits:

  1. rail.too_far(ball), true if the ball is too far from the rail to be colliding;
  2. rail.ball_is_approaching(ball), true if the ball is moving toward the rail.
  3. bounce, which will reverse the “y” coordinate, causing the ball to bounce off the rail. I say “y”, but if it’s a side rail, the rotation of the rail will make the change refer to “x”.

Here are some new tests, just testing too_far:

def test_balls_too_far_from_foot():
    rail = Rail.foot(600)
    assert rail.too_far(Ball(0, 0))
    assert not rail.too_far(Ball(0, -599))


def test_balls_too_far_from_head():
    rail = Rail.head(600)
    assert rail.too_far(Ball(0, 0))
    assert rail.too_far(Ball(0, -599))
    assert not rail.too_far((Ball(0, 599)))


def test_balls_too_far_from_right():
    rail = Rail.right(300)
    assert rail.too_far(Ball(0, 0))
    assert rail.too_far(Ball(-299, 0))
    assert not rail.too_far((Ball(299, 0)))


def test_balls_too_far_from_left():
    rail = Rail.left(300)
    assert rail.too_far(Ball(0, 0))
    assert rail.too_far(Ball(299, 0))
    assert not rail.too_far((Ball(-299, 0)))

You might notice here that I’m just providing the distance that the rail is from center, and using class methods to create an appropriate Rail. Note, though, that if it’s the foot rail, you’re close if your “y” is near -600. If it’s the left rail, you’re close if your “x” is near -300. And so on. So the ball uses table coordinates, and the rails deal with their own personal issues privately.

Here’s the code so far:

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

    @classmethod
    def head(cls, dist):
        return Rail(0, dist, 0)

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

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

    def __init__(self, x, y, angle):
        self.position = Vector2(x,y)
        self.angle = angle
        names = {0: "foot", 180: "head", 90: "left", -90: "right"}
        self.name = names[angle]

    def too_far(self, ball):
        pos = ball.position.rotate(self.angle)
        y_distance = self.position.y - pos.y
        if y_distance > ball.radius:
            return True
        else:
            return False

Despite the fact that I started testing with the foot rail, you might notice that it’s the head rail whose rotation is zero, and the foot is 180. That’s because I decided that the ball’s coordinates should be less than the rail’s, from the viewpoint of the rail. Rails think they are above everything else and they rotate their view to make it so.

The tests are green. We can improve that last method:

    def too_far(self, ball):
        pos = ball.position.rotate(self.angle)
        y_distance = self.position.y - pos.y
        return y_distance > ball.radius

Green. Commit: reworking Rail entirely.

Now let’s test a method “approaching”, similarly to the way we did too_far.

def test_ball_approaching_head():
    rail = Rail.head(600)
    ball = Ball(0, 0, 0, 1)
    assert rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, 0)
    assert not rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, -1)
    assert not rail.ball_is_approaching(ball)

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

Note that for convenience, I’ve caused the Ball constructor to accept, x, y, vx, vy, where x and y are the position coordinates, and vx and vy are the velocity coordinates.

class Ball:
    def __init__(self, x, y, vx=0, vy=0):
        self.radius = 2
        self.position = Vector2(x, y)
        self.velocity = Vector2(vx, vy)

The velocities provided will depend on the particular rail we’re checking. Since head is up, increasing y, the y velocity should be greater than zero if we’re approaching it. And, as we’ll see, similarly throughout.

We should be able to do similar tests for the other rails, adjusting the velocity.

def test_ball_approaching_foot():
    rail = Rail.foot(600)
    ball = Ball(0, 0, 0, -1)
    assert rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, 0)
    assert not rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, 1)
    assert not rail.ball_is_approaching(ball)

Here, approaching the foot, y velocity must be negative, not positive. But remember … the foot rail rotates the velocity through 180 degrees, so it sees a positive velocity when the ball thinks its velocity is negative in y.

I think it helps if you don’t think about this too much. Just enough.

That passes. Two more:

def test_ball_approaching_left():
    rail = Rail.left(300)
    ball = Ball(0, 0, -1, 0)
    assert rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, 1)
    assert not rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, -1)
    assert not rail.ball_is_approaching(ball)


def test_ball_approaching_right():
    rail = Rail.right(300)
    ball = Ball(0, 0, 1, 0)
    assert rail.ball_is_approaching(ball)
    ball = Ball(0, 0, -1, 1)
    assert not rail.ball_is_approaching(ball)
    ball = Ball(0, 0, -1, -1)
    assert not rail.ball_is_approaching(ball)

On the left, x velocity must be negative if we’re approaching. On the right, it must be positive.

I wonder if those tests can be made more clear. I’ll try … later.

Now I can do colliding and bounce. I don’t write tests for them yet, because they seem trivial to me:

class Rail ...
    def colliding(self, ball):
        return not self.too_far(ball) and self.ball_is_approaching(ball)

    def bounce(self, ball):
        if self.colliding(ball):
            vel = ball.velocity.rotate(self.angle)
            vel.y = -vel.y
            ball.velocity = vel.rotate(-self.angle)

We could and probably should invert too_far to close_enough, but I’ll leave that for another time.

Now I want to turn back to my drawing code, which seems to have its own issues. Here’s my main_loop:

def main_loop():
    width = 300
    height = 600
    ball = Ball(0, 0, 3, 4)
    foot = Rail.foot(height)
    head = Rail.head(height)
    left = Rail.left(width)
    right = Rail.right(width)
    pygame.init()
    screen = pygame.display.set_mode((width, height))
    pygame.display.set_caption("Billiards")
    clock = pygame.time.Clock()
    running = True
    delta_time = 0

    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        ball.move(delta_time)
        left.bounce(ball)
        right.bounce(ball)
        head.bounce(ball)
        foot.bounce(ball)
        ball_pos = (5*ball.position.x + width/2, 5*ball.position.y + height/2)
        pygame.draw.circle(screen, "white", ball_pos, 20)
        pygame.display.flip()
        delta_time = clock.tick(60) / 1000
    pygame.quit()

Two things happen that I don’t like. Here’s a picture after the program runs a while:

smearing

The ball is smearing … and it isn’t bouncing off the rail.

I could imagine that we might have to clear the screen before drawing, but the asteroids program doesn’t seem to do that.

Ha! The bouncing is a matter of me being confused. The screen width needs to be twice the coordinates of left and right, and similarly for height. So:

def main_loop():
    width = 150
    height = 300
    ball = Ball(0, 0, 30, 40)
    foot = Rail.foot(height)
    head = Rail.head(height)
    left = Rail.left(width)
    right = Rail.right(width)
    pygame.init()
    screen = pygame.display.set_mode((2*width, 2*height))  # <====
    pygame.display.set_caption("Billiards")
    clock = pygame.time.Clock()
    running = True
    delta_time = 0

    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        ball.move(delta_time)
        left.bounce(ball)
        right.bounce(ball)
        head.bounce(ball)
        foot.bounce(ball)
        ball_pos = (ball.position.x + width, ball.position.y + height)  # <===
        pygame.draw.circle(screen, "white", ball_pos, 4)
        pygame.display.flip()
        delta_time = clock.tick(60) / 1000
    pygame.quit()

This gives us a trace that does bounce off the walls:

good bounce, still smearing

I have a theory about the smearing. Maybe the flip only works if it sees a blit. Let’s change the game to blit the ball to the screen.

That doesn’t help, but filling the screen with black works.There is a fill hiding in Asteroids, too: I just didn’t see it. Here’s my loop now:

def main_loop():
    width = 150
    height = 300
    ball = Ball(0, 0, 100, 120)
    foot = Rail.foot(height)
    head = Rail.head(height)
    left = Rail.left(width)
    right = Rail.right(width)
    pygame.init()
    screen = pygame.display.set_mode((2*width, 2*height))  # <====
    pygame.display.set_caption("Billiards")
    ball_surface = pygame.Surface((20, 20))
    ball_surface.set_colorkey((0, 0, 0))
    pygame.draw.circle(ball_surface, "white", (10, 10), 10)
    clock = pygame.time.Clock()
    running = True
    delta_time = 0

    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        ball.move(delta_time)
        left.bounce(ball)
        right.bounce(ball)
        head.bounce(ball)
        foot.bounce(ball)
        ball_pos = (ball.position.x + width, ball.position.y + height)  # <===
        screen.fill((0, 0, 0))
        rect = ball_surface.get_rect(center=ball_pos)
        screen.blit(ball_surface, rect)
        pygame.display.flip()
        delta_time = clock.tick(60) / 1000
    pygame.quit()

And the ball bounces. It does seem to reach a bit into the walls, but I think that’s a function of tuning the radius.

ball bouncing

Summary

The whole point of this exercise was to demonstrate a principle: what appears to be similar but different behavior of the rail-ball collision, where you have to check x velocity on the side rails, negative on the left, positive on the right, and you have to check y velocity on the head and foot, positive for the head and negative for the foot …

… and you have to check closeness with a negative or positive value of vx or vy, depending on which rail it is …

… can all be done in one simple class!

If we figure out one rail, and then rotate the ball’s position and velocity according to the rotation of the actual rail, we can collapse all the code down to one very simple case.

I think we’ve accomplished that. QED. I’ll commit this and take a look tomorrow to see what can be improved before the Zoom meeting

Here’s all the code:



Main

# MAIN: Simple ball reflection.

import pygame
from Ball import Ball
from Rail import Rail


def main_loop():
    width = 150
    height = 300
    ball = Ball(0, 0, 100, 120)
    foot = Rail.foot(height)
    head = Rail.head(height)
    left = Rail.left(width)
    right = Rail.right(width)
    pygame.init()
    screen = pygame.display.set_mode((2*width, 2*height))  # <====
    pygame.display.set_caption("Billiards")
    ball_surface = pygame.Surface((20, 20))
    ball_surface.set_colorkey((0, 0, 0))
    pygame.draw.circle(ball_surface, "white", (10, 10), 10)
    clock = pygame.time.Clock()
    running = True
    delta_time = 0

    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        ball.move(delta_time)
        left.bounce(ball)
        right.bounce(ball)
        head.bounce(ball)
        foot.bounce(ball)
        ball_pos = (ball.position.x + width, ball.position.y + height)  # <===
        screen.fill((0, 0, 0))
        rect = ball_surface.get_rect(center=ball_pos)
        screen.blit(ball_surface, rect)
        pygame.display.flip()
        delta_time = clock.tick(60) / 1000
    pygame.quit()


if __name__ == "__main__":
    main_loop()

Tests

# TESTS

from pygame import Vector2
from Ball import Ball
from Rail import Rail


def test_ball_moves():
    ball = Ball(0, 0)
    ball.velocity = Vector2(60, 60)
    ball.move(1/60)
    assert ball.position == (1, 1)


def test_rotate_velocity_180():
    velocity = Vector2(0, 1)
    rotated = velocity.rotate(180)
    assert rotated.x == 0
    assert rotated.y == -1


def test_rotate_position_180():
    position = Vector2(0, -59)
    rotated = position.rotate(180)
    assert rotated.x == 0
    assert rotated.y == 59


def test_balls_too_far_from_foot():
    rail = Rail.foot(600)
    assert rail.too_far(Ball(0, 0))
    assert not rail.too_far(Ball(0, -599))


def test_balls_too_far_from_head():
    rail = Rail.head(600)
    assert rail.too_far(Ball(0, 0))
    assert rail.too_far(Ball(0, -599))
    assert not rail.too_far((Ball(0, 599)))


def test_balls_too_far_from_right():
    rail = Rail.right(300)
    assert rail.too_far(Ball(0, 0))
    assert rail.too_far(Ball(-299, 0))
    assert not rail.too_far((Ball(299, 0)))


def test_balls_too_far_from_left():
    rail = Rail.left(300)
    assert rail.too_far(Ball(0, 0))
    assert rail.too_far(Ball(299, 0))
    assert not rail.too_far((Ball(-299, 0)))


def test_ball_approaching_head():
    rail = Rail.head(600)
    ball = Ball(0, 0, 0, 1)
    assert rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, 0)
    assert not rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, -1)
    assert not rail.ball_is_approaching(ball)


def test_ball_approaching_foot():
    rail = Rail.foot(600)
    ball = Ball(0, 0, 0, -1)
    assert rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, 0)
    assert not rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, 1)
    assert not rail.ball_is_approaching(ball)


def test_ball_approaching_left():
    rail = Rail.left(300)
    ball = Ball(0, 0, -1, 0)
    assert rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, 1)
    assert not rail.ball_is_approaching(ball)
    ball = Ball(0, 0, 1, -1)
    assert not rail.ball_is_approaching(ball)


def test_ball_approaching_right():
    rail = Rail.right(300)
    ball = Ball(0, 0, 1, 0)
    assert rail.ball_is_approaching(ball)
    ball = Ball(0, 0, -1, 1)
    assert not rail.ball_is_approaching(ball)
    ball = Ball(0, 0, -1, -1)
    assert not rail.ball_is_approaching(ball)

Ball

# Ball

from pygame import Vector2


class Ball:
    def __init__(self, x, y, vx=0, vy=0):
        self.radius = 2
        self.position = Vector2(x, y)
        self.velocity = Vector2(vx, vy)

    def move(self, delta_time):
        self.position += self.velocity*delta_time

Rail (ta da!)

# Rail

from pygame import Vector2


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

    @classmethod
    def head(cls, dist):
        return Rail(0, dist, 0)

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

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

    def __init__(self, x, y, angle):
        self.position = Vector2(x,y)
        self.angle = angle
        names = {0: "foot", 180: "head", 90: "left", -90: "right"}
        self.name = names[angle]

    def too_far(self, ball):
        pos = ball.position.rotate(self.angle)
        y_distance = self.position.y - pos.y
        return y_distance > ball.radius

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

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

    def bounce(self, ball):
        if self.colliding(ball):
            vel = ball.velocity.rotate(self.angle)
            vel.y = -vel.y
            ball.velocity = vel.rotate(-self.angle)