{"id":2131,"date":"2025-10-02T07:00:02","date_gmt":"2025-10-02T12:00:02","guid":{"rendered":"https:\/\/wollen.org\/blog\/?p=2131"},"modified":"2026-02-01T11:48:38","modified_gmt":"2026-02-01T17:48:38","slug":"elliptical-bank-shots","status":"publish","type":"post","link":"https:\/\/wollen.org\/blog\/2025\/10\/elliptical-bank-shots\/","title":{"rendered":"Elliptical bank shots"},"content":{"rendered":"<p>I think everyone knows what an ellipse is. It&#8217;s an elongated, squished circle that looks kind of like an egg. In mathematical terms it&#8217;s considered a <a href=\"https:\/\/en.wikipedia.org\/wiki\/Conic_section\" target=\"_blank\" rel=\"noopener\">conic section<\/a>. That means you can slice through a cone and trace an ellipse, like the image below.<\/p>\n<figure id=\"attachment_2164\" aria-describedby=\"caption-attachment-2164\" style=\"width: 286px\" class=\"wp-caption aligncenter\"><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/conic_sections.png\"><img loading=\"lazy\" decoding=\"async\" class=\"wp-image-2164 size-medium\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/conic_sections-286x300.png\" alt=\"\" width=\"286\" height=\"300\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/conic_sections-286x300.png 286w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/conic_sections-768x806.png 768w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/conic_sections.png 953w\" sizes=\"auto, (max-width: 286px) 100vw, 286px\" \/><\/a><figcaption id=\"caption-attachment-2164\" class=\"wp-caption-text\">The four conic section types. Image credit: Wikipedia.<\/figcaption><\/figure>\n<p>Before getting into ellipses I want to touch on their conic section cousin: the parabola. Parabolas have similar properties and a familiar real-world application.<\/p>\n<p>These big satellite dishes (picture below) were common in the 1980s. They got smaller as technology improved and, in the 90s, shrank to the 2-foot DirecTV-style dishes we still see today.<\/p>\n<p>Technically they are 3-dimensional <em>paraboloids<\/em> but the same principles apply. Parallel rays are deflected toward a single point, called the <em>focus<\/em>. Satellite dish manufacturers mount a <em>feedhorn<\/em> at this point. It collects the amplified signal, filters it, and converts it into an electrical signal. A wire carries that signal to your living room where a satellite receiver decodes it and draws a picture on your TV. Simple!<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/parabola_satellite-1.gif\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2149\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/parabola_satellite-1.gif\" alt=\"\" width=\"1280\" height=\"640\" \/><\/a><\/p>\n<p>Notice how all the photons\u2014the little bits of light that carry the signal\u2014hit the focus at the same time. No matter where they strike the parabola, they travel the same distance. A dish collects a signal out of the air like a bowl collects rainwater. Parabolic microphones operate using the same principle.<\/p>\n<hr \/>\n<h4>1. The ellipse.<\/h4>\n<p>That&#8217;s great but we&#8217;re here to talk about ellipses.<\/p>\n<p>It turns out that ellipses behave in a similar way. Rather than one focus they have two, and any particle fired from a focus will deflect off the inner wall and hit the other focus. It&#8217;s called the <em>reflective property<\/em> of ellipses. Check out the diagram below to see what I mean.<\/p>\n<p>No matter what direction a particle is fired, it always ends up hitting the other focus. It&#8217;s a bank shot that goes in every time. You can imagine the orange tangent line sliding around the perimeter of the ellipse. At every position the two \u03b8 angles will be the same.<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2152\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1.png\" alt=\"\" width=\"900\" height=\"900\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1.png 900w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1-300x300.png 300w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1-150x150.png 150w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1-768x768.png 768w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1-800x800.png 800w\" sizes=\"auto, (max-width: 900px) 100vw, 900px\" \/><\/a><\/p>\n<p>And that behavior is what we&#8217;ll demonstrate in this post. We&#8217;ll fire several particles and animate them to prove that they all impact the focus.<\/p>\n<p>At this point there is a fork in the road. We already know what a particle&#8217;s behavior will be after it strikes the ellipse wall: it will change direction and travel toward the focus. So we could simply dictate this behavior and program every particle to move in the direction we know it&#8217;s supposed to go. But it will be a lot more interesting to model the deflection pictured above, i.e. calculate the tangent and reflect the particle over the normal line and let it go wherever it wants to go, thus proving the reflective property is real. I think it&#8217;s more fun to define basic rules of motion and see how the system behaves.<\/p>\n<p>It will also require more code, but that&#8217;s okay. We&#8217;ll step through a main loop to generate each frame of the animation. Particles will be instantiated from a <code>Particle<\/code> class. This object-oriented approach will make it more difficult to talk through the code. But, as usual, you can find the complete script at the bottom of this post.<\/p>\n<hr \/>\n<h4>2. The code.<\/h4>\n<p>The formula for an ellipse centered at the origin looks like this:<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/formula_ellipse.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2181\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/formula_ellipse.png\" alt=\"\" width=\"202\" height=\"88\" \/><\/a><\/p>\n<p>Where <strong>A<\/strong> defines the ellipse&#8217;s width along the x-axis and <strong>B<\/strong> along the y-axis.<\/p>\n<p>Because we&#8217;ll refer to it many times later, let&#8217;s create an <code>Ellipse<\/code> class. It&#8217;s not a continuous function\u2014there are two y-values for every value of x\u2014so we need some logic to distinguish between &#8220;upper&#8221; and &#8220;lower.&#8221; The distance <strong>C<\/strong> from the center (0, 0) to the two foci is defined by <code>C\u00b2 = A\u00b2 - B\u00b2<\/code>.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">class Ellipse:\r\n    def __init__(self, a, b):\r\n        self.A = a\r\n        self.B = b\r\n        self.x = arange(-self.A, self.A + 0.0001, 0.0001)\r\n        self.y_lower, self.y_upper = self.get_y_values()\r\n        self.focus_x_left, self.focus_x_right = self.get_focus_values()\r\n        self.focus_y_left, self.focus_y_right = 0, 0\r\n\r\n    def get_y_values(self):\r\n        y_lower = -self.B * sqrt(1 - self.x**2 \/ self.A**2)\r\n        y_upper = self.B * sqrt(1 - self.x**2 \/ self.A**2)\r\n        return y_lower, y_upper\r\n\r\n    def get_focus_values(self):\r\n        focus_x_left = -sqrt(self.A**2 - self.B**2)\r\n        focus_x_right = sqrt(self.A**2 - self.B**2)\r\n        return focus_x_left, focus_x_right\r\n\r\n\r\nellipse = Ellipse(a=5, b=3)<\/pre>\n<p>To model a particle&#8217;s motion we need to know its direction (slope) and speed. <code>speed<\/code> will be the distance a particle travels during each frame of the animation.<\/p>\n<p>We&#8217;re going to fire several particles in different directions. Let&#8217;s package them into a list so we can easily iterate over them. For now the list will only contain one particle, but more will be appended later. This approach will allow us to fire particles one after another, rather than all at once.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">class Particle:\r\n    def __init__(self, initial_slope, speed):\r\n        self.x = ellipse.focus_x_left\r\n        self.y = ellipse.focus_y_left\r\n        self.movement_history = []\r\n        self.slope = initial_slope\r\n        self.collision_point = self.get_collision_point()\r\n        self.speed = speed\r\n        self.speed_x, self.speed_y = self.get_speed_components()\r\n        self.phase = 1\r\n    \r\n    [...]\r\n\r\nparticle = Particle(initial_slope=-10, speed=0.1)\r\n\r\nparticle_list = [particle]\r\n\r\n<\/pre>\n<p>Inside the <code>__init__<\/code> you&#8217;ll notice a couple other methods.<\/p>\n<p><code>get_collision_point<\/code> checks where exactly the particle will collide with the ellipse wall. Calculating this value during initialization makes it much easier to do collision detection. We only have to check the particle&#8217;s distance from that exact point. When the distance is near zero, a collision has happened.<\/p>\n<p>To solve for this point we have to set the particle&#8217;s position equation equal to the ellipse equation and find what values of x and y satisfy the expression. This requires an obnoxious quadratic solution. I happily outsourced the work to <a href=\"https:\/\/www.wolframalpha.com\" target=\"_blank\" rel=\"noopener\">Wolfram Alpha<\/a>.<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/collision_eq1.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2184\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/collision_eq1.png\" alt=\"\" width=\"366\" height=\"114\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/collision_eq1.png 366w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/collision_eq1-300x93.png 300w\" sizes=\"auto, (max-width: 366px) 100vw, 366px\" \/><\/a><\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/collision_eq2.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2185\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/collision_eq2.png\" alt=\"\" width=\"691\" height=\"91\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/collision_eq2.png 691w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/collision_eq2-300x40.png 300w\" sizes=\"auto, (max-width: 691px) 100vw, 691px\" \/><\/a><\/p>\n<p>Then we plug the x value back into the <code>y=mx+b<\/code> particle equation to solve for y. Technically this expression should include a \u00b1 symbol. But if we agree to only fire particles to the right, we can ignore it. I think that&#8217;s a fair compromise.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">class Particle:\r\n\r\n    [...]\r\n\r\n    def get_collision_point(self):\r\n        y_intercept = self.y - self.slope * self.x\r\n        x_val = (sqrt(ellipse.A**4 * ellipse.B**2 * self.slope**2 + ellipse.A**2 * ellipse.B**4 - ellipse.A**2 * ellipse.B**2 * y_intercept**2) - ellipse.A**2 * y_intercept * self.slope) \/ (ellipse.A**2 * self.slope**2 + ellipse.B**2)\r\n        y_val = self.slope * x_val + y_intercept\r\n        return (x_val, y_val)\r\n\r\n    [...]<\/pre>\n<p>The <code>__init__<\/code> also references <code>get_speed_components<\/code>. This method returns x- and y-components of the speed vector. They tell us exactly how the particle will move during each frame.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">class Particle:\r\n\r\n    [...]\r\n\r\n    def get_speed_components(self):\r\n        angle = atan2(self.collision_point[1] - self.y, self.collision_point[0] - self.x)\r\n        speed_x = self.speed * cos(angle)\r\n        speed_y = self.speed * sin(angle)\r\n        return speed_x, speed_y\r\n\r\n    [...]<\/pre>\n<p><code>Particle<\/code> objects have a <code>movement_history<\/code> attribute which keeps track of all their past locations. We&#8217;ll use it to draw a tail on each particle\u2014sort of like the old NHL <a href=\"https:\/\/www.espn.com\/nhl\/story\/_\/id\/21080555\/nhl-bring-back-infamous-glow-puck\" target=\"_blank\" rel=\"noopener\">glow puck<\/a> but hopefully less hated by viewers.<\/p>\n<p>The <code>phase<\/code> attribute will assist in collision detection.<\/p>\n<ul>\n<li>Phase 1 \u2014 The particle was recently fired. It&#8217;s moving toward the ellipse wall.<\/li>\n<li>Phase 2 \u2014 The particle has recently deflected off the ellipse wall. It&#8217;s moving toward the focus.<\/li>\n<li>Phase 3 \u2014 The particle has impacted the focus and its journey is over.<\/li>\n<\/ul>\n<p>With our ellipse and particle instances in hand, we can jump back to the main code.<\/p>\n<p>Additional particles will be spawned according to the dictionary below. For example, at frame 20 we&#8217;ll launch a particle with a slope of -1.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">additional_particles = {20: -1, 40: -0.5, 60: -0.1, 80: 0.2, 100: 0.4, 120: 1.2, 140: 7}<\/pre>\n<p>Each iteration of this large <code>for<\/code> loop will generate one frame of the animation.<\/p>\n<p>Begin by creating a figure and an Axes instance on which to draw. Then plot the ellipse and foci.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">plt.style.use(\"wollen_ellipse.mplstyle\")\r\n\r\nfor frame_counter in range(250):\r\n    print(frame_counter)\r\n\r\n    fig, ax = plt.subplots()\r\n\r\n    ax.plot(ellipse.x, ellipse.y_upper, color=\"#444\", linewidth=2.0, zorder=1)\r\n    ax.plot(ellipse.x, ellipse.y_lower, color=\"#444\", linewidth=2.0, zorder=1)\r\n\r\n    ax.scatter([ellipse.focus_x_left, ellipse.focus_x_right],\r\n               [ellipse.focus_y_left, ellipse.focus_y_right],\r\n               color=\"None\", s=80, edgecolor=\"#444\", linewidth=1.8, zorder=1)\r\n\r\n    [...]<\/pre>\n<p>Iterate over each particle within <code>particle_list<\/code>.<\/p>\n<p>First update particle position (which I&#8217;ll detail in a moment). Then use <code>scatter<\/code> to plot it. Plotting the 10 most recent values of <code>movement_history<\/code> will create a tail behind each particle. It sounds strange but it will make it easier to see what direction a particle is traveling.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">for frame_counter in range(250):\r\n\r\n    [...]\r\n\r\n    for particle in particle_list:\r\n        particle.update_position()\r\n\r\n        ax.scatter([particle.x], [particle.y], color=\"#4C4\", s=50, edgecolor=\"#444\", linewidth=1.0, zorder=3)\r\n\r\n        ax.plot([pos[0] for pos in particle.movement_history[-10:]],\r\n                [pos[1] for pos in particle.movement_history[-10:]],\r\n                color=\"#4C4\", linewidth=1.5, zorder=2)\r\n\r\n    [...]<\/pre>\n<p><code>Particle.update_position<\/code> is where it starts to get interesting. First check for a collision during the current frame, then move the particle according to its speed. Once a particle enters phase 3 we can stop recording its position in <code>movement_history<\/code>.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">class Particle:\r\n\r\n    [...]\r\n\r\n    def update_position(self):\r\n        self.check_collision()\r\n        self.x += self.speed_x\r\n        self.y += self.speed_y\r\n        if self.phase &lt; 3:\r\n            self.movement_history.append((self.x, self.y))\r\n        else:\r\n            self.movement_history.append((None, None))\r\n\r\n    [...]<\/pre>\n<p>The heaviest math lies in the methods <code>check_collision<\/code> and <code>get_reflected_point<\/code>. These methods implement the geometry of the ellipse diagram shown earlier in this post. It&#8217;s difficult to walk through every line of code so you&#8217;ll have to scroll down a little to follow along.<\/p>\n<p>The <code>if<\/code> and <code>elif<\/code> conditions check the distance from a particle to the collision point and focus, respectively.<\/p>\n<p>If a particle is very close to its collision point, we enter the first block. Set the <code>phase<\/code> attribute to 2 to indicate that we&#8217;ll now be traveling toward the focus. Since our frame rate isn&#8217;t infinite we have to cheat a little and nudge the particle to its precise collision point. Then record position in <code>movement_history<\/code> as usual.<\/p>\n<p>The tangent line slope (derivative), which you can think of as a flat surface where the particle will bounce, is defined this way:<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/tangent_slope_latex.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2190\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/tangent_slope_latex.png\" alt=\"\" width=\"260\" height=\"94\" \/><\/a>The normal line is perpendicular to the tangent line. To calculate its slope, do the negative inverse, e.g. 4 \u279e -\u00bc.<\/p>\n<p>At this point we want to <em>reflect<\/em> the particle&#8217;s trajectory over the normal line. Take another look at the ellipse diagram to refresh.<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-medium wp-image-2152\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1-300x300.png\" alt=\"\" width=\"300\" height=\"300\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1-300x300.png 300w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1-150x150.png 150w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1-768x768.png 768w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1-800x800.png 800w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram-1.png 900w\" sizes=\"auto, (max-width: 300px) 100vw, 300px\" \/><\/a><\/p>\n<p>We could reflect any arbitrary point found in <code>movement_history<\/code> but let&#8217;s instead use the left focus, from which every particle is fired. To reflect a point over a line in the general form <code>ax + by + c = 0<\/code>, use this formula:<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/reflected_eq_x.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2191\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/reflected_eq_x.png\" alt=\"\" width=\"510\" height=\"83\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/reflected_eq_x.png 510w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/reflected_eq_x-300x49.png 300w\" sizes=\"auto, (max-width: 510px) 100vw, 510px\" \/><\/a><\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/reflected_eq_y.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2192\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/reflected_eq_y.png\" alt=\"\" width=\"500\" height=\"83\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/reflected_eq_y.png 500w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/reflected_eq_y-300x50.png 300w\" sizes=\"auto, (max-width: 500px) 100vw, 500px\" \/><\/a><\/p>\n<p>We know the particle is located exactly at the collision point and we know it will travel toward this reflected point, so we can calculate its new angle using <code>atan2<\/code>. Use <code>sin<\/code> and <code>cos<\/code> to get the new x and y speed components. Then the collision is over and it&#8217;s back to normal. Each frame updates the particle&#8217;s position and nothing notable will happen until it impacts the focus.<\/p>\n<p>When a particle does impact the focus, it enters phase 3 and it should disappear from the plot. I chose to relocate it to the point (999, 999). Out of sight, out of mind.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">class Particle:\r\n\r\n    [...]\r\n\r\n    def check_collision(self):\r\n        if self.phase == 1 and sqrt((self.collision_point[1] - self.y)**2 + (self.collision_point[0] - self.x)**2) &lt; self.speed:\r\n            self.phase = 2\r\n            self.x = self.collision_point[0]\r\n            self.y = self.collision_point[1]\r\n            self.movement_history.append((self.x, self.y))\r\n            tangent_line_slope = -(ellipse.B ** 2 \/ ellipse.A ** 2) * (self.x \/ self.y)\r\n            normal_line_slope = -1 \/ tangent_line_slope\r\n            normal_line_intercept = self.y - normal_line_slope * self.x\r\n            trajectory_point = self.get_reflected_point(normal_line_slope, -1, normal_line_intercept)\r\n            trajectory_angle = atan2(trajectory_point[1] - self.y, trajectory_point[0] - self.x)\r\n            self.speed_x = self.speed * cos(trajectory_angle)\r\n            self.speed_y = self.speed * sin(trajectory_angle)\r\n        elif self.phase == 2 and sqrt((ellipse.focus_y_right - self.y)**2 + (ellipse.focus_x_right - self.x)**2) &lt; self.speed:\r\n            self.phase = 3\r\n            self.x = 999\r\n            self.y = 999\r\n\r\n    def get_reflected_point(self, a, b, c):\r\n        point_x, point_y = ellipse.focus_x_left, ellipse.focus_y_left\r\n        d = (a * point_x + b * point_y + c) \/ (a**2 + b**2)\r\n        x_reflected = point_x - 2 * a * d\r\n        y_reflected = point_y - 2 * b * d\r\n        return x_reflected, y_reflected<\/pre>\n<p>Back down to the main loop&#8230; We need to take care of a few Matplotlib things before finishing. Set ticks and window limits as a function of A and B, the ellipse parameters.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">for frame_counter in range(250):\r\n\r\n    [...]\r\n\r\n    ticks = range(-ellipse.A, ellipse.A + 1)\r\n    window_limits = (-ellipse.A * 1.1, ellipse.A * 1.1)\r\n    ax.set(xticks=ticks,\r\n           yticks=ticks,\r\n           xlim=window_limits,\r\n           ylim=window_limits)\r\n\r\n    [...]<\/pre>\n<p>Let&#8217;s try Matplotlib&#8217;s built-in <a href=\"https:\/\/en.wikibooks.org\/wiki\/LaTeX\/Mathematics\" target=\"_blank\" rel=\"noopener\">LaTeX<\/a> renderer and place the ellipse equation near the top of the window. Activate rendering by putting $ characters at the beginning and end of the string.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">for frame_counter in range(250):\r\n\r\n    [...]\r\n\r\n    ax.text(x=0, y=ellipse.A * 0.98,\r\n            s=r'$\\frac{x^2}{' + f'{ellipse.A}' + r'^2} + \\frac{y^2}{'  + f'{ellipse.B}' + r'^2} = 1$',\r\n            size=18, ha=\"center\", va=\"top\")\r\n\r\n    [...]<\/pre>\n<p>I&#8217;m saving each frame inside a &#8220;frames&#8221; folder. When the script finishes I&#8217;ll open the images in <a href=\"https:\/\/www.gimp.org\/\" target=\"_blank\" rel=\"noopener\">GIMP<\/a> and export an animated GIF.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">for frame_counter in range(250):\r\n\r\n    [...]\r\n    \r\n    plt.savefig(f\"frames\/{frame_counter}.png\")\r\n    \r\n    [...]<\/pre>\n<p>Remember, we&#8217;re spawning a new particle every 20 frames. Check if the current frame number exists within the <code>additional_particles<\/code> dictionary. If it does, instantiate a new <code>Particle<\/code> object and append it to the list.<\/p>\n<p>Finally, iterate <code>frame_counter<\/code>. One pass through the <code>for<\/code> loop is complete.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">for frame_counter in range(250):\r\n\r\n    [...]\r\n\r\n    if frame_counter in additional_particles:\r\n        particle_list.append(Particle(initial_slope=additional_particles[frame_counter], speed=0.1))\r\n\r\n    frame_counter += 1<\/pre>\n<p>When the script finishes running you&#8217;ll have 250 images inside the &#8220;frames&#8221; folder. As I said, I&#8217;ll use GIMP to save them as an animated GIF, but other online and offline tools will work just as well.<\/p>\n<hr \/>\n<h4>3. The output.<\/h4>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_output.gif\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter wp-image-2208 size-full\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_output.gif\" alt=\"\" width=\"900\" height=\"900\" \/><\/a><\/p>\n<p>There it is. It was a bit of a marathon to get here. Every particle deflects off the ellipse wall and impacts the other focus. You can play around with the slopes in <code>additional_particles<\/code> and see that the values don&#8217;t matter. The end result is the same. It&#8217;s worth reiterating: this behavior isn&#8217;t programmed into the particles. They&#8217;re designed to bounce off the wall and follow their natural trajectory.<\/p>\n<p>If you check the <code>movement_history<\/code> of the particles you&#8217;ll find they all took 100 frames to reach their destination. It&#8217;s analogous to the parabola behavior illustrated earlier. All particles travel the same distance regardless of the direction they&#8217;re fired. You can use the Pythagorean Theorem to predict exactly how far particles will travel:<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram_pythagorean-1.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter wp-image-2252 size-medium\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram_pythagorean-1-300x300.png\" alt=\"\" width=\"300\" height=\"300\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram_pythagorean-1-300x300.png 300w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram_pythagorean-1-150x150.png 150w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram_pythagorean-1-768x768.png 768w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram_pythagorean-1-800x800.png 800w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2025\/08\/ellipse_diagram_pythagorean-1.png 900w\" sizes=\"auto, (max-width: 300px) 100vw, 300px\" \/><\/a><\/p>\n<p>Or more intuitively, imagine a particle fired directly to the right.<\/p>\n<p>We don&#8217;t encounter ellipses as often in everyday life but I think they&#8217;re just as interesting as parabolas. <a href=\"https:\/\/www.wired.com\/2015\/11\/elliptic-pool-loop-round-billiard-table\/\" target=\"_blank\" rel=\"noopener\">Elliptical pool tables<\/a> are one (niche) example of the reflective property. It&#8217;s also showcased in <a href=\"https:\/\/en.wikipedia.org\/wiki\/Whispering_gallery\" target=\"_blank\" rel=\"noopener\">whispering galleries<\/a>, where sound is carried across a room seemingly by magic. Now we know how they work.<!-- HFCM by 99 Robots - Snippet # 15: endmark-python -->\n<span class=\"endmark-python\"><\/span>\n<!-- \/end HFCM by 99 Robots -->\n<\/p>\n<hr \/>\n<p><a href=\"https:\/\/wollen.org\/misc\/ellipse_2025.zip\"><strong>Download the Matplotlib style.<\/strong><\/a><\/p>\n<p><strong>Full code:<\/strong><\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from numpy import sqrt, arange\r\nfrom math import sin, cos, atan2\r\nimport matplotlib.pyplot as plt\r\n\r\n\r\nclass Ellipse:\r\n    def __init__(self, a, b):\r\n        self.A = a\r\n        self.B = b\r\n        self.x = arange(-self.A, self.A + 0.0001, 0.0001)\r\n        self.y_lower, self.y_upper = self.get_y_values()\r\n        self.focus_x_left, self.focus_x_right = self.get_focus_values()\r\n        self.focus_y_left, self.focus_y_right = 0, 0\r\n\r\n    def get_y_values(self):\r\n        y_lower = -self.B * sqrt(1 - self.x**2 \/ self.A**2)\r\n        y_upper = self.B * sqrt(1 - self.x**2 \/ self.A**2)\r\n        return y_lower, y_upper\r\n\r\n    def get_focus_values(self):\r\n        focus_x_left = -sqrt(self.A**2 - self.B**2)\r\n        focus_x_right = sqrt(self.A**2 - self.B**2)\r\n        return focus_x_left, focus_x_right\r\n\r\n\r\nclass Particle:\r\n    def __init__(self, initial_slope, speed):\r\n        self.x = ellipse.focus_x_left\r\n        self.y = ellipse.focus_y_left\r\n        self.movement_history = []\r\n        self.slope = initial_slope\r\n        self.collision_point = self.get_collision_point()\r\n        self.speed = speed\r\n        self.speed_x, self.speed_y = self.get_speed_components()\r\n        self.phase = 1\r\n\r\n    def get_collision_point(self):\r\n        y_intercept = self.y - self.slope * self.x\r\n        x_val = (sqrt(ellipse.A**4 * ellipse.B**2 * self.slope**2 + ellipse.A**2 * ellipse.B**4 - ellipse.A**2 * ellipse.B**2 * y_intercept**2) - ellipse.A**2 * y_intercept * self.slope) \/ (ellipse.A**2 * self.slope**2 + ellipse.B**2)\r\n        y_val = self.slope * x_val + y_intercept\r\n        return (x_val, y_val)\r\n\r\n    def get_speed_components(self):\r\n        angle = atan2(self.collision_point[1] - self.y, self.collision_point[0] - self.x)\r\n        speed_x = self.speed * cos(angle)\r\n        speed_y = self.speed * sin(angle)\r\n        return speed_x, speed_y\r\n\r\n    def update_position(self):\r\n        self.check_collision()\r\n        self.x += self.speed_x\r\n        self.y += self.speed_y\r\n        if self.phase &lt; 3:\r\n            self.movement_history.append((self.x, self.y))\r\n        else:\r\n            self.movement_history.append((None, None))\r\n\r\n    def check_collision(self):\r\n        if self.phase == 1 and sqrt((self.collision_point[1] - self.y)**2 + (self.collision_point[0] - self.x)**2) &lt; self.speed:\r\n            self.phase = 2\r\n            self.x = self.collision_point[0]\r\n            self.y = self.collision_point[1]\r\n            self.movement_history.append((self.x, self.y))\r\n            tangent_line_slope = -(ellipse.B ** 2 \/ ellipse.A ** 2) * (self.x \/ self.y)\r\n            normal_line_slope = -1 \/ tangent_line_slope\r\n            normal_line_intercept = self.y - normal_line_slope * self.x\r\n            trajectory_point = self.get_reflected_point(normal_line_slope, -1, normal_line_intercept)\r\n            trajectory_angle = atan2(trajectory_point[1] - self.y, trajectory_point[0] - self.x)\r\n            self.speed_x = self.speed * cos(trajectory_angle)\r\n            self.speed_y = self.speed * sin(trajectory_angle)\r\n        elif self.phase == 2 and sqrt((ellipse.focus_y_right - self.y)**2 + (ellipse.focus_x_right - self.x)**2) &lt; self.speed:\r\n            self.phase = 3\r\n            self.x = 999\r\n            self.y = 999\r\n\r\n    def get_reflected_point(self, a, b, c):\r\n        point_x, point_y = ellipse.focus_x_left, ellipse.focus_y_left\r\n        d = (a * point_x + b * point_y + c) \/ (a**2 + b**2)\r\n        x_reflected = point_x - 2 * a * d\r\n        y_reflected = point_y - 2 * b * d\r\n        return x_reflected, y_reflected\r\n\r\n\r\nellipse = Ellipse(a=5, b=3)\r\n\r\nparticle = Particle(initial_slope=-10, speed=0.1)\r\n\r\nparticle_list = [particle]\r\n\r\nadditional_particles = {20: -1, 40: -0.5, 60: -0.1, 80: 0.2, 100: 0.4, 120: 1.2, 140: 7}\r\n\r\nplt.style.use(\"wollen_ellipse.mplstyle\")\r\n\r\nfor frame_counter in range(250):\r\n    print(frame_counter)\r\n\r\n    fig, ax = plt.subplots()\r\n\r\n    ax.plot(ellipse.x, ellipse.y_upper, color=\"#444\", linewidth=2.0, zorder=1)\r\n    ax.plot(ellipse.x, ellipse.y_lower, color=\"#444\", linewidth=2.0, zorder=1)\r\n\r\n    ax.scatter([ellipse.focus_x_left, ellipse.focus_x_right],\r\n               [ellipse.focus_y_left, ellipse.focus_y_right],\r\n               color=\"None\", s=80, edgecolor=\"#444\", linewidth=1.8, zorder=1)\r\n\r\n    for particle in particle_list:\r\n        particle.update_position()\r\n\r\n        ax.scatter([particle.x], [particle.y], color=\"#4C4\", s=50, edgecolor=\"#444\", linewidth=1.0, zorder=3)\r\n\r\n        ax.plot([pos[0] for pos in particle.movement_history[-10:]],\r\n                [pos[1] for pos in particle.movement_history[-10:]],\r\n                color=\"#4C4\", linewidth=1.5, zorder=2)\r\n\r\n    ticks = range(-ellipse.A, ellipse.A + 1)\r\n    window_limits = (-ellipse.A * 1.1, ellipse.A * 1.1)\r\n    ax.set(xticks=ticks,\r\n           yticks=ticks,\r\n           xlim=window_limits,\r\n           ylim=window_limits)\r\n\r\n    ax.text(x=0, y=ellipse.A * 0.98,\r\n            s=r'$\\frac{x^2}{' + f'{ellipse.A}' + r'^2} + \\frac{y^2}{'  + f'{ellipse.B}' + r'^2} = 1$',\r\n            size=18, ha=\"center\", va=\"top\")\r\n\r\n    plt.savefig(f\"frames\/{frame_counter}.png\")\r\n\r\n    if frame_counter in additional_particles:\r\n        particle_list.append(Particle(initial_slope=additional_particles[frame_counter], speed=0.1))\r\n\r\n    frame_counter += 1<\/pre>\n<p>&nbsp;<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I think everyone knows what an ellipse is. It&#8217;s an elongated, squished circle that looks kind of like an egg. In mathematical terms it&#8217;s considered a conic section. That means you can slice through a cone and trace an ellipse,<\/p>\n","protected":false},"author":1,"featured_media":3274,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[40,561],"tags":[451,387,437,464,310,457,456,461,22,454,449,221,450,220,462,86,438,458,460,47,24,126,459,311,453,452,25,455,463,384,141],"class_list":["post-2131","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-math","category-animated","tag-animate","tag-animated","tag-animation","tag-bisect","tag-class","tag-collision","tag-collision-detection","tag-component","tag-data","tag-deflection","tag-ellipse","tag-equation","tag-focus","tag-formula","tag-frame","tag-geometry","tag-gif","tag-hit-box","tag-latex","tag-math","tag-matplotlib","tag-mplstyle","tag-object","tag-oop","tag-particle","tag-projectile","tag-python","tag-reflection","tag-reflective-property","tag-vector","tag-visualization"],"_links":{"self":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2131","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/comments?post=2131"}],"version-history":[{"count":53,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2131\/revisions"}],"predecessor-version":[{"id":3366,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2131\/revisions\/3366"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media\/3274"}],"wp:attachment":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media?parent=2131"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/categories?post=2131"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/tags?post=2131"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}