Simulation Engine

Event-based time-stepping — advancing physics exactly to each collision or state transition, no fixed timestep approximations.

Why event-based simulation?

Fixed-timestep (Euler/RK4) methods accumulate error at each step. Because billiard physics are analytically solvable between events, pooltool finds the exact next event time, jumps there, then applies the collision model — zero error accumulation between events.

The simulation loop

pooltool/evolution/event_based/simulate.py:103

simulate(system) — main loop
0
init — Reset all ball histories, push a null event at t=0. Caches are warm.
1
detectEventDetector.get_next_event() queries all event categories and returns the soonest one (broken by priority tier).
2
evolveevolve_ball_motion() is called for every non-stationary ball, analytically advancing rvw by dt = event.time − system.t.
3
resolveResolver.resolve(event) applies the collision or transition model, updating the agents' BallState.
4
record — Current ball states are appended to ball.history. The event is appended to system.events. Caches are invalidated.
repeat until all balls are stationary or pocketed (or t_final is reached).
simulate() — full signature and parameters
def simulate(
    shot:       System,
    engine:     SimulationEngine | None = None,
    inplace:    bool               = False,
    continuous: bool               = False,
    t_final:    float | None       = None,
    include:    set[EventType]      = INCLUDE_ALL,
) -> System:
ParameterTypeDescription
shotSystemThe system to simulate. Copied unless inplace=True.
engineSimulationEngine | NonePhysics engine bundle. Created with defaults if not given.
inplaceboolMutate shot directly instead of copying. Cheaper for single-use.
continuousboolIf True, call continuize() automatically after simulation.
t_finalfloat | NoneStop simulation early at this time (seconds).
includeset[EventType]Restrict which event types are resolved (others are still detected but skipped).
_SimulationState — internal state machine
# pooltool/evolution/event_based/simulate.py:21
class _SimulationState:
    shot:   System
    engine: SimulationEngine

    def init(self):                             # line 38
        # Reset all ball histories
        # Record initial null event at t=0
        ...

    def step(self) -> Event:                    # line 42
        event = self.engine.detector.get_next_event(self.shot)
        self._evolve(self.shot, event.time - self.shot.t)
        self.engine.resolver.resolve(event, self.shot)
        self.shot._update_history(event)
        self.update_caches()
        return event

    @staticmethod
    def evolve(shot: System, dt: float):        # line 80
        for ball in shot.balls.values():
            if ball.state.s != stationary and ball.state.s != pocketed:
                ball.state.rvw, ball.state.s = evolve_ball_motion(
                    state=ball.state.s, rvw=ball.state.rvw,
                    R=ball.params.R, m=ball.params.m,
                    u_s=ball.params.u_s, u_sp=ball.params.u_sp,
                    u_r=ball.params.u_r, g=g, t=dt,
                )

SimulationEngine

pooltool/evolution/engine.py:12

SimulationEngine — bundling detector and resolver
# pooltool/evolution/engine.py:12
@dataclass
class SimulationEngine:
    detector: EventDetector   # finds the next event
    resolver: Resolver        # applies the physics model
    is_3d:    bool           # enable airborne-ball support

# Default engine (used when engine=None in simulate()):
engine = SimulationEngine.default()
# Equivalent to:
engine = SimulationEngine(
    detector=EventDetector(is_3d=False),
    resolver=default_resolver(),
    is_3d=False,
)

The engine is designed to be swapped. You can pass a custom engine to simulate() to use different collision models (e.g. Mathavan instead of FrictionalInelastic for ball-ball).

evolve_ball_motion() — analytical motion update

pooltool/physics/evolve/__init__.py:28

This Numba-compiled function takes the current rvw and motion state, advances them analytically by time t, and returns the new (rvw, state). No numerical integration — the equations have closed-form solutions.

evolve_ball_motion() — dispatch by motion state
@nb.jit(nopython=True, cache=True)
def evolve_ball_motion(state, rvw, R, m, u_s, u_sp, u_r, g, t):
    # pooltool/physics/evolve/__init__.py:28
    if state == stationary or state == pocketed:
        return rvw, state

    if state == sliding:
        return _evolve_slide_state(rvw, R, m, u_s, u_sp, g, t)  # line 88

    if state == rolling:
        return _evolve_roll_state(rvw, R, m, u_r, u_sp, g, t)   # line 133

    if state == spinning:
        return _evolve_perpendicular_spin_state(rvw, R, m, u_sp, g, t)  # line 184

    if state == airborne:
        return _evolve_airborne_state(rvw, g, t)                 # line 192
Sliding state — kinetic friction decelerates ball
# pooltool/physics/evolve/__init__.py:88
def _evolve_slide_state(rvw, R, m, u_s, u_sp, g, t):
    # Sliding: surface velocity ≠ ball velocity
    # Kinetic (sliding) friction decelerates the center-of-mass
    # and also applies a torque changing angular velocity.
    #
    # v(t) = v0 + a_slide * t           (linear)
    # w(t) = w0 + alpha_slide * t        (angular)
    #
    # Transition to ROLLING when |v - R×w| → 0
    ...
Rolling state — pure rolling, slower deceleration
# pooltool/physics/evolve/__init__.py:133
def _evolve_roll_state(rvw, R, m, u_r, u_sp, g, t):
    # Rolling: no slip between ball and cloth
    # Only rolling friction (u_r ≪ u_s) slows the ball.
    # The spin vector stays aligned with velocity.
    #
    # v(t) = v0 - u_r * g * t            (decelerates)
    # w(t) = v(t) / R                    (no-slip constraint)
    #
    # Transition to SPINNING when forward speed → 0 but spin remains,
    # or to STATIONARY when both are zero.
    ...
Spinning state — residual vertical spin decays
# pooltool/physics/evolve/__init__.py:184
def _evolve_perpendicular_spin_state(rvw, R, m, u_sp, g, t):
    # Ball has zero linear velocity but non-zero angular velocity
    # around the vertical axis (e.g. from stun or massé shot).
    # Spinning friction (u_sp) decays it to zero.
    ...
State transition timeline — single ball example
t=0.000s  STICK_BALL   → ball enters SLIDING  (high slip, both v and w change)
t=0.012s  SLIDING→ROLLING  → slip ratio = 0, pure roll begins
t=0.290s  BALL_LINEAR_CUSHION → cushion collision, ball re-enters SLIDING
t=0.308s  SLIDING→ROLLING
t=1.100s  ROLLING→STATIONARY → ball stops
After each collision the ball usually re-enters SLIDING because the bounce changes the velocity/spin ratio.

Event caching

pooltool/evolution/event_based/cache.py

TransitionCache — avoid recomputing transition times
# pooltool/evolution/event_based/cache.py:30
#
# For each ball, the cache stores the time of its next
# motion-state transition (SLIDING→ROLLING, etc.).
#
# Transition times depend only on current ball state, so
# they only need recomputing when a ball is touched by a collision.
# All other balls can reuse their cached value.

class TransitionCache:
    def get_next(self, ball: Ball) -> Event:
        if ball.id not in self._cache:
            self._cache[ball.id] = self._next_transition(ball)  # line 73
        return self._cache[ball.id]

    def invalidate(self, ball_id: str) -> None:
        self._cache.pop(ball_id, None)