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:
- Cut-Induced Throw (CIT): Friction pushes the object ball slightly away from the center-to-center impact line.
- 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.