{
"cells": [
{
"cell_type": "markdown",
"id": "a0475cb8",
"metadata": {
"editable": true,
"slideshow": {
"slide_type": ""
},
"tags": []
},
"source": [
"# The 30 Degree Rule\n",
"\n",
"## Intro\n",
"\n",
"The 30 degree rule states that the cue ball, when colliding with a ball over a wide range of cut angles, will be deflected roughly 30 degrees from it's initial course after the collision. It is more of a *rule of thumb* used by pool players to improve their game, rather than a truism of pool physics.\n",
"\n",
"In this example, we will setup simulations that test the 30 degree rule and some of the physics equations defined by [Dr. Dave Billiards](https://drdavebilliards.com/).\n",
"\n",
"## Assumptions\n",
"\n",
"We'll use one of pooltool's simpler ball-ball models which assumes perfectly elastic and frictionless ball-ball collisions. Read more [here](https://ekiefl.github.io/2020/04/24/pooltool-theory/#section-ii-ball-ball-interactions).\n",
"\n",
"Importantly, the cue ball must be rolling (without slippage) when it contacts the object ball. Otherwise, the 30-degree rule will not hold. As an extreme example of this, a cue ball with no spin will deflect off the object ball along the [tangent line](https://billiards.colostate.edu/faq/stun/90-degree-rule/).\n",
"\n",
"## Definitions\n",
"\n",
"**The rule, stated in full:**\n",
"\n",
"> The 30° rule states that for a rolling cue ball (CB) shot, over a wide range of cut angles, between a 1/4-ball hit (49 degree cut) and 3/4-ball hit (14 degree cut), the CB will deflect or carom off by very close to 30° (the “natural angle“) from its original direction after hitting the object ball (OB). If you want to be more precise, the angle is a little more (about 34°) closer to a 1/2-ball hit and a little less (about 27°) closer to a 1/4-ball or 3/4-ball hit.\n",
"\n",
"*(source: [https://billiards.colostate.edu/faq/30-90-rules/30-degree-rule/](https://billiards.colostate.edu/faq/30-90-rules/30-degree-rule/))*\n",
"\n",
"**The carom angle**\n",
"\n",
"The carom angle is the angle that the rule claims is 30 degrees. Formally, it is the angle between the velocity of the cue ball directly before impact, and the velocity of the cue ball after the collision once the cue ball is no longer sliding on the cloth. We will see that this angle is independent of the cue's initial speed.\n",
"\n",
"**Ball-hit fraction and cut angle**\n",
"\n",
"- Ball-hit fraction, $f$, describes the fraction of overlap between the cue ball and object ball, projected in the direction of the aiming line.\n",
"- Cut angle, $\\phi$, refers to the angle that the cue ball glances the object ball, where $0$ refers to a full ball hit (straight on), and $90$ refers to the lower bound of the thinnest hit possible.\n",
"\n",
"These two are visualized in this diagram, where $f = \\text{ball overlap} / (2R)$:\n",
"\n",
"
\n",
"\n",
"*(source: [https://billiards.colostate.edu/technical_proofs/new/TP_A-23.pdf](https://billiards.colostate.edu/technical_proofs/new/TP_A-23.pdf))*\n",
"\n",
"Establishing the relationship between these quantities is important, since the 30 degree rule makes reference to both cut angle and ball-hit fraction. One can calculate the ball-hit fraction from cut angle with the following equation:\n",
"\n",
"$$\n",
"f(\\phi) = 1 - \\sin{\\phi}\n",
"$$"
]
},
{
"cell_type": "markdown",
"id": "329f9243",
"metadata": {},
"source": [
"## Simulating a collision\n",
"\n",
"To start, we'll need to create a billiards system. That means defining a table, a cue stick, and a collection of balls.\n",
"\n",
"We'll start with a table. Since we don't want collisions with cushions to interfere with our trajectory, let's make an unrealistically large $10\\text{m} \\times 10\\text{m}$ [Table](../autoapi/pooltool/index.rst#pooltool.Table)."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "0ab855d2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T06:51:49.959700Z",
"iopub.status.busy": "2026-03-15T06:51:49.959391Z",
"iopub.status.idle": "2026-03-15T06:51:52.468511Z",
"shell.execute_reply": "2026-03-15T06:51:52.467319Z"
},
"lines_to_next_cell": 2
},
"outputs": [],
"source": [
"import pooltool as pt\n",
"\n",
"table_specs = pt.objects.BilliardTableSpecs(l=10, w=10)\n",
"table = pt.Table.from_table_specs(table_specs)"
]
},