Creating animations is a great way to pair math concepts with python programming skills. This article explores an animated fish that swims across the screen, following your mouse. We’ll break down the math principles behind it and show how Python implements them.

Setting Up the Environment
Before diving into the code, create a virtual environment to keep the project dependencies isolated.
On Windows:
python -m venv fish_env
fish_env\Scripts\activate
pip install pygame scipy
On Linux:
python3 -m venv fish_env
source fish_env/bin/activate
pip install pygame scipy
The Math Behind the Animation
Mathematics is at the core of this fish animation. The code utilizes trigonometry, coordinate transformations, and spline interpolation to bring the fish to life.
1. Trigonometry and Angle Constraints
The fish’s spine consists of connected joints (or segments), calculated dynamically using trigonometry. This creates a chain that mimics the motion of a swimming fish.
Each joint’s position is computed using the angle between two points:
dx = pos[0] - self.joints[0][0]
dy = pos[1] - self.joints[0][1]
angle = math.atan2(dy, dx)
Here, atan2
calculates the angle between the x-axis and the line connecting two points. By iteratively applying this calculation across all spine joints, the fish forms smooth curves as it follows the mouse.
Angle Constraints: To avoid unrealistic bending, the angles between consecutive joints are constrained:
angle = self.constrain_angle(angle, self.angles[i - 1], self.angle_constraint)
The constrain_angle
method ensures that each segment bends within a specified range (angle_constraint
).
2. Smoothing the Fish’s Body
The fish’s body outline is drawn by tracing the positions of the spine joints and their offsets, forming a loop around the spine. Without smoothing, the body looks jagged as each connection is visible.
The scipy.interpolate.splprep
function generates smooth curves:
tck, _ = splprep([x, y], s=2, per=True)
x_smooth, y_smooth = splev(np.linspace(0, 1, 100), tck)
This code takes raw points (x
, y
) and interpolates them to create a smooth boundary. These points are then used to draw the fish’s body and tail.
3. Animating the Tail
The tail’s motion is calculated from the last four joints of the spine. Its width dynamically changes based on the fish’s bending. To make the tail’s edge smooth, spline interpolation is applied again:
tail_points = [...]
tck, _ = splprep([x, y], s=3)
x_smooth, y_smooth = splev(np.linspace(0, 1, 100), tck)
This smoothing ensures that the tail blends seamlessly with the fish’s body, even during sharp turns.
4. Eyes Following the Head
The eyes are positioned inside the head using trigonometric transformations. Their position depends on the head’s orientation, ensuring they remain symmetrical and aligned with the front:
right_eye_x = head[0] + math.cos(head_angle) * eye_forward_offset - math.sin(head_angle) * eye_side_offset
right_eye_y = head[1] + math.sin(head_angle) * eye_forward_offset + math.cos(head_angle) * eye_side_offset
This calculation shifts the eyes relative to the head’s center, maintaining proportional spacing and avoiding clipping outside the body.
Building the Fish
With the math foundations in place, let’s discuss how the Python code brings it all together.
1. The Chain Class
This class models the fish’s spine as a chain of connected segments:
class Chain:
def __init__(self, origin, joint_count, link_size, angle_constraint=math.pi):
self.link_size = link_size
self.joints = [origin[:]]
self.angles = [0.0]
for _ in range(1, joint_count):
last_joint = self.joints[-1]
self.joints.append([last_joint[0], last_joint[1] + link_size])
self.angles.append(0.0)
Each segment is linked to the next, with its position calculated based on the angle and length.
2. The Fish Class
The Fish
class handles drawing and animating the fish:
- Body: The body is drawn by tracing the spine and calculating offsets using trigonometry.
- Tail: The tail points are dynamically adjusted and smoothed with splines.
- Eyes: Eyes are positioned relative to the head using transformations.
3. Interaction with the Mouse
The fish follows the mouse using the resolve
method:
def resolve(self, mouse_x, mouse_y):
head = self.spine.joints[0]
target = [
head[0] + (mouse_x - head[0]) * 0.2,
head[1] + (mouse_y - head[1]) * 0.2,
]
self.spine.resolve(target)
This adds a subtle delay, giving the fish a natural, fluid motion.
Running the Animation
Run the code by typing:
python animated_fish.py
Move your mouse across the screen, and watch the fish follow. Notice how its body curves and tail sways. These motions are powered by the math we explored.
Extensions
This animation is a great starting point for learning about math in graphics. Here are a few ideas for extending the project:
- Add textures or patterns to the fish body.
- Introduce more fish that interact with each other.
- Create a background scene with water effects.
The “Animated Math Fish” shows how math can bring programming to life. By combining trigonometry, splines, and Python libraries, you’ve not only animated a fish but also deepened your understanding of mathematical modeling.
The Full Code:
import pygame
import math
from scipy.interpolate import splprep, splev # For smoothing the body outline
# Initialize pygame
pygame.init()
# Screen dimensions
WIDTH, HEIGHT = 800, 600
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Animated Math Fish")
# Colors
BODY_COLOR = (58, 124, 165)
FIN_COLOR = (129, 195, 215)
WHITE = (255, 255, 255)
# Clock for frame rate
clock = pygame.time.Clock()
class Chain:
def __init__(self, origin, joint_count, link_size, angle_constraint=math.pi):
self.link_size = link_size
self.angle_constraint = angle_constraint
self.joints = [origin[:]] # List of [x, y] positions
self.angles = [0.0] # List of angles for each joint
for _ in range(1, joint_count):
last_joint = self.joints[-1]
self.joints.append([last_joint[0], last_joint[1] + link_size])
self.angles.append(0.0)
def resolve(self, pos):
# Forward pass: Set head position and propagate
dx = pos[0] - self.joints[0][0]
dy = pos[1] - self.joints[0][1]
self.angles[0] = math.atan2(dy, dx)
self.joints[0] = pos[:]
for i in range(1, len(self.joints)):
dx = self.joints[i - 1][0] - self.joints[i][0]
dy = self.joints[i - 1][1] - self.joints[i][1]
angle = math.atan2(dy, dx)
angle = self.constrain_angle(angle, self.angles[i - 1], self.angle_constraint)
self.angles[i] = angle
self.joints[i][0] = self.joints[i - 1][0] - math.cos(angle) * self.link_size
self.joints[i][1] = self.joints[i - 1][1] - math.sin(angle) * self.link_size
@staticmethod
def constrain_angle(angle, target_angle, constraint):
diff = (angle - target_angle + math.pi) % (2 * math.pi) - math.pi
return target_angle + max(-constraint, min(constraint, diff))
class Fish:
def __init__(self, x, y):
self.spine = Chain([x, y], 12, 64, math.pi / 8)
self.body_width = [68, 81, 84, 83, 77, 64, 51, 38, 32, 19]
def resolve(self, mouse_x, mouse_y):
head = self.spine.joints[0]
target = [
head[0] + (mouse_x - head[0]) * 0.2,
head[1] + (mouse_y - head[1]) * 0.2,
]
self.spine.resolve(target)
def smooth_outline(self, points):
# Smooth points using spline interpolation
x, y = zip(*points)
tck, _ = splprep([x, y], s=2, per=True)
x_smooth, y_smooth = splev(np.linspace(0, 1, 100), tck)
return list(zip(x_smooth, y_smooth))
def draw_eyes(self, head, head_angle):
# Head circle radius
radius = self.body_width[0] / 2 # Radius of the head (blue area)
# Place eyes fully inside the head circle
eye_forward_offset = radius * -1.5 # Slightly forward within the head
eye_side_offset = radius * 0.5 # Constrain lateral distance inside the head
# Right eye
right_eye_x = head[0] + math.cos(head_angle) * eye_forward_offset - math.sin(head_angle) * eye_side_offset
right_eye_y = head[1] + math.sin(head_angle) * eye_forward_offset + math.cos(head_angle) * eye_side_offset
# Left eye
left_eye_x = head[0] + math.cos(head_angle) * eye_forward_offset + math.sin(head_angle) * eye_side_offset
left_eye_y = head[1] + math.sin(head_angle) * eye_forward_offset - math.cos(head_angle) * eye_side_offset
# Draw the eyes
pygame.draw.circle(screen, WHITE, (int(right_eye_x), int(right_eye_y)), 6)
pygame.draw.circle(screen, WHITE, (int(left_eye_x), int(left_eye_y)), 6)
def draw(self):
# Helper functions for positioning
def get_pos_x(i, angle_offset, length_offset):
return self.spine.joints[i][0] + math.cos(self.spine.angles[i] + angle_offset) * (self.body_width[i] + length_offset)
def get_pos_y(i, angle_offset, length_offset):
return self.spine.joints[i][1] + math.sin(self.spine.angles[i] + angle_offset) * (self.body_width[i] + length_offset)
# Draw fins first (underneath the body)
pygame.draw.ellipse(
screen,
FIN_COLOR,
(
get_pos_x(3, math.pi / 3, 0) - 80,
get_pos_y(3, math.pi / 3, 0) - 32,
160,
64,
),
)
pygame.draw.ellipse(
screen,
FIN_COLOR,
(
get_pos_x(3, -math.pi / 3, 0) - 80,
get_pos_y(3, -math.pi / 3, 0) - 32,
160,
64,
),
)
# Draw body outline
points = []
for i in range(10):
points.append((get_pos_x(i, math.pi / 2, 0), get_pos_y(i, math.pi / 2, 0)))
points.append((get_pos_x(9, math.pi, 0), get_pos_y(9, math.pi, 0)))
for i in range(9, -1, -1):
points.append((get_pos_x(i, -math.pi / 2, 0), get_pos_y(i, -math.pi / 2, 0)))
# Smooth outline
smoothed_points = self.smooth_outline(points)
pygame.draw.polygon(screen, BODY_COLOR, smoothed_points)
# Draw tail
# Draw tail with smoother transitions
tail_points = []
for i in range(8, 12):
tail_width = 1.5 * (i - 8) ** 2
tail_points.append(
(
self.spine.joints[i][0] + math.cos(self.spine.angles[i] - math.pi / 2) * tail_width,
self.spine.joints[i][1] + math.sin(self.spine.angles[i] - math.pi / 2) * tail_width,
)
)
for i in range(11, 7, -1):
tail_width = 13 # Ensure symmetry in width
tail_points.append(
(
self.spine.joints[i][0] + math.cos(self.spine.angles[i] + math.pi / 2) * tail_width,
self.spine.joints[i][1] + math.sin(self.spine.angles[i] + math.pi / 2) * tail_width,
)
)
# Smooth the tail points using splprep
x, y = zip(*tail_points)
tck, _ = splprep([x, y], s=3) # Adjust s for smoother curves
x_smooth, y_smooth = splev(np.linspace(0, 1, 100), tck)
smooth_tail_points = list(zip(x_smooth, y_smooth))
pygame.draw.polygon(screen, FIN_COLOR, smooth_tail_points)
# Draw eyes based on head angle
head = self.spine.joints[0]
head_angle = self.spine.angles[0]
self.draw_eyes(head, head_angle)
def main():
fish = Fish(WIDTH // 2, HEIGHT // 2)
running = True
while running:
screen.fill((0, 0, 0))
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
mouse_x, mouse_y = pygame.mouse.get_pos()
fish.resolve(mouse_x, mouse_y)
fish.draw()
pygame.display.flip()
clock.tick(60)
pygame.quit()
if __name__ == "__main__":
import numpy as np
main()
Thank you for reading this article. I hope you found it helpful and informative. If you have any questions, or if you would like to suggest new Python code examples or topics for future tutorials, please feel free to reach out. Your feedback and suggestions are always welcome!
Happy coding!
C. C. Python Programming
You can also find this article at Medium.com