Note

This is under construction (work in progress)!

Modular Physics

One of pooltool’s founding ambitions is completely customizable physics.

Loosely speaking, the physics in pooltool can be simplified into two categories:

  1. Evolution - refers to the physics that governs ball trajectories over time in the absence of interupting collisions.

  2. Resolution - refers to the physics that resolves the outcome of a collision, e.g. a ball-ball collision or ball-cushion collision.

Evolution

Unfortunately, evolution physics are not yet modular. That means the ball trajectories are governed by the equations presented in this blog and no other equations can be easily substituted in without overwriting these equations in the source code.

The equations can still be modified by changing parameters governing the trajectories, like ball mass, radius, and coefficients of friction, but no matter how you change these parameters, it’s still the same underlying model.

Resolution

Pooltool supports modular physics for resolving events. To clarify, events refer to either collisions or transitions. The way in which an event is resolved depends on the strategy you choose to employ.

Below details how you can either (a) plug in models that already exist in the codebase and (b) write your own custom models.

Modify

This section guides you on how to switch between existing models within the codebase.

The handling of events is determined by the resolver configuration file, which is located at ~/.config/pooltool/physics/resolver.yaml. Here’s an example of what that looks like:

ball_ball: frictionless_elastic
ball_ball_params: {}
ball_circular_cushion: han_2005
ball_circular_cushion_params: {}
ball_linear_cushion: han_2005
ball_linear_cushion_params: {}
ball_pocket: canonical
ball_pocket_params: {}
stick_ball: instantaneous_point
stick_ball_params:
  throttle_english: true
transition: canonical
transition_params: {}

The resolver configuration file is automatically generated during the initial execution. If you don’t have one yet, execute pooltool with the command run_pooltool, start a new game, and take a shot--one will be generated.

Models are identified by their names. You can view the names of all available models by executing the following command:

from pooltool.physics.resolve.ball_ball import BallBallModel
from pooltool.physics.resolve.ball_cushion import BallCCushionModel, BallLCushionModel
from pooltool.physics.resolve.ball_pocket import BallPocketModel
from pooltool.physics.resolve.stick_ball import StickBallModel
from pooltool.physics.resolve.transition import BallTransitionModel

print("ball-ball models:")
for model in BallBallModel: print(f"\t{model}")
print("ball-linear cushion models:")
for model in BallLCushionModel: print(f"\t{model}")
print("ball-circular cushion models:")
for model in BallCCushionModel: print(f"\t{model}")
print("stick-ball models:")
for model in StickBallModel: print(f"\t{model}")
print("ball-pocket models:")
for model in BallPocketModel: print(f"\t{model}")
print("ball-transition models:")
for model in BallTransitionModel: print(f"\t{model}")

Each model lives in its own directory. For example, the frictionless_elastic ball-ball collision model is found in pooltool/physics/resolve/ball_ball/frictionless_elastic/__init__.py with the class name FrictionlessElastic. FrictionlessElastic doesn’t take any parameters, so in the resolver config the parameter field is empty:

ball_ball: frictionless_elastic
ball_ball_params: {}

But some models do take parameters: The stick-ball collision model instantaneous_point has a parameter throttle_english, which you can see is an initialization parameter of the class InstantaneousPoint housed in pooltool/physics/resolve/stick_ball/instantaneous_point/__init__.py:

@attrs.define
class InstantaneousPoint(CoreStickBallCollision):
    throttle_english: bool

    def solve(self, cue: Cue, ball: Ball) -> Tuple[Cue, Ball]:
        (...)

To set this parameter to False, modify stick_ball_params:

stick_ball: instantaneous_point
stick_ball_params:
  throttle_english: false

Currently, the easiest way to see which parameters are available for a model is to dig through the source code, specifically in pooltool/physics/resolve/*.

What happens at runtime?

This may be a useful section for you if you want to learn more about how this all works.

Resolver

The cornerstone of event resolution is pooltool.physics.resolve.resolver.Resolver. An instance of Resolver is either passed to or generated by the shot evolution algorithm, and it is this instance that exclusively determines how events are resolved.

This is the structure of Resolver:

class Resolver:
    ball_ball: BallBallCollisionStrategy
    ball_linear_cushion: BallLCushionCollisionStrategy
    ball_circular_cushion: BallCCushionCollisionStrategy
    ball_pocket: BallPocketStrategy
    stick_ball: StickBallCollisionStrategy
    transition: BallTransitionStrategy

Each attribute is a physics strategy (or model) for each event class: ball-ball collisions, ball-linear cushion collisions, ball-circular cushion collisions, ball-pocket “collisions”, and ball motion transitions.

While it’s possible to create a Resolver object by instantiating physics strategies for each event class, this programmatic interface is complex to replicate. A more user-friendly approach would be to serialize the desired physics strategies into a configuration file. This is the function of ResolverConfig. As a dataclass, it supports seamless serialization and deserialization, allowing all information needed to create a Resolver object to be conveniently stored in a YAML file.

The structure of ResolverConfig is as follows:

class ResolverConfig:
    ball_ball: BallBallModel
    ball_ball_params: ModelArgs
    ball_linear_cushion: BallLCushionModel
    ball_linear_cushion_params: ModelArgs
    ball_circular_cushion: BallCCushionModel
    ball_circular_cushion_params: ModelArgs
    ball_pocket: BallPocketModel
    ball_pocket_params: ModelArgs
    stick_ball: StickBallModel
    stick_ball_params: ModelArgs
    transition: BallTransitionModel
    transition_params: ModelArgs

    def save(self, path: Pathish) -> Path:
        # Code to serialize the object to YAML and save it to a file...

    @classmethod
    def load(cls, path: Pathish) -> ResolverConfig:
        # Code to load a YAML file and deserialize it into a ResolverConfig object...

You might observe that this closely resembles the YAML configuration file. That’s because the resolver.yaml file is simply a serialization of this class. When you modify resolver.yaml, a ResolverConfig is created from the resolver.yaml file using ResolverConfig.load(...) during runtime. This configuration is then used to build the Resolver object employed by the shot evolution algorithm, using Resolver.from_config(resolver_config).

Creating new physics models

To demonstrate how you can integrate your own physics model into pooltool, I’ll be incorporating a mock ball-cushion model suitable for both linear and circular segments. I’ll outline the general steps here, and for each step, I’ll include a link to a commit where you can view the specific files I modified. By following these patterns, the process should be quite straightforward.

Ok let’s get started.

Create a directory

First, we need to establish a model within its own dedicated directory. This directory should be named after the model. As I’m using a simple toy example, I’ll name mine unrealistic. The directory should be located in one of the pooltool/physics/resolve/* folders, depending on the event class your model manages. Since I’m constructing a ball-cushion model, I’ll create the unrealistic folder in pooltool/physics/resolve/ball_cushion/.

Within your model directory, create an __init__.py file. If your model is simple, all your model logic can be contained within this single file. However, if your model grows complex, feel free to expand it across multiple files, provided they’re kept within your model directory.

Here’s the example code: 7ded13254150cdebb09013fa35e6fe0846d59ea9

Create the template

Regardless of how you choose to structure your code, it must eventually lead to a class that:

  1. Contains a method named solve.

  2. Inherits from the core model.

The call signature of your solve method and the core model from which you inherit will depend on the event class for which you’re developing a model.

Since I’m developing a ball-cushion model, I’ll refer to pooltool/physics/resolve/ball_cushion/core.py for this information. Below is the required call signature for my solve method:

class BallLCushionCollisionStrategy(_BaseLinearStrategy, Protocol):
    def solve(
        self, ball: Ball, cushion: LinearCushionSegment
    ) -> Tuple[Ball, LinearCushionSegment]:
        ...

It takes a ball and linear cushion, and then returns a ball and linear cushion. Simple enough.

And here is the required core model:

class CoreBallLCushionCollision(ABC):
    """Operations used by every ball-linear cushion collision resolver"""
    (...)

With these, we can create our template:

"""An unrealistic ball-cushion model"""

from typing import Tuple

from pooltool.objects.ball.datatypes import Ball
from pooltool.objects.table.components import LinearCushionSegment
from pooltool.physics.resolve.ball_cushion.core import CoreBallLCushionCollision


class UnrealisticLinear(CoreBallLCushionCollision):
    def solve(
        self, ball: Ball, cushion: LinearCushionSegment
    ) -> Tuple[Ball, LinearCushionSegment]:
        return ball, cushion

Here’s the example code: af507032217914629e53954965c982d21fdc8094

As you can see, resolve currently does nothing, it just returns what is handed to it.

Implement the logic

Note

You may prefer registering and activating your model before you start implementing the logic. Even though your model doesn’t do anything at this point, you may prefer registering and activating it now, so that you can make changes, and immediately see how your implementation affects a test case.

This is where you come in, but there are a few points to make. First, I really like type hints, but I remember a time when I didn’t. If that’s you, don’t worry about them--or any other conventions I follow, for that matter. This is your code, just do your thing and don’t get overwhelmed in my conventions.

Second, since you’ll be working with the core pooltool objects Cue, Ball, LinearCushionSegment, CircularCushionSegment, and Pocket, it is worth scanning their source code to determine what parameters they have, and therefore what tools you have at your disposal.

Anyways, here’s my preliminary implementation: 17510e7d014c8aa5e60d6556db2e5b0dea36f2f0

Then I added a parameter to the model to add some flavor and complexity. Note that the model parameters should not be things like mass or friction coefficients. Those are properties of the passed objects. If you think a property is missing for an object, we can add it to the object. Model parameters are more meta/behavioral (see the below example).

Please note that the resolver config can only handle strings, booleans, floats, and integers for model parameters due to serialization requirements. If you have more complex model types like functions, try and simplify the passed argument to a string by string-lookup dictionary.

Here’s me adding a model parameter that dictates whether or not the outgoing speed should be dampened with the ball’s restitution coefficient: ec42752f381edf3d576a66a9178a27d6054ff437

Register the model

Your model is in the codebase, but no other part of the codebase knows about it yet. Changing that is simple.

Open the __init__.py file corresponding to your event class:

pooltool/physics/resolve/ball_ball/__init__.py
pooltool/physics/resolve/ball_cushion/__init__.py
pooltool/physics/resolve/ball_pocket/__init__.py
pooltool/physics/resolve/stick_ball/__init__.py
pooltool/physics/resolve/transition/__init__.py

You’ll need to modify two objects.

First, is an Enum that holds the collection of all model names for a given event-class. You can find it by searching for a class that inherits from StrEnum. Here is the ball linear cushion Enum:

class BallLCushionModel(StrEnum):
    HAN_2005 = auto()

Add a new element to this Enum that will correspond to your model, like I’ve done here:

class BallLCushionModel(StrEnum):
    HAN_2005 = auto()
    UNREALISTIC = auto()

Technically the name here is arbitrary, but it makes good sense to have it match your model name.

Second, you’ll have to modify a dictionary that associates model names to model classes. It’s in the same file. Mine was this:

_ball_lcushion_models: Dict[BallLCushionModel, Type[BallLCushionCollisionStrategy]] = {
    BallLCushionModel.HAN_2005: Han2005Linear,
}

And I made it this:

_ball_lcushion_models: Dict[BallLCushionModel, Type[BallLCushionCollisionStrategy]] = {
    BallLCushionModel.HAN_2005: Han2005Linear,
    BallLCushionModel.UNREALISTIC: UnrealisticLinear,
}

I needed to import my new model, so I put this at the top of the file:

from pooltool.physics.resolve.ball_cushion.unrealistic import UnrealisticLinear

Here are the full changes: 9c4d9ad2dc6bae3848bfc9973f150b864e07db06

Activate the model

In order to apply your new model using pooltool, you’ll need to modify the resolver configuration file, which is found at ~/.config/pooltool/physics/resolver.yaml. This file is automatically generated upon the first run-time. Therefore, if it’s missing, simply execute the command python sandbox/break.py from the pooltool root directory to generate it.

Next, you need to replace the existing model name with the name of your new model. It’s important to note that the model name should be the lower case version of the Enum attribute. For instance, if my Enum attribute is UNREALISTIC, I would input unrealistic for ball_linear_cushion.

If your model doesn’t require parameters, you should set the _params key to {}. However, if your model does have parameters, you should list them, along with their corresponding values. For example, if my model includes a parameter, I would specify its value as follows:

ball_linear_cushion: unrealistic
ball_linear_cushion_params:
  restitution: true

That’s it. If everything runs without error, your model is active.

Note, there is no git commit to show you here, because ~/.config/pooltool/physics/resolver.yaml isn’t part of the project--it’s part of your personal workspace.

Addendum: ball-cushion models

This is instructions for taking your ball-linear cushion model and creating a corresponding ball-circular cushion model.

The ball-cushion collision has two event classes: collision of the ball with (1) linear cushion segments and (2) circular cushion segments. I developed circular cushion segments to smoothly join two linear cushion segments, e.g. to create the jaws of a pocket.

You could have separate models for circular and linear cushion segment collisions, or if you’re lazy they could mimic each other. In this git commit, I illustrate how you can use the same model for both collision types, assuming you’ve already implemented the linear cushion segment: f4b91e436976fb857bf7681fcb6458c3ae1e6377

If you want to activate the model, don’t forget to modify the resolver config.