
{
"cell_type": "markdown",
"id": "c84af7d9",
"metadata": {},
"source": [
"Next, we'll create two [Ball](../autoapi/pooltool/index.rst#pooltool.Ball) objects."
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "098c6d65",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T06:51:52.471464Z",
"iopub.status.busy": "2026-03-15T06:51:52.471067Z",
"iopub.status.idle": "2026-03-15T06:51:52.876446Z",
"shell.execute_reply": "2026-03-15T06:51:52.875150Z"
}
},
"outputs": [],
"source": [
"cue_ball = pt.Ball.create(\"cue\", xy=(2.5, 1.5))\n",
"obj_ball = pt.Ball.create(\"obj\", xy=(2.5, 3.0))"
]
},
{
"cell_type": "markdown",
"id": "5772615e",
"metadata": {},
"source": [
"Next, we'll need a [Cue](../autoapi/pooltool/index.rst#pooltool.Cue)."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "11a26169",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T06:51:52.879384Z",
"iopub.status.busy": "2026-03-15T06:51:52.879173Z",
"iopub.status.idle": "2026-03-15T06:51:52.883214Z",
"shell.execute_reply": "2026-03-15T06:51:52.881763Z"
}
},
"outputs": [],
"source": [
"cue = pt.Cue(cue_ball_id=\"cue\")"
]
},
{
"cell_type": "markdown",
"id": "26714c28",
"metadata": {},
"source": [
"Finally, we'll need to wrap these objects up into a [System](../autoapi/pooltool/index.rst#pooltool.System). We'll call this our system *template*, with the intention of reusing it for many different shots."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "13b3d613",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T06:51:52.885844Z",
"iopub.status.busy": "2026-03-15T06:51:52.885640Z",
"iopub.status.idle": "2026-03-15T06:51:52.889024Z",
"shell.execute_reply": "2026-03-15T06:51:52.888129Z"
}
},
"outputs": [],
"source": [
"system_template = pt.System(\n",
" table=table,\n",
" cue=cue,\n",
" balls=(cue_ball, obj_ball),\n",
")"
]
},
{
"cell_type": "markdown",
"id": "937977d6",
"metadata": {},
"source": [
"Let's set up a shot by aiming at the object ball with a cut angle of 30 degrees. There is a small clash in terminology here, because in pooltool, `phi` is an angle defined with respect to the table, not the cut angle:\n",
"\n",
"
\n",
"\n",
"So in the function call below, `pt.aim.at_ball(system, \"obj\", cut=30)` returns the angle `phi` that the cue ball should be directed at such that a cut angle of 30 degrees with the object ball is achieved."
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "ef0802b2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T06:51:52.891426Z",
"iopub.status.busy": "2026-03-15T06:51:52.891217Z",
"iopub.status.idle": "2026-03-15T06:51:53.004144Z",
"shell.execute_reply": "2026-03-15T06:51:53.003050Z"
}
},
"outputs": [],
"source": [
"# Creates a deep copy of the template\n",
"system = system_template.copy()\n",
"\n",
"phi = pt.aim.at_ball(system, \"obj\", cut=30)\n",
"system.cue.set_state(V0=3, phi=phi, b=0.4)"
]
},
{
"cell_type": "markdown",
"id": "232cb6c2",
"metadata": {},
"source": [
"Now, we [simulate](../autoapi/pooltool/index.rst#pooltool.simulate) the shot and then [continuize](../autoapi/pooltool/evolution/continuize/index.html#pooltool.evolution.continuize.continuize) it to store ball state data (like coordinates) in $10\\text{ms}$ timestep intervals."
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "d29a2bda",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T06:51:53.007176Z",
"iopub.status.busy": "2026-03-15T06:51:53.006883Z",
"iopub.status.idle": "2026-03-15T06:51:53.255275Z",
"shell.execute_reply": "2026-03-15T06:51:53.253495Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"System simulated: True\n"
]
}
],
"source": [
"# Create a default physics engine, then overwrite ball-ball model with frictionless, elastic model.\n",
"engine = pt.physics.PhysicsEngine()\n",
"engine.resolver.ball_ball = pt.physics.ball_ball_models[pt.physics.BallBallModel.FRICTIONLESS_ELASTIC]()\n",
"\n",
"pt.simulate(system, engine=engine, inplace=True)\n",
"pt.continuize(system, dt=0.01, inplace=True)\n",
"\n",
"print(f\"System simulated: {system.simulated}\")"
]
},
{
"cell_type": "markdown",
"id": "0debc0af",
"metadata": {},
"source": [
"## Visualizing the collision"
]
},