How to Model Pool Ball Spin in Matter.js

Matter.js is a two-dimensional rigid-body physics engine that does not natively account for the three-dimensional rotational dynamics of cue sports, such as topspin, backspin, and sidespin. To simulate realistic billiard trajectories, developers must track an independent spin vector on each ball, calculate the frictional forces between the ball and the felt, and manually adjust velocities during cushion and ball-to-ball collisions. This article explains how to mathematically model these effects and implement them cleanly using Matter.js lifecycle events.

1. Representing 3D Spin in a 2D Engine

Because Matter.js bodies only track 2D linear velocity (body.velocity) and a single axis of 2D angular velocity (body.angularVelocity), you must store 3D spin properties directly on the ball body object.

const ball = Matter.Bodies.circle(x, y, radius, {
    restitution: 0.95,
    friction: 0.05,
    frictionAir: 0.01
});

// Custom spin state: normalized from -1 to 1 or in rad/s
ball.customSpin = {
    x: 0, // Side spin (English): positive = right, negative = left
    y: 0  // Longitudinal spin: positive = topspin (follow), negative = backspin (draw)
};

In this model, longitudinal spin (\(y\)) controls follow and draw shots, while transverse spin (\(x\)) controls side english.

2. Simulating Felt Friction and Longitudinal Spin

When a ball is struck with backspin or topspin, it slides across the cloth. The relative velocity at the contact point between the ball and the table produces a frictional force that gradually converts sliding motion into pure rolling motion.

Hook into the beforeUpdate event to calculate the sliding velocity and apply corrective forces:

Matter.Events.on(engine, 'beforeUpdate', () => {
    const balls = [cueBall, ...objectBalls];

    balls.forEach(ball => {
        if (!ball.customSpin) return;

        const speed = Matter.Vector.magnitude(ball.velocity);
        if (speed < 0.01) return;

        // Normalized direction vector of current travel
        const forward = Matter.Vector.normalise(ball.velocity);

        // Longitudinal friction: spin pushes the ball along its heading
        const longitudinalForce = Matter.Vector.mult(forward, ball.customSpin.y * 0.0005);
        Matter.Body.applyForce(ball, ball.position, longitudinalForce);

        // Decay the spin toward pure rolling equilibrium
        ball.customSpin.y *= 0.98;
        ball.customSpin.x *= 0.98;

        if (Math.abs(ball.customSpin.y) < 0.001) ball.customSpin.y = 0;
        if (Math.abs(ball.customSpin.x) < 0.001) ball.customSpin.x = 0;
    });
});

When a cue ball with backspin hits an object ball, its linear velocity rapidly drops. However, ball.customSpin.y remains negative, causing the longitudinal friction force to reverse the cue ball's direction immediately after impact, creating the draw effect.

3. Modeling Cushion Rebounds with Sidespin

When a spinning ball contacts a rail, sidespin creates a tangential friction force that widens or narrows the rebound angle and alters post-cushion spin.

Use the collisionStart event to detect cushion impacts:

Matter.Events.on(engine, 'collisionStart', (event) => {
    event.pairs.forEach(pair => {
        const { bodyA, bodyB } = pair;
        const ball = bodyA.isBall ? bodyA : (bodyB.isBall ? bodyB : null);
        const cushion = bodyA.isCushion ? bodyA : (bodyB.isCushion ? bodyB : null);

        if (ball && cushion && ball.customSpin) {
            // Determine collision normal
            const normal = pair.collision.normal;
            
            // Tangent vector parallel to the cushion face
            const tangent = { x: -normal.y, y: normal.x };

            // Apply tangential impulse based on sidespin
            const spinImpulse = Matter.Vector.mult(tangent, ball.customSpin.x * 2.5);
            Matter.Body.setVelocity(ball, Matter.Vector.add(ball.velocity, spinImpulse));

            // Reduce sidespin due to cushion grip and invert slightly
            ball.customSpin.x *= -0.3;
        }
    });
});

A positive (right) english value applied to a ball hitting a horizontal top rail will impart an impulse to the right, widening the rebound angle relative to a standard geometric reflection.

4. Modeling Ball-to-Ball Spin Transfer and Cut-Induced Throw

When two balls collide at an angle, friction between the surfaces introduces two critical phenomena:

  1. Cut-Induced Throw (CIT): Friction pushes the object ball slightly away from the center-to-center impact line.
  2. Spin Transfer: Sidespin on the cue ball is imparted onto the object ball in the opposite direction.

To implement spin transfer during ball collisions:

Matter.Events.on(engine, 'collisionStart', (event) => {
    event.pairs.forEach(pair => {
        if (pair.bodyA.isBall && pair.bodyB.isBall) {
            const ballA = pair.bodyA;
            const ballB = pair.bodyB;

            // Transfer fraction of sidespin
            const transferRatio = 0.05;
            const spinDifference = ballA.customSpin.x - ballB.customSpin.x;

            ballA.customSpin.x -= spinDifference * transferRatio;
            ballB.customSpin.x += spinDifference * transferRatio;

            // Apply slight tangential throw force to both bodies
            const normal = pair.collision.normal;
            const tangent = { x: -normal.y, y: normal.x };
            const throwVelocity = Matter.Vector.mult(tangent, spinDifference * 0.1);

            Matter.Body.setVelocity(ballB, Matter.Vector.add(ballB.velocity, throwVelocity));
            Matter.Body.setVelocity(ballA, Matter.Vector.sub(ballA.velocity, throwVelocity));
        }
    });
});

By decoupling 3D spin parameters from the 2D physics solver and modifying body velocities at key simulation steps, Matter.js can accurately replicate standard billiard physics.