
{
"cell_type": "markdown",
"id": "cbd37f0d",
"metadata": {},
"source": [
"If you have a graphics card, you can immediately visualize this shot in 3D with\n",
"\n",
"```python\n",
"pt.show(system)\n",
"```\n",
"\n",
"Since that can't be embedded into the documentation, we'll instead plot the trajectory of the cue ball and object ball by accessing ther historical states."
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "ffab2ecc",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T06:51:53.258426Z",
"iopub.status.busy": "2026-03-15T06:51:53.258212Z",
"iopub.status.idle": "2026-03-15T06:51:53.265826Z",
"shell.execute_reply": "2026-03-15T06:51:53.264508Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"pooltool.objects.ball.datatypes.BallHistory"
]
},
"execution_count": 7,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"cue_ball = system.balls[\"cue\"]\n",
"obj_ball = system.balls[\"obj\"]\n",
"cue_history = cue_ball.history_cts\n",
"obj_history = obj_ball.history_cts\n",
"type(cue_history)"
]
},
{
"cell_type": "markdown",
"id": "3103fb30",
"metadata": {},
"source": [
"The [BallHistory](../autoapi/pooltool/objects/index.rst#pooltool.objects.BallHistory) holds the ball's historical states, each stored as a [BallState](../autoapi/pooltool/objects/index.rst#pooltool.objects.BallState) object. Each attribute of the ball states can be concatenated into numpy arrays with the [BallHistory.vectorize](../autoapi/pooltool/objects/index.rst#pooltool.objects.BallHistory.vectorize) method."
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "e82a222e",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T06:51:53.268409Z",
"iopub.status.busy": "2026-03-15T06:51:53.268141Z",
"iopub.status.idle": "2026-03-15T06:51:53.274256Z",
"shell.execute_reply": "2026-03-15T06:51:53.272910Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"(1305, 3, 3)\n",
"(1305,)\n",
"(1305,)\n"
]
}
],
"source": [
"rvw_cue, s_cue, t_cue = cue_history.vectorize()\n",
"rvw_obj, s_obj, t_obj = obj_history.vectorize()\n",
"\n",
"print(rvw_cue.shape)\n",
"print(s_cue.shape)\n",
"print(t_cue.shape)"
]
},
{
"cell_type": "markdown",
"id": "46a2bc8b",
"metadata": {},
"source": [
"We can grab the xy-coordinates from the `rvw` array by with the following."
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "de1ed3f0",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T06:51:53.276677Z",
"iopub.status.busy": "2026-03-15T06:51:53.276464Z",
"iopub.status.idle": "2026-03-15T06:51:53.280828Z",
"shell.execute_reply": "2026-03-15T06:51:53.279851Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"(1305, 2)"
]
},
"execution_count": 9,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"coords_cue = rvw_cue[:, 0, :2]\n",
"coords_obj = rvw_obj[:, 0, :2]\n",
"coords_cue.shape"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "9a2d85fe",
"metadata": {
"editable": true,
"execution": {
"iopub.execute_input": "2026-03-15T06:51:53.283225Z",
"iopub.status.busy": "2026-03-15T06:51:53.283005Z",
"iopub.status.idle": "2026-03-15T06:51:55.397244Z",
"shell.execute_reply": "2026-03-15T06:51:55.396142Z"
},
"lines_to_next_cell": 2,
"slideshow": {
"slide_type": ""
},
"tags": []
},
"outputs": [
{
"data": {
"text/html": [
"
| \n", " | phi | \n", "f | \n", "theta | \n", "
|---|---|---|---|
| 0 | \n", "2.000000 | \n", "0.965101 | \n", "0.086763 | \n", "
| 1 | \n", "3.755102 | \n", "0.934508 | \n", "0.160719 | \n", "
| 2 | \n", "5.510204 | \n", "0.903977 | \n", "0.229332 | \n", "
| 3 | \n", "7.265306 | \n", "0.873536 | \n", "0.292464 | \n", "
| 4 | \n", "9.020408 | \n", "0.843214 | \n", "0.350121 | \n", "
| 5 | \n", "10.775510 | \n", "0.813039 | \n", "0.399313 | \n", "
| 6 | \n", "12.530612 | \n", "0.783039 | \n", "0.442581 | \n", "
| 7 | \n", "14.285714 | \n", "0.753243 | \n", "0.478738 | \n", "
| 8 | \n", "16.040816 | \n", "0.723678 | \n", "0.508446 | \n", "
| 9 | \n", "17.795918 | \n", "0.694373 | \n", "0.532765 | \n